This commit is contained in:
lunaticbum 2026-02-09 15:32:31 +09:00
parent aa1f3817bb
commit ce976a895d
7 changed files with 390 additions and 183 deletions

View File

@ -3,7 +3,7 @@ package model
import java.time.LocalDateTime import java.time.LocalDateTime
const val feesAndTaxRate = 0.33 const val feesAndTaxRate = 0.33
const val minimumNetProfit = 0.5 const val minimumNetProfit = 0.35
const val buyWeight = 2.0 const val buyWeight = 2.0
data class AppConfig( data class AppConfig(

View File

@ -42,27 +42,54 @@ data class RankingResponse(
val list = output + output1 + emptyList() val list = output + output1 + emptyList()
} }
//@Serializable
enum class RankingType( enum class RankingType(
val title: String, val title: String,
val trId: String, val trId: String,
val scrNo: String, val scrNo: String,
val path: String, val path: String,
val sortCode: String // 추가: 각 TR ID에 맞는 정렬 코드 val extraParams: Map<String, String>
) { ) {
VOLUME("거래량순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "0"), // [1] 등락률 (RISE, FALL)
VALUE("거래대금순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "3"), // FID_INPUT_CNT_1: 0 (전체/당일), FID_TRGT_CLS_CODE: 11111111 (전체)
RISE("상승률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "0"), RISE("상승률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation",
FALL("하락률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "1"), mapOf("FID_RANK_SORT_CLS_CODE" to "0", "FID_TRGT_CLS_CODE" to "11111111", "FID_INPUT_CNT_1" to "0")),
// MARKET_CAP("시가총액순", "FHPST01740000", "20174", "/uapi/domestic-stock/v1/quotations/market-cap", "0"), FALL("하락률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation",
// HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", "0"), mapOf("FID_RANK_SORT_CLS_CODE" to "1", "FID_TRGT_CLS_CODE" to "11111111", "FID_INPUT_CNT_1" to "0")),
// 링크로 전달주신 추가 기능 보완
VOLUME_POWER("체결강도순", "FHPST01680000", "20168", "/uapi/domestic-stock/v1/ranking/volume-power", "0"), // [2] 당사매매 (COMPANY_TRADE) - 날짜 빈값 = 당일
// BEFORE("장전예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "0"), COMPANY_TRADE("당사매매", "FHPST01860000", "20186", "/uapi/domestic-stock/v1/ranking/traded-by-company",
// AFTER("장후예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "1") 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")),
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"), // [3] 거래량/거래대금
NEW_HIGH("52주신고가", "FHPST01690000", "20169", "/uapi/domestic-stock/v1/rankingsh-52w-high", "0"), VOLUME("거래량순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank",
COMPANY_TRADE("당사매매", "FHPST01860000", "20187", "/uapi/domestic-stock/v1/ranking/traded-by-company", "0") 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 @Serializable

View File

@ -10,6 +10,8 @@ import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -112,98 +114,73 @@ object KisTradeService {
*/ */
private suspend fun fetchDomesticRanking(type: RankingType): Result<List<RankingStock>> { private suspend fun fetchDomesticRanking(type: RankingType): Result<List<RankingStock>> {
val config = KisSession.config val config = KisSession.config
val jsonParser = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
}
return try { return try {
val response = client.get("$prodUrl${type.path}") { val response = client.get("${prodUrl}${type.path}") {
header("authorization", "Bearer ${config.marketToken}") header("authorization", "Bearer ${config.marketToken}")
header("appkey", config.realAppKey) header("appkey", config.realAppKey)
header("appsecret", config.realSecretKey) header("appsecret", config.realSecretKey)
header("tr_id", type.trId) header("tr_id", type.trId)
header("custtype", "P") header("custtype", "P")
header("Accept", "application/json")
parameter("FID_COND_MRKT_DIV_CODE", "J") // [Step 1] 파라미터 기본값 재설정 (성공했던 원본 코드 기준)
parameter("FID_COND_SCR_DIV_CODE", type.scrNo) val params = mutableMapOf(
parameter("FID_INPUT_ISCD", "0000") // 전체 시장 "FID_COND_MRKT_DIV_CODE" to "J",
parameter("FID_DIV_CLS_CODE", "0") // 전체 "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") // [Step 2] Enum 특화 설정 덮어쓰기
parameter("FID_PRC_CLS_CODE", "0") params.putAll(type.extraParams)
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 3] 적용
RankingType.RISE -> { params.forEach { (key, value) -> parameter(key, value) }
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: 전일 대비
}
} }
val body = response.body<RankingResponse>()
if (listOf( val rawJson = response.bodyAsText()
RankingType.VOLUME_POWER, val body = try {
RankingType.EXPECTED_RISE, jsonParser.decodeFromString<RankingResponse>(rawJson)
RankingType.COMPANY_TRADE } catch (e: Exception) {
).contains(type)) { println("[ERROR] ${type.title} 파싱 실패. Raw: $rawJson")
println("${type.name} , ${body}" ) 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) { } catch (e: Exception) {
if (listOf(
RankingType.VOLUME_POWER,
RankingType.EXPECTED_RISE,
RankingType.COMPANY_TRADE
).contains(type)) {
println("${type.name} , ${e.message}" )
}
Result.failure(e) Result.failure(e)
} }
} }

View File

@ -13,6 +13,7 @@ import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.http.ContentType.Application.Json import io.ktor.http.ContentType.Application.Json
import io.ktor.http.Url
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import model.DartFinancialResponse import model.DartFinancialResponse
@ -36,17 +37,29 @@ object NewsService {
suspend fun fetchAndIngestNews(corpInfo: CorpInfo) { suspend fun fetchAndIngestNews(corpInfo: CorpInfo) {
val clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요 val clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요
val clientSecret = "DODCxb1M4Z" val clientSecret = "DODCxb1M4Z"
var qlist = listOf<String>("${corpInfo.stockName} 분석","${corpInfo.stockName}[${corpInfo.stockCode}]", "${corpInfo.cName} 최근 동향", "${corpInfo.cName}") val qlistNews = listOf(
qlist.forEach { query -> "${corpInfo.stockName} 주가",
"${corpInfo.stockName} 실적",
"${corpInfo.stockName} 공시",
"${corpInfo.stockName} 이벤트"
)
val qlistCorpTrend = listOf(
"${corpInfo.cName} 최근 동향",
"${corpInfo.cName} 이슈",
"${corpInfo.cName} 투자",
"${corpInfo.cName} 실적"
)
(qlistNews + qlistCorpTrend).forEach { query ->
try { try {
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") { val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
parameter("query", query) parameter("query", query)
parameter("display", 3) // 최근 10개 뉴스 parameter("display", 5) // 최근 10개 뉴스
parameter("sort", "sim") // 유사도 순 (또는 date 발간순) parameter("sort", "date") // 유사도 순 (또는 date 발간순)
header("X-Naver-Client-Id", clientId) header("X-Naver-Client-Id", clientId)
header("X-Naver-Client-Secret", clientSecret) header("X-Naver-Client-Secret", clientSecret)
}.body() }.body()
SafeScraper.scrapeParallel(corpInfo,response.items) SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(5) )
} catch (e: Exception) { } catch (e: Exception) {
println("❌ 뉴스 가져오기 실패: ${e.message}") println("❌ 뉴스 가져오기 실패: ${e.message}")
} }

View File

@ -91,16 +91,26 @@ object AutoTradingManager {
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
// [프로세스 2] 후보군 수집 // [프로세스 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.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE }
.filter { it.code !in myHoldings && it.code !in pendingStocks } .filter { it.code !in myHoldings && it.code !in pendingStocks }
.distinctBy { it.code } .distinctBy { it.code }
.apply {
println("후보군 조건 충족 총 개수 : $size")
}
// [프로세스 3] 종목별 순회 분석 // [프로세스 3] 종목별 순회 분석
candidates.forEach { stock -> candidates.forEach { stock ->
lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고 try {
processSingleStock(stock, myCash, tradeService, callback) lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고
delay(300) processSingleStock(stock, myCash, tradeService, callback)
} catch (e: Exception) {
}finally {
delay(300)
}
} }
println("⏱️ [Cycle End] ${LocalTime.now()}") println("⏱️ [Cycle End] ${LocalTime.now()}")
@ -163,14 +173,23 @@ object AutoTradingManager {
} }
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope { private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
listOf( 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.VOLUME, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FALL, 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.VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, 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.NEW_HIGH, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, 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() ).awaitAll().flatten()
} }

View File

@ -10,7 +10,9 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import model.NewsItem import model.NewsItem
@ -18,12 +20,75 @@ import network.CorpInfo
import java.net.URL import java.net.URL
import kotlin.random.Random import kotlin.random.Random
object DynamicNewsScraper { object BrowserManager {
private val playwright by lazy { Playwright.create() } private var playwright: Playwright? = null
private val browser by lazy { private var _browser: com.microsoft.playwright.Browser? = null
playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true)) 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 { fun extractSmartContentWithLineFilter(page: Page): String {
val script = """ val script = """
() => { () => {
@ -95,66 +160,81 @@ object DynamicNewsScraper {
return page.evaluate(script) as String return page.evaluate(script) as String
} }
var failDomainList = arrayListOf<String>() // private fun getBrowser(): com.microsoft.playwright.Browser {
// return if (!browser.isConnected) {
// println("🔄 브라우저 연결 끊김 확인, 재시작 시도...")
// // 기존 lazy browser 대신 새 인스턴스를 할당하는 로직 필요
// // (단순 object singleton 보다는 관리형 클래스가 유리)
// browser // 예시를 위해 유지
// } else {
// browser
// }
// }
var failCountMap = mutableMapOf<String, Int>()
suspend fun fetchFullContent(url: String): String { suspend fun fetchFullContent(url: String): String {
// browser.newContext().use { ... } 대신 직접 변수를 선언하고 제어합니다. val domain = try { URL(url).host } catch (e: Exception) { return "" }
val domain = URL(url).host if ((failCountMap[domain] ?: 0) > 2) return ""
val context = browser.newContext()
if(failDomainList.contains(domain)) {
println("실패한 도메인 스크래핑 종료 $domain ")
return ""
}
return try { return try {
// 브라우저 인스턴스를 뮤텍스 보호 하에 가져옴
val browser = BrowserManager.getBrowser()
// Context/Page 생성 시점부터 에러 감시
val context = browser.newContext()
context.use { ctx -> context.use { ctx ->
ctx.newPage().use { page -> ctx.newPage().use { page ->
page.setDefaultNavigationTimeout(8000.0) page.setDefaultNavigationTimeout(15000.0)
delay(Random.nextInt(2000).toLong())
// 1. 리스너 설정 시 예외 처리 강화 // Route 설정 시 예외 방어
blockUnnecessaryResources(page) // try {
// blockUnnecessaryResources(page)
// 2. 타임아웃을 설정하여 무한 대기 방지 // } catch (e: Exception) { /* 브라우저 상태 이상 감지 시 catch로 이동 */ }
page.navigate(url, Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)) page.navigate(url, Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED))
// 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기
page.waitForLoadState(LoadState.LOAD) page.waitForLoadState(LoadState.LOAD)
val content = cleanText(extractSmartContentWithLineFilter(page)) val content = cleanText(extractSmartContentWithLineFilter(page))
// 4. 명시적으로 route를 해제하여 close 시 발생할 수 있는 리스너 충돌 방지 // 명시적으로 route 해제 (TargetClosed 에러 방지)
page.unroute("**/*") // try { page.unroute("**/*") } catch (e: Exception) {}
BrowserManager.notifySuccess()
content content
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
failDomainList.add(domain) val msg = e.message ?: ""
println("❌ [Playwright] 스크래핑 실패 ($url): ${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) { private fun blockUnnecessaryResources(page: Page) {
// 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단
page.route("**/*") { route -> page.route("**/*") { route ->
try { try {
val req = route.request() // route나 request가 이미 파기되었는지 확인
if (req != null) { val req = runCatching { route.request() }.getOrNull()
val type = req.resourceType() if (req == null) {
if (type == "image" || type == "font" || type == "stylesheet") { // 이미 브라우저가 닫히는 중이라면 무시
route.abort() return@route
} else { }
route.resume()
} val type = req.resourceType()
if (type == "image" || type == "font" || type == "stylesheet") {
route.abort()
} else { } else {
// request가 이미 null이면 처리를 포기
route.resume() route.resume()
} }
} catch (e: Exception) { } catch (e: Exception) {
// 브라우저 종료 시 발생하는 에러는 여기서 조용히 처리
} }
} }
} }
@ -165,49 +245,44 @@ object DynamicNewsScraper {
.trim() .trim()
} }
} }
object SafeScraper { object SafeScraper {
// 세마포어를 2개로 유지하되, 작업당 타임아웃을 반드시 설정해야 합니다. // 동시 처리를 1개로 줄여서 안정성을 극대화 (추천)
private val semaphore = Semaphore(4) // Playwright는 여러 페이지를 띄울 때 CPU/메모리 점유율이 매우 높습니다.
private val semaphore = Semaphore(2)
suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List<NewsItem>) = coroutineScope { suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List<NewsItem>) = coroutineScope {
val query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}" urls.forEach { item -> // map + awaitAll 대신 순차 처리가 현재 상황에선 더 안정적입니다.
if (UrlCacheManager.isAlreadyProcessed(item.originallink)) {
urls.map { item -> println("✅ [학습완료 데이터 스킵] ${item.originallink}")
async { return@forEach
if (UrlCacheManager.isAlreadyProcessed(item.originallink)) { }
// println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵")
return@async
}
semaphore.withPermit {
try { try {
// 세마포어 획득 시도에 타임아웃을 걸어 대기열 정체 방지 withTimeout(25000L) { // 타임아웃 약간 증가
semaphore.withPermit { val content = DynamicNewsScraper.fetchFullContent(item.originallink)
// 개별 뉴스 스크래핑에 최대 30~60초 제한 설정 (무한 대기 방지 핵심) if (content.isNotBlank()) {
withTimeout(10000L) { RagService.ingestWithChunking(
val content = DynamicNewsScraper.fetchFullContent(item.originallink) text = content,
newsLink = item.originallink,
if (content.isNotBlank()) { pubDate = item.pubDate,
RagService.ingestWithChunking( stockCode = corpInfo.stockCode,
text = content, corpName = corpInfo.cName,
newsLink = item.originallink, corpCode = corpInfo.cCode,
pubDate = item.pubDate, stcokName = corpInfo.stockName
stockCode = corpInfo.stockCode, )
corpName = corpInfo.cName, println("✅ [학습완료] ${item.originallink}")
corpCode = corpInfo.cCode,
stcokName = corpInfo.stockName
)
println("✅ [학습완료] ${item.originallink}")
}
} }
} }
} catch (e: TimeoutCancellationException) {
println("⏳ [타임아웃] 뉴스 읽기 시간 초과: ${item.originallink}")
} catch (e: Exception) { } catch (e: Exception) {
println("❌ [스크래핑 에러] ${item.originallink}: ${e.localizedMessage}") println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}")
} }
// 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화)
delay(Random.nextLong(500, 1500))
} }
}.awaitAll() }
println("🏁 뉴스 처리 완료")
println("🏁 $query 관련 뉴스 ${urls.size}개 처리 시도 완료")
} }
} }

View File

@ -26,6 +26,51 @@ import model.minimumNetProfit
import network.KisTradeService import network.KisTradeService
import util.MarketUtil 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 curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.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 { scope.launch {
val tickSize = MarketUtil.getTickSize(basePrice) 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. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용) // 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용)
val finalPrice = if (orderPrice.isBlank()) { val finalPrice = if (orderPrice.isBlank()) {
@ -183,7 +279,7 @@ fun IntegratedOrderSection(
} }
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
val MAX_BUDGET = 30000.0 val MAX_BUDGET = 35000.0
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
val calculatedQty = if (basePrice > 0) { val calculatedQty = if (basePrice > 0) {
(MAX_BUDGET / basePrice).toInt().coerceAtLeast(1) (MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)
@ -195,7 +291,7 @@ fun IntegratedOrderSection(
willEnableAutoSell = true, willEnableAutoSell = true,
orderQty = calculatedQty.toString(), orderQty = calculatedQty.toString(),
profitRate1 = finalMargin, profitRate1 = finalMargin,
confidence = totalScore >= HIGH_QUALITY_SCORE investmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence),
) )
} else { } else {