..
This commit is contained in:
parent
aa1f3817bb
commit
ce976a895d
@ -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(
|
||||
|
||||
@ -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<String, String>
|
||||
) {
|
||||
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
|
||||
|
||||
@ -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<List<RankingStock>> {
|
||||
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<RankingResponse>()
|
||||
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<RankingResponse>(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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String>("${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}")
|
||||
}
|
||||
|
||||
@ -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<RankingStock> = 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()
|
||||
}
|
||||
|
||||
|
||||
@ -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<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 {
|
||||
// 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<NewsItem>) = 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("🏁 뉴스 처리 완료")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user