package network import io.ktor.client.* import io.ktor.client.request.* import io.ktor.client.request.forms.FormDataContent import io.ktor.client.statement.* import io.ktor.http.HttpHeaders import io.ktor.http.Parameters import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import model.KisSession import model.RankingStock import service.AutoTradingManager import java.io.ByteArrayInputStream import java.io.File import java.util.zip.ZipInputStream import javax.xml.parsers.DocumentBuilderFactory import kotlinx.serialization.encodeToString // 추가 필요 import java.nio.charset.Charset @Serializable data class StockItem( val code: String, val name: String ){} object StockUniverseLoader { // prettyPrint = true 를 주면 JSON 파일이 한 줄로 안 뭉치고 예쁘게 저장됩니다. private val json = Json { ignoreUnknownKeys = true; prettyPrint = true } private const val DEFAULT_FILE_PATH = "stocks_universe.json" fun readSafeLines(file: File): List { val eucKr = Charset.forName("EUC-KR") val utf8 = Charsets.UTF_8 // 우선 EUC-KR로 읽어봄 val lines = file.readLines(eucKr) // 첫 줄에서 한글이 깨졌는지 검사 (정규식 활용) // 한글이 하나도 없고 깨진 특수문자만 있다면 UTF-8로 재시도 val hasKorean = lines.firstOrNull()?.any { it in '\uAC00'..'\uD7A3' } ?: false return if (hasKorean) { lines } else { println("⚠️ EUC-KR에서 한글 미검출. UTF-8로 재시도합니다.") file.readLines(utf8) } } fun loadUniverse(filePath: String = DEFAULT_FILE_PATH): List> { return try { val file = File(filePath) if (!file.exists()) { println("⚠️ 파일을 찾을 수 없습니다: ${file.absolutePath}") return emptyList() } val rawJson = file.readText() val stockItems = json.decodeFromString>(rawJson) stockItems.map { it.code to it.name } } catch (e: Exception) { println("❌ 유니버스 로드 실패: ${e.message}") emptyList() } } // 💡 [신규] JSON 파일로 덮어쓰기 저장 fun saveUniverse(items: List>, filePath: String = DEFAULT_FILE_PATH) { try { val stockItems = items.map { StockItem(it.first, it.second) } val jsonString = json.encodeToString(stockItems) // File(filePath).writeText(jsonString) File(filePath).writeText(jsonString, Charsets.UTF_8) println("💾 [System] 유니버스 영구 저장 완료: 총 ${items.size}종목") } catch (e: Exception) { println("❌ 유니버스 저장 실패: ${e.message}") } } // 💡 [신규] CSV 파일을 받아 파싱, 중복 제거, 저장까지 원스톱으로 처리 fun parseAndMergeCsv(file: File, targetJsonPath: String = DEFAULT_FILE_PATH): List> { val newItems = mutableListOf>() try { val lines = readSafeLines(file) if (lines.isEmpty()) return loadUniverse(targetJsonPath) // 헤더 자동 추적 val headers = lines[0].split(",").map { it.replace("\"", "").trim() } val codeIndex = headers.indexOfFirst { it.contains("종목코드") || it.contains("코드") } val nameIndex = headers.indexOfFirst { it.contains("종목명") || it.contains("이름") } val finalCodeIdx = if (codeIndex != -1) codeIndex else 0 val finalNameIdx = if (nameIndex != -1) nameIndex else 1 for (i in 1 until lines.size) { val line = lines[i] if (line.isBlank()) continue val parts = line.split(",").map { it.replace("\"", "").trim() } if (parts.size > maxOf(finalCodeIdx, finalNameIdx)) { // 엑셀이 날려먹은 앞자리 '0' 복원 (6자리 맞춤) val rawCode = parts[finalCodeIdx].replace(Regex("[^0-9]"), "") val code = rawCode.padStart(6, '0') val name = parts[finalNameIdx] if (code.length == 6) { newItems.add(code to name) } } } // 1. 기존 데이터 불러오기 val existing = loadUniverse(targetJsonPath) // 2. 종목코드(it.first) 기준으로 완벽하게 중복 제거 병합 val mergedList = (existing + newItems).distinctBy { it.first } // 3. 파일에 덮어쓰기 (영구 저장) saveUniverse(mergedList, targetJsonPath) println("✅ CSV 병합 성공! (신규 ${mergedList.size - existing.size}건 추가됨)") return mergedList } catch (e: Exception) { println("❌ CSV 파싱 및 병합 실패: ${e.message}") return loadUniverse(targetJsonPath) // 실패 시 기존 데이터라도 반환 } } } data class CorpInfo( var cCode : String = "", var cName : String = "", var stockCode : String = "", var stockName : String = "", ) object DartCodeManager { private val corpCodeMap = mutableMapOf() private var DART_API_KEY = KisSession.config.dAppKey // 지범님의 API 키 입력 private fun saveXmlDebugFile(xmlBytes: ByteArray) { try { val debugFile = java.io.File("debug_CORPCODE.xml") debugFile.writeBytes(xmlBytes) println("💾 [디버그] XML 파일 저장 완료: ${debugFile.absolutePath}") // M3 Pro 환경에서는 파일 쓰기가 거의 즉시 완료됩니다. } catch (e: Exception) { println("⚠️ [디버그] 파일 저장 실패: ${e.message}") } } // suspend fun fetchKrxData(client: HttpClient) { // val url = "https://data.krx.co.kr/comm/bldAttendant/getJsonData.cmd" // // val response: HttpResponse = client.post(url) { // // Headers 설정 // header(HttpHeaders.Accept, "application/json, text/javascript, */*; q=0.01") // header(HttpHeaders.AcceptLanguage, "en-US,en;q=0.9,ko-KR;q=0.8,ko;q=0.7") // header("X-Requested-With", "XMLHttpRequest") // header(HttpHeaders.Referrer, "https://data.krx.co.kr/contents/MDC/MDI/mdiLoader/index.cmd?menuId=MDC0302") // header(HttpHeaders.UserAgent, "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36") // // // Body 설정 (application/x-www-form-urlencoded) // setBody(FormDataContent(Parameters.build { // append("bld", "dbms/MDC/EASY/ranking/MDCEASY01601") // append("locale", "ko_KR") // append("mktId", "ALL") // append("itmTpCd3", "2") // append("itmTpCd2", "1") // append("strtDd", "20250402") // append("endDd", "20260402") // append("stkprcTpCd", "Y") // append("share", "1") // append("money", "1") // append("csvxls_isNo", "false") // })) // } // // val responseBody = response.bodyAsText() // println(responseBody) // } /** * 앱 실행 시 호출하여 매핑 테이블 업데이트 */ suspend fun updateCorpCodes(client: HttpClient) { println("📂 [DART] 법인코드 매핑 데이터 업데이트 시작...") try { val url = "https://opendart.fss.or.kr/api/corpCode.xml?crtfc_key=$DART_API_KEY" val response: HttpResponse = client.get(url) val zipBytes = response.readBytes() // val zipFile = File("dart_corp_codes.zip") // zipFile.writeBytes(zipBytes) // println("💾 [디버그] 원본 ZIP 저장 완료: ${zipFile.absolutePath} (${zipBytes.size} bytes)") ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis -> var entry = zis.nextEntry while (entry != null) { if (entry.name == "CORPCODE.xml") { // saveXmlDebugFile(zipBytes) parseXml(zis.readAllBytes()) break } entry = zis.nextEntry } } println("✅ [DART] 매핑 완료: ${corpCodeMap.size}개의 상장사 로드됨") } catch (e: Exception) { println("❌ [DART] 법인코드 업데이트 실패: ${e.message}") } } private fun parseXml(xmlBytes: ByteArray) { val factory = DocumentBuilderFactory.newInstance() val builder = factory.newDocumentBuilder() val doc = builder.parse(ByteArrayInputStream(xmlBytes)) val nodeList = doc.getElementsByTagName("list") for (i in 0 until nodeList.length) { val element = nodeList.item(i) as org.w3c.dom.Element val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: "" val corpCode = element.getElementsByTagName("corp_code").item(0)?.textContent ?: "" val corpName = element.getElementsByTagName("corp_name").item(0)?.textContent ?: "" // println("[$corpName]stockCode: $stockCode , corpCode: $corpCode") // 종목코드(stock_code)가 있는 상장사만 매핑에 추가 if (stockCode.isNotEmpty()) { corpCodeMap[stockCode] = CorpInfo(corpCode, corpName, stockCode) // AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode, hts_kor_isnm = corpName)) } } } fun getStockCodez() : Array = corpCodeMap.keys.toTypedArray() /** * 6자리 종목코드로 8자리 법인코드 반환 */ fun getCorpCode(stockCode: String): CorpInfo? { // 1. 직접 매칭 시도 corpCodeMap[stockCode]?.let { return it } // 2. 우선주 규칙 적용 (마지막 자리가 5, 7, 9인 경우 0으로 변경) if (stockCode.length == 6 && stockCode.last() in listOf('5', '7', '9')) { val commonStockCode = stockCode.substring(0, 5) + "0" corpCodeMap[commonStockCode]?.let { println("ℹ️ [DART] 우선주($stockCode)를 보통주($commonStockCode) 코드로 매핑 성공") return it } } return null } }