255 lines
10 KiB
Kotlin
255 lines
10 KiB
Kotlin
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<String> {
|
||
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<Pair<String, String>> {
|
||
return try {
|
||
val file = File(filePath)
|
||
if (!file.exists()) {
|
||
println("⚠️ 파일을 찾을 수 없습니다: ${file.absolutePath}")
|
||
return emptyList()
|
||
}
|
||
val rawJson = file.readText()
|
||
val stockItems = json.decodeFromString<List<StockItem>>(rawJson)
|
||
stockItems.map { it.code to it.name }
|
||
} catch (e: Exception) {
|
||
println("❌ 유니버스 로드 실패: ${e.message}")
|
||
emptyList()
|
||
}
|
||
}
|
||
|
||
// 💡 [신규] JSON 파일로 덮어쓰기 저장
|
||
fun saveUniverse(items: List<Pair<String, String>>, 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<Pair<String, String>> {
|
||
val newItems = mutableListOf<Pair<String, String>>()
|
||
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<String, CorpInfo>()
|
||
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<String> = 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
|
||
}
|
||
} |