From ce976a895d7c72b447f677a6046471527e58410b Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Mon, 9 Feb 2026 15:32:31 +0900 Subject: [PATCH] .. --- src/main/kotlin/model/AppConfig.kt | 2 +- src/main/kotlin/model/StockModels.kt | 57 +++-- src/main/kotlin/network/KisTradeService.kt | 137 +++++------ src/main/kotlin/network/NewsService.kt | 23 +- src/main/kotlin/service/AutoTradingManager.kt | 31 ++- src/main/kotlin/service/DynamicNewsScraper.kt | 219 ++++++++++++------ src/main/kotlin/ui/IntegratedOrderSection.kt | 104 ++++++++- 7 files changed, 390 insertions(+), 183 deletions(-) diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index ddff93c..a2f1757 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -3,7 +3,7 @@ package model import java.time.LocalDateTime const val feesAndTaxRate = 0.33 -const val minimumNetProfit = 0.5 +const val minimumNetProfit = 0.35 const val buyWeight = 2.0 data class AppConfig( diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index 8dbcf71..2363bab 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -42,27 +42,54 @@ data class RankingResponse( val list = output + output1 + emptyList() } +//@Serializable enum class RankingType( val title: String, val trId: String, val scrNo: String, val path: String, - val sortCode: String // 추가: 각 TR ID에 맞는 정렬 코드 + val extraParams: Map ) { - VOLUME("거래량순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "0"), - VALUE("거래대금순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "3"), - RISE("상승률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "0"), - FALL("하락률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "1"), - // MARKET_CAP("시가총액순", "FHPST01740000", "20174", "/uapi/domestic-stock/v1/quotations/market-cap", "0"), -// HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", "0"), - // 링크로 전달주신 추가 기능 보완 - VOLUME_POWER("체결강도순", "FHPST01680000", "20168", "/uapi/domestic-stock/v1/ranking/volume-power", "0"), -// BEFORE("장전예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "0"), -// AFTER("장후예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "1") - AFTER_HOURS_VOLUME("시간외거래량", "FHPST01810000", "20181", "/uapi/domestic-stock/v1/rankingafterhours-volume", "0"), - EXPECTED_RISE("예상상승", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "0"), - NEW_HIGH("52주신고가", "FHPST01690000", "20169", "/uapi/domestic-stock/v1/rankingsh-52w-high", "0"), - COMPANY_TRADE("당사매매", "FHPST01860000", "20187", "/uapi/domestic-stock/v1/ranking/traded-by-company", "0") + // [1] 등락률 (RISE, FALL) + // FID_INPUT_CNT_1: 0 (전체/당일), FID_TRGT_CLS_CODE: 11111111 (전체) + RISE("상승률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", + mapOf("FID_RANK_SORT_CLS_CODE" to "0", "FID_TRGT_CLS_CODE" to "11111111", "FID_INPUT_CNT_1" to "0")), + FALL("하락률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", + mapOf("FID_RANK_SORT_CLS_CODE" to "1", "FID_TRGT_CLS_CODE" to "11111111", "FID_INPUT_CNT_1" to "0")), + + // [2] 당사매매 (COMPANY_TRADE) - 날짜 빈값 = 당일 + COMPANY_TRADE("당사매매", "FHPST01860000", "20186", "/uapi/domestic-stock/v1/ranking/traded-by-company", + mapOf("FID_RANK_SORT_CLS_CODE" to "1", "FID_INPUT_DATE_1" to "", "FID_INPUT_DATE_2" to "", "FID_APLY_RANG_VOL" to "0", "FID_TRGT_CLS_CODE" to "11111111")), + + // [3] 거래량/거래대금 + VOLUME("거래량순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", + mapOf("FID_RANK_SORT_CLS_CODE" to "2", "FID_TRGT_CLS_CODE" to "11111111")), + VALUE("거래대금순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", + mapOf("FID_RANK_SORT_CLS_CODE" to "3", "FID_TRGT_CLS_CODE" to "11111111")), + + // [4] 기타 랭킹 (예상체결만 FID_MK_OP_CLS_CODE 필요) + EXPECTED_RISE("예상상승", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", + mapOf("FID_MK_OP_CLS_CODE" to "0", "FID_TRGT_CLS_CODE" to "11111111")), + + NEW_HIGH("52주신고가", "FHPST01690000", "20169", "/uapi/domestic-stock/v1/ranking/new-high-new-low", + mapOf("FID_RANK_SORT_CLS_CODE" to "0", "FID_TRGT_CLS_CODE" to "11111111")), + + FINANCE("재무비율순", "FHPST01750000", "20175", "/uapi/domestic-stock/v1/ranking/finance-ratio", + mapOf("FID_RANK_SORT_CLS_CODE" to "0", "FID_TRGT_CLS_CODE" to "0", "FID_INPUT_OPTION_1" to "2023", "FID_INPUT_OPTION_2" to "3")), + + MARKET_VALUE("시장가치순", "FHPST01790000", "20179", "/uapi/domestic-stock/v1/ranking/market-value", + mapOf("FID_RANK_SORT_CLS_CODE" to "23", "FID_TRGT_CLS_CODE" to "0", "FID_INPUT_OPTION_1" to "2023", "FID_INPUT_OPTION_2" to "3")), + + SHORT_SALE("공매도상위", "FHPST04820000", "20482", "/uapi/domestic-stock/v1/ranking/short-sale", + mapOf("FID_PERIOD_DIV_CODE" to "D", "FID_SELECT_DIV_CODE" to "1", "FID_INPUT_CNT_1" to "0", "FID_TRGT_CLS_CODE" to "0")), + + VOLUME_POWER("체결강도순", "FHPST01680000", "20168", "/uapi/domestic-stock/v1/ranking/volume-power", + mapOf("FID_TRGT_CLS_CODE" to "11111111")), + + MARKET_CAP("시가총액순", "FHPST01740000", "20174", "/uapi/domestic-stock/v1/ranking/market-cap", + mapOf("FID_TRGT_CLS_CODE" to "0")), + + HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", emptyMap()) } @Serializable diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 7c18107..13f85c2 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -10,6 +10,8 @@ import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.client.request.* +import io.ktor.client.statement.bodyAsText +import io.ktor.client.statement.request import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json @@ -112,98 +114,73 @@ object KisTradeService { */ private suspend fun fetchDomesticRanking(type: RankingType): Result> { val config = KisSession.config + val jsonParser = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } + return try { - val response = client.get("$prodUrl${type.path}") { + val response = client.get("${prodUrl}${type.path}") { header("authorization", "Bearer ${config.marketToken}") header("appkey", config.realAppKey) header("appsecret", config.realSecretKey) header("tr_id", type.trId) header("custtype", "P") + header("Accept", "application/json") - parameter("FID_COND_MRKT_DIV_CODE", "J") - parameter("FID_COND_SCR_DIV_CODE", type.scrNo) - parameter("FID_INPUT_ISCD", "0000") // 전체 시장 - parameter("FID_DIV_CLS_CODE", "0") // 전체 + // [Step 1] 파라미터 기본값 재설정 (성공했던 원본 코드 기준) + val params = mutableMapOf( + "FID_COND_MRKT_DIV_CODE" to "J", + "FID_COND_SCR_DIV_CODE" to type.scrNo, + "FID_INPUT_ISCD" to "0000", + "FID_DIV_CLS_CODE" to "0", + "FID_BLNG_CLS_CODE" to "0", + "FID_RANK_SORT_CLS_CODE" to "0", + // "FID_MK_OP_CLS_CODE" 삭제! (이게 있으면 RISE/FALL이 깨짐) + "FID_PRC_CLS_CODE" to "0", + "FID_INPUT_CNT_1" to "0", // 1이 아니라 0이어야 함 + "FID_INPUT_PRICE_1" to "", + "FID_INPUT_PRICE_2" to "", + "FID_VOL_CNT" to "", + "FID_RSFL_RATE1" to "", + "FID_RSFL_RATE2" to "", // 누락되었던 파라미터 추가 + "FID_INPUT_DATE_1" to "", + "FID_INPUT_DATE_2" to "", + "FID_APLY_RANG_VOL" to "", + "FID_APLY_RANG_PRC_1" to "", + "FID_APLY_RANG_PRC_2" to "", + "FID_PERIOD_DIV_CODE" to "", + "FID_SELECT_DIV_CODE" to "", + "FID_INPUT_OPTION_1" to "", + "FID_INPUT_OPTION_2" to "", + "FID_TRGT_CLS_CODE" to "11111111", + "FID_TRGT_EXLS_CLS_CODE" to "000000" + ) - parameter("FID_ETC_CLS_CODE", "0") - parameter("FID_PRC_CLS_CODE", "0") - when(type) { - RankingType.VALUE -> { - parameter("FID_BLNG_CLS_CODE", type.sortCode) - } - RankingType.VOLUME -> { - parameter("FID_BLNG_CLS_CODE",type.sortCode) - } - RankingType.FALL -> { - parameter("FID_RANK_SORT_CLS_CODE", type.sortCode) + // [Step 2] Enum 특화 설정 덮어쓰기 + params.putAll(type.extraParams) - } - RankingType.RISE -> { - parameter("FID_RANK_SORT_CLS_CODE", type.sortCode) - } -// RankingType.AFTER -> { -// parameter("FID_MKOP_CLS_CODE", type.sortCode) -// } -// RankingType.BEFORE -> { -// parameter("FID_MKOP_CLS_CODE", type.sortCode) -// } -// RankingType.FOREIGNER_BUY, RankingType.INSTITUTION_BUY -> { -// parameter("FID_BLNG_CLS_CODE", type.sortCode) // 순매수용 -// parameter("FID_INPUT_CNT_1", "5") // 5일 누적 등 조정 가능 -// } -// RankingType.PER_RANK, RankingType.PBR_RANK -> { -// parameter("FID_FINCL_CLS_CODE", type.sortCode) // 재무비율용 -// } - RankingType.AFTER_HOURS_VOLUME -> { - parameter("FID_TIME_OUT_CLS_CODE", "1") // 시간외 구분 - } - RankingType.EXPECTED_RISE -> parameter("FID_MK_OP_CLS_CODE", type.sortCode) - // 체결강도/호가 등은 FID_EXEC_CLS_CODE 추가 - else -> parameter("FID_BLNG_CLS_CODE", "0") // 기본 -// else -> { -// -// } - } - parameter("FID_PBMN", "") - parameter("FID_APLY_RANG_PRC_1", "") - parameter("FID_TRGT_CLS_CODE", "11111111") - parameter("FID_TRGT_EXLS_CLS_CODE", "000000") - parameter("FID_RSFL_RATE2", "") - parameter("FID_RSFL_RATE1", "") - parameter("FID_INPUT_CNT_1", "0") - parameter("FID_INPUT_PRICE_1", "") - parameter("FID_INPUT_PRICE_2", "") - parameter("FID_VOL_CNT", "") - parameter("FID_INPUT_DATE_1", "") - - - - - - - - // 상승/하락률 순위(HHPST01710000)일 경우 추가 파라미터 - if (type.trId == "HHPST01710000") { - parameter("fid_diff_div_code", "00") // 00: 전일 대비 - } + // [Step 3] 적용 + params.forEach { (key, value) -> parameter(key, value) } } - val body = response.body() - if (listOf( - RankingType.VOLUME_POWER, - RankingType.EXPECTED_RISE, - RankingType.COMPANY_TRADE - ).contains(type)) { - println("${type.name} , ${body}" ) + + val rawJson = response.bodyAsText() + val body = try { + jsonParser.decodeFromString(rawJson) + } catch (e: Exception) { + println("[ERROR] ${type.title} 파싱 실패. Raw: $rawJson") + return Result.failure(e) + } + + if (body.rt_cd == "0") { + Result.success(body.list ?: emptyList()) + } else { + // rt_cd가 비어있다면 에러 메시지도 없을 수 있으므로 Raw Body 일부를 포함하여 예외 처리 + val errorMsg = if (body.msg1.isNotBlank()) body.msg1 else "응답 코드 없음 (Raw: $rawJson)" + Result.failure(Exception(errorMsg)) } - if (body.rt_cd == "0") Result.success(body.list) else Result.failure(Exception(body.msg1)) } catch (e: Exception) { - if (listOf( - RankingType.VOLUME_POWER, - RankingType.EXPECTED_RISE, - RankingType.COMPANY_TRADE - ).contains(type)) { - println("${type.name} , ${e.message}" ) - } Result.failure(e) } } diff --git a/src/main/kotlin/network/NewsService.kt b/src/main/kotlin/network/NewsService.kt index 723b17f..533e2a0 100644 --- a/src/main/kotlin/network/NewsService.kt +++ b/src/main/kotlin/network/NewsService.kt @@ -13,6 +13,7 @@ import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.ContentType.Application.Json +import io.ktor.http.Url import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import model.DartFinancialResponse @@ -36,17 +37,29 @@ object NewsService { suspend fun fetchAndIngestNews(corpInfo: CorpInfo) { val clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요 val clientSecret = "DODCxb1M4Z" - var qlist = listOf("${corpInfo.stockName} 분석","${corpInfo.stockName}[${corpInfo.stockCode}]", "${corpInfo.cName} 최근 동향", "${corpInfo.cName}") - qlist.forEach { query -> + val qlistNews = listOf( + "${corpInfo.stockName} 주가", + "${corpInfo.stockName} 실적", + "${corpInfo.stockName} 공시", + "${corpInfo.stockName} 이벤트" + ) + + val qlistCorpTrend = listOf( + "${corpInfo.cName} 최근 동향", + "${corpInfo.cName} 이슈", + "${corpInfo.cName} 투자", + "${corpInfo.cName} 실적" + ) + (qlistNews + qlistCorpTrend).forEach { query -> try { val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") { parameter("query", query) - parameter("display", 3) // 최근 10개 뉴스 - parameter("sort", "sim") // 유사도 순 (또는 date 발간순) + parameter("display", 5) // 최근 10개 뉴스 + parameter("sort", "date") // 유사도 순 (또는 date 발간순) header("X-Naver-Client-Id", clientId) header("X-Naver-Client-Secret", clientSecret) }.body() - SafeScraper.scrapeParallel(corpInfo,response.items) + SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(5) ) } catch (e: Exception) { println("❌ 뉴스 가져오기 실패: ${e.message}") } diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index d7ff5bd..7b105d8 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -91,16 +91,26 @@ object AutoTradingManager { val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } // [프로세스 2] 후보군 수집 - val candidates = fetchCandidates(tradeService) + val candidates = fetchCandidates(tradeService).apply { + println("후보군 총 개수 : $size") + } .filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE } .filter { it.code !in myHoldings && it.code !in pendingStocks } .distinctBy { it.code } + .apply { + println("후보군 조건 충족 총 개수 : $size") + } // [프로세스 3] 종목별 순회 분석 candidates.forEach { stock -> - lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고 - processSingleStock(stock, myCash, tradeService, callback) - delay(300) + try { + lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고 + processSingleStock(stock, myCash, tradeService, callback) + } catch (e: Exception) { + + }finally { + delay(300) + } } println("⏱️ [Cycle End] ${LocalTime.now()}") @@ -163,14 +173,23 @@ object AutoTradingManager { } private suspend fun fetchCandidates(tradeService: KisTradeService): List = coroutineScope { + + listOf( +// async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) }, +// async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) }, +// async { tradeService.fetchMarketRanking(RankingType.RISE2, true).getOrDefault(emptyList()) }, +// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) }, -// async { tradeService.fetchMarketRanking(RankingType.EXPECTED_RISE, true).getOrDefault(emptyList()) }, -// async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) } + async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) }, + async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) }, + async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) }, + async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) }, + async { tradeService.fetchMarketRanking(RankingType.SHORT_SALE, true).getOrDefault(emptyList()) }, ).awaitAll().flatten() } diff --git a/src/main/kotlin/service/DynamicNewsScraper.kt b/src/main/kotlin/service/DynamicNewsScraper.kt index 1c59751..f906248 100644 --- a/src/main/kotlin/service/DynamicNewsScraper.kt +++ b/src/main/kotlin/service/DynamicNewsScraper.kt @@ -10,7 +10,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeout import model.NewsItem @@ -18,12 +20,75 @@ import network.CorpInfo import java.net.URL import kotlin.random.Random -object DynamicNewsScraper { - private val playwright by lazy { Playwright.create() } - private val browser by lazy { - playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true)) +object BrowserManager { + private var playwright: Playwright? = null + private var _browser: com.microsoft.playwright.Browser? = null + private var failCount = 0 + private const val MAX_TOTAL_FAILURES = 3 + private val mutex = Mutex() // 동시 접근 제어용 뮤텍스 + + suspend fun getBrowser(): com.microsoft.playwright.Browser { + return mutex.withLock { + // 브라우저가 없거나 연결이 끊겼다면 새로 생성 + if (_browser == null || !_browser!!.isConnected) { + startNewBrowser() + } + _browser!! + } } + suspend fun notifyFailure() { + mutex.withLock { + failCount++ + if (failCount >= MAX_TOTAL_FAILURES) { + restart() + } + } + } + + suspend fun notifySuccess() { + mutex.withLock { if (failCount > 0) failCount-- } + } + + private suspend fun restart() { // suspend 추가 + println("♻️ 브라우저 엔진을 완전히 재시작합니다...") + try { + // null 처리를 먼저 하여 다른 스레드가 getBrowser() 호출 시 대기하게 함 + val oldBrowser = _browser + val oldPlaywright = playwright + _browser = null + playwright = null + + oldBrowser?.close() + oldPlaywright?.close() + } catch (e: Exception) { + // 종료 에러 무시 + } finally { + startNewBrowser() + failCount = 0 + } + } + + private fun startNewBrowser() { + try { + playwright = Playwright.create() + _browser = playwright!!.chromium().launch( + com.microsoft.playwright.BrowserType.LaunchOptions() + .setHeadless(true) + .setArgs(listOf("--no-sandbox", "--disable-dev-shm-usage")) // 리소스 부족 방지 + ) + } catch (e: Exception) { + println("🚨 브라우저 엔진 시작 실패: ${e.message}") + } + } +} + +object DynamicNewsScraper { +// private val playwright by lazy { Playwright.create() } +// private val browser by lazy { +// playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true)) +// } + fun extractSmartContentWithLineFilter(page: Page): String { val script = """ () => { @@ -95,66 +160,81 @@ object DynamicNewsScraper { return page.evaluate(script) as String } - var failDomainList = arrayListOf() +// private fun getBrowser(): com.microsoft.playwright.Browser { +// return if (!browser.isConnected) { +// println("🔄 브라우저 연결 끊김 확인, 재시작 시도...") +// // 기존 lazy browser 대신 새 인스턴스를 할당하는 로직 필요 +// // (단순 object singleton 보다는 관리형 클래스가 유리) +// browser // 예시를 위해 유지 +// } else { +// browser +// } +// } + + var failCountMap = mutableMapOf() suspend fun fetchFullContent(url: String): String { - // browser.newContext().use { ... } 대신 직접 변수를 선언하고 제어합니다. - val domain = URL(url).host - val context = browser.newContext() - if(failDomainList.contains(domain)) { - println("실패한 도메인 스크래핑 종료 $domain ") - return "" - } + val domain = try { URL(url).host } catch (e: Exception) { return "" } + if ((failCountMap[domain] ?: 0) > 2) return "" + return try { + // 브라우저 인스턴스를 뮤텍스 보호 하에 가져옴 + val browser = BrowserManager.getBrowser() + + // Context/Page 생성 시점부터 에러 감시 + val context = browser.newContext() context.use { ctx -> ctx.newPage().use { page -> - page.setDefaultNavigationTimeout(8000.0) - delay(Random.nextInt(2000).toLong()) + page.setDefaultNavigationTimeout(15000.0) - // 1. 리스너 설정 시 예외 처리 강화 - blockUnnecessaryResources(page) + // Route 설정 시 예외 방어 +// try { +// blockUnnecessaryResources(page) +// } catch (e: Exception) { /* 브라우저 상태 이상 감지 시 catch로 이동 */ } - // 2. 타임아웃을 설정하여 무한 대기 방지 - page.navigate(url, Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)) - - // 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기 page.waitForLoadState(LoadState.LOAD) val content = cleanText(extractSmartContentWithLineFilter(page)) - // 4. 명시적으로 route를 해제하여 close 시 발생할 수 있는 리스너 충돌 방지 - page.unroute("**/*") + // 명시적으로 route 해제 (TargetClosed 에러 방지) +// try { page.unroute("**/*") } catch (e: Exception) {} + BrowserManager.notifySuccess() content } } } catch (e: Exception) { - failDomainList.add(domain) - println("❌ [Playwright] 스크래핑 실패 ($url): ${e.message}") + val msg = e.message ?: "" + // 통신 단절 관련 핵심 에러 키워드 체크 + if (msg.contains("adopt") || msg.contains("closed") || msg.contains("exist") || msg.contains("respond")) { + BrowserManager.notifyFailure() + } + + failCountMap[domain] = (failCountMap[domain] ?: 0) + 1 + // 불필요한 스택트레이스 출력을 줄이기 위해 메시지만 출력 + println("❌ [Playwright] 실패 (${url.take(30)}...): ${e.localizedMessage}") "" - } finally { - // use 블록이 자원을 닫으려 할 때 발생하는 오류는 내부적으로 처리되거나 무시되도록 유도 } } private fun blockUnnecessaryResources(page: Page) { - // 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단 page.route("**/*") { route -> try { - val req = route.request() - if (req != null) { - val type = req.resourceType() - if (type == "image" || type == "font" || type == "stylesheet") { - route.abort() - } else { - route.resume() - } + // route나 request가 이미 파기되었는지 확인 + val req = runCatching { route.request() }.getOrNull() + if (req == null) { + // 이미 브라우저가 닫히는 중이라면 무시 + return@route + } + + val type = req.resourceType() + if (type == "image" || type == "font" || type == "stylesheet") { + route.abort() } else { - // request가 이미 null이면 처리를 포기 route.resume() } } catch (e: Exception) { - + // 브라우저 종료 시 발생하는 에러는 여기서 조용히 처리 } } } @@ -165,49 +245,44 @@ object DynamicNewsScraper { .trim() } } + object SafeScraper { - // 세마포어를 2개로 유지하되, 작업당 타임아웃을 반드시 설정해야 합니다. - private val semaphore = Semaphore(4) + // 동시 처리를 1개로 줄여서 안정성을 극대화 (추천) + // Playwright는 여러 페이지를 띄울 때 CPU/메모리 점유율이 매우 높습니다. + private val semaphore = Semaphore(2) suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List) = coroutineScope { - val query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}" - - urls.map { item -> - async { - if (UrlCacheManager.isAlreadyProcessed(item.originallink)) { - // println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵") - return@async - } + urls.forEach { item -> // map + awaitAll 대신 순차 처리가 현재 상황에선 더 안정적입니다. + if (UrlCacheManager.isAlreadyProcessed(item.originallink)) { + println("✅ [학습완료 데이터 스킵] ${item.originallink}") + return@forEach + } + semaphore.withPermit { try { - // 세마포어 획득 시도에 타임아웃을 걸어 대기열 정체 방지 - semaphore.withPermit { - // 개별 뉴스 스크래핑에 최대 30~60초 제한 설정 (무한 대기 방지 핵심) - withTimeout(10000L) { - val content = DynamicNewsScraper.fetchFullContent(item.originallink) - - if (content.isNotBlank()) { - RagService.ingestWithChunking( - text = content, - newsLink = item.originallink, - pubDate = item.pubDate, - stockCode = corpInfo.stockCode, - corpName = corpInfo.cName, - corpCode = corpInfo.cCode, - stcokName = corpInfo.stockName - ) - println("✅ [학습완료] ${item.originallink}") - } + withTimeout(25000L) { // 타임아웃 약간 증가 + val content = DynamicNewsScraper.fetchFullContent(item.originallink) + if (content.isNotBlank()) { + RagService.ingestWithChunking( + text = content, + newsLink = item.originallink, + pubDate = item.pubDate, + stockCode = corpInfo.stockCode, + corpName = corpInfo.cName, + corpCode = corpInfo.cCode, + stcokName = corpInfo.stockName + ) + println("✅ [학습완료] ${item.originallink}") } } - } catch (e: TimeoutCancellationException) { - println("⏳ [타임아웃] 뉴스 읽기 시간 초과: ${item.originallink}") } catch (e: Exception) { - println("❌ [스크래핑 에러] ${item.originallink}: ${e.localizedMessage}") + println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}") } + // 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화) + delay(Random.nextLong(500, 1500)) } - }.awaitAll() - - println("🏁 $query 관련 뉴스 ${urls.size}개 처리 시도 완료") + } + println("🏁 뉴스 처리 완료") } -} \ No newline at end of file +} + diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 6977689..34e22fd 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -26,6 +26,51 @@ import model.minimumNetProfit import network.KisTradeService import util.MarketUtil +enum class InvestmentGrade( + val displayName: String, + val description: String, + val shortWeight: Double = 0.0, + val midWeight: Double = 0.0, + val longWeight: Double = 0.0 +) { + LEVEL_5_STRONG_RECOMMEND( + displayName = "최상급 추천", + description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천", + shortWeight = 1.0, + midWeight = 1.0, + longWeight = 1.0 + ), + LEVEL_4_BALANCED_RECOMMEND( + displayName = "균형 추천", + description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천", + shortWeight = 0.8, + midWeight = 1.0, + longWeight = 1.0 + ), + LEVEL_3_CAUTIOUS_RECOMMEND( + displayName = "보수적 추천", + description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함", + shortWeight = 0.6, + midWeight = 1.0, + longWeight = 1.0 + ), + LEVEL_2_HIGH_RISK( + displayName = "고위험 추천", + description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자", + shortWeight = 1.0, + midWeight = 0.4, + longWeight = 0.4 + ), + LEVEL_1_SPECULATIVE( + displayName = "순수 공격적 선택", + description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자", + shortWeight = 1.0, + midWeight = 0.2, + longWeight = 0.2 + ) +} + + /** * 통합 주문 및 자동매매 설정 섹션 * * [수정 사항] @@ -83,11 +128,62 @@ fun IntegratedOrderSection( val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0) + fun getInvestmentGrade( + ts: TradingDecision, + totalScore: Double, + confidence: Double + ): InvestmentGrade { + // 1. 기본 조건 충족 여부 + if (totalScore < 68.0 || confidence < 70.0) { + return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음) + } - fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?,confidence : Boolean = false) { + // 2. 단기/중기/장기 패턴 기준 + val ultraShort = ts.ultraShortScore + val short = ts.shortTermScore + val mid = ts.midTermScore + val long = ts.longTermScore + + val shortAvg = listOf(ultraShort, short).average() // 초단기+단기 + val midLongAvg = listOf(mid, long).average() // 중기+장기 + + return when { + // LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음 + shortAvg >= 85.0 && midLongAvg >= 80.0 -> + InvestmentGrade.LEVEL_5_STRONG_RECOMMEND + + // LEVEL_4: 중기·장기 기본 준수, 단기까지 양호 + midLongAvg >= 75.0 && shortAvg >= 70.0 -> + InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND + + // LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형 + midLongAvg >= 70.0 && shortAvg in 60.0..70.0 -> + InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND + + // LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매 + shortAvg >= 75.0 && midLongAvg < 65.0 -> + InvestmentGrade.LEVEL_2_HIGH_RISK + + // LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함 + shortAvg >= 70.0 && midLongAvg < 55.0 -> + InvestmentGrade.LEVEL_1_SPECULATIVE + + // 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립) + else -> + InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND + } + } + + fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) { scope.launch { val tickSize = MarketUtil.getTickSize(basePrice) - val oneTickLowerPrice = basePrice - (tickSize * if (confidence) { 1} else {2}) + val oneTickLowerPrice = basePrice - (tickSize * when(investmentGrade) { + InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> 0 + InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> 1 + InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> 1 + InvestmentGrade.LEVEL_2_HIGH_RISK -> 2 + InvestmentGrade.LEVEL_1_SPECULATIVE -> 3 + }) // 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용) val finalPrice = if (orderPrice.isBlank()) { @@ -183,7 +279,7 @@ fun IntegratedOrderSection( } println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") - val MAX_BUDGET = 30000.0 + val MAX_BUDGET = 35000.0 // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) val calculatedQty = if (basePrice > 0) { (MAX_BUDGET / basePrice).toInt().coerceAtLeast(1) @@ -195,7 +291,7 @@ fun IntegratedOrderSection( willEnableAutoSell = true, orderQty = calculatedQty.toString(), profitRate1 = finalMargin, - confidence = totalScore >= HIGH_QUALITY_SCORE + investmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence), ) } else {