This commit is contained in:
lunaticbum 2026-02-05 14:26:02 +09:00
parent e0e328ea97
commit 3f209dcd4d
9 changed files with 194 additions and 107 deletions

View File

@ -59,6 +59,32 @@ enum class RankingType(
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")
FOREIGNER_BUY("외인순매수", "FHPST01720000", "20172", "uapidomestic-stockv1quotationsfrgn-buy-rank", "0"),
INSTITUTION_BUY("기관순매수", "FHPST01730000", "20173", "uapidomestic-stockv1quotationsinst-buy-rank", "0"),
// 신규: 재무/지표
PER_RANK("PER", "FHPST01760000", "20176", "uapidomestic-stockv1quotationsper-rank", "0"),
PBR_RANK("PBR", "FHPST01770000", "20177", "uapidomestic-stockv1quotationspbr-rank", "0"),
DIVIDEND("배당률", "FHPST01800000", "20180", "uapidomestic-stockv1quotationsdividend-rank", "0"),
// 신규: 시간외/예상
AFTER_HOURS_VOLUME("시간외거래량", "FHPST01810000", "20181", "uapidomestic-stockv1rankingafterhours-volume", "0"),
EXPECTED_RISE("예상상승", "FHPST01820000", "20182", "uapidomestic-stockv1rankingexp-trans-updown", "0"),
EXPECTED_FALL("예상하락", "FHPST01820000", "20182", "uapidomestic-stockv1rankingexp-trans-updown", "1"),
// 신규: 체결/호가/신고가 등
EXEC_STRENGTH("체결강도", "FHPST01780000", "20178", "uapidomestic-stockv1quotationsexec-strength", "0"),
BID_ASK_VOLUME("호가잔량", "FHPST01790000", "20179", "uapidomestic-stockv1quotationsbid-ask-volume", "0"),
NEW_HIGH("52주신고가", "FHPST01690000", "20169", "uapidomestic-stockv1rankingsh-52w-high", "0"),
// 신규: 신용/공매도/대량
MARGIN_BALANCE("신용잔고", "FHPST01830000", "20183", "uapidomestic-stockv1quotationsmargin-balance", "0"),
SHORT_SELL("공매도", "FHPST01840000", "20184", "uapidomestic-stockv1quotationsshort-sell", "0"),
LARGE_DEAL("대량체결", "FHPST01850000", "20185", "uapidomestic-stockv1quotationslarge-deal", "0"),
// 기타 인기 (KIS HTS 순위분석 기반)
INTEREST_TOP("관심순", "FHPST01860000", "20186", "uapidomestic-stockv1rankinginterest-top", "0"),
COMPANY_TRADE("당사매매", "FHPST01870000", "20187", "uapidomestic-stockv1rankingcompany-trade", "0")
}
@Serializable

View File

@ -147,9 +147,22 @@ object KisTradeService {
// RankingType.BEFORE -> {
// parameter("FID_MKOP_CLS_CODE", type.sortCode)
// }
else -> {
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", "")

View File

@ -139,7 +139,11 @@ object RagService {
corpInfo?.stockName = stockName
tradingDecision.stockName = stockName
tradingDecision.corpName = corpInfo?.cName ?: ""
corpInfo?.let { NewsService.fetchAndIngestNews(it) }
corpInfo?.let {
try {
NewsService.fetchAndIngestNews(it)
} catch (e: Exception) {}
}
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }

View File

@ -4,9 +4,11 @@ import TradingDecision
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import model.CandleData
import model.RankingType
import network.KisTradeService
@ -26,7 +28,8 @@ object AutoTradingManager {
private var discoveryJob: Job? = null
val MIN = 0.1
val MAX = 15.0
fun startAutoDiscoveryLoop(
tradeService: KisTradeService,
callback: TradingDecisionCallback
@ -56,100 +59,113 @@ object AutoTradingManager {
val riseList = riseRankDeferred.await()
val amountList = amountRankDeferred.await()
val volumeList = volumePowerDeferred.await()
// (C) 거래대금 상위 종목 필터링 (시장의 주도주)
val amountCandidates = amountList
.filter { stock ->
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
rate in 1.0..15.0 // 너무 과열되지 않은 주도주
}
val volCandidates = volList
.filter { stock ->
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
rate in 1.0..18.0 // 0% 초과 20% 이하
}
// (B) 상승률 상위 종목 중: 너무 급등한(20% 초과) 종목은 제외하고, 적당히 오르고 있는 종목만 필터링 -> 상위 10개
// 보통 상승률 랭킹은 상한가(30%)부터 내려오므로, 앞쪽의 급등주를 건너뛰어야 함
val riseCandidates = riseList
.filter { stock ->
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
rate in 2.0..18.0 // 최소 3% 이상은 올라야 의미 있음, 20% 이하는 안전 구간
}
val volumeCandidates = volumeList .filter { stock ->
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
rate in 1.0..18.0 // 최소 3% 이상은 올라야 의미 있음, 20% 이하는 안전 구간
}
// 3. 리스트 합치기 (중복 제거)
val candidates = (volCandidates + riseCandidates + amountCandidates + volumeCandidates).distinctBy { it.code }
val candidates = (volList + riseList + amountList + volumeList + async { tradeService.fetchMarketRanking(RankingType.FOREIGNER_BUY, true).getOrDefault(emptyList()) + async { tradeService.fetchMarketRanking(RankingType.INSTITUTION_BUY, true).getOrDefault(emptyList()) }.await()}.await()).filter {stock ->
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
rate in MIN..MAX // 너무 과열되지 않은 주도주
}.distinctBy { it.code }
println("🔎 1차 필터링 후보 ${candidates.size}개 (급등주 제외) 검증 시작...")
candidates.forEach { stock ->
// [조건 1] 이미 보유한 종목 제외
if (myHoldings.contains(stock.code)) return@forEach
try {
val currentPrice = stock.stck_prpr.replace(",", "").toDoubleOrNull() ?: 0.0
// [조건 2] 최소 1주 매수 가능 여부
if (currentPrice > myCash || currentPrice > 5000) return@forEach
callback(TradingDecision().apply {
this.stockCode = stock.code
this.confidence = -1.0
this.stockName = stock.name
}, false)
// 3. 일봉 데이터 조회 (필터링 용도 + TechnicalAnalyzer 입력용)
val dailyResult = tradeService.fetchPeriodChartData(stock.code, "D", true)
val dailyData = dailyResult.getOrNull()
val todayCandle = dailyData?.lastOrNull()
// [조건 1] 이미 보유한 종목 제외
if (myHoldings.contains(stock.code)) return@forEach
if (dailyData != null && todayCandle != null) {
val currentPrice = stock.stck_prpr.replace(",", "").toDoubleOrNull() ?: 0.0
val open = todayCandle.stck_oprc.toDoubleOrNull() ?: 0.0
val current = todayCandle.stck_prpr.toDoubleOrNull() ?: 0.0
// [조건 2] 최소 1주 매수 가능 여부
if (currentPrice > myCash || currentPrice > 10000) {
println("⏭️ [제외] ${stock.name}: 주가($currentPrice)가 예산 초과")
return@forEach
}
callback(TradingDecision().apply {
this.stockCode = stock.code
this.confidence = -1.0
this.stockName = stock.name
}, false)
// 3. 일봉 데이터 조회 (필터링 용도 + TechnicalAnalyzer 입력용)
val dailyResult = tradeService.fetchPeriodChartData(stock.code, "D", true)
val dailyData = dailyResult.getOrNull()
val todayCandle = dailyData?.lastOrNull()
if (open > 0) {
val riseRate = (current - open) / open * 100
if (dailyData != null && todayCandle != null) {
// [조건 3] 상승 중(양봉)이면서 20% 이하 상승
if (riseRate > 0 && riseRate <= 20.0) {
println("✨ [발굴] ${stock.name} (+${String.format("%.1f", riseRate)}%) -> 데이터 수집 및 분석")
val open = todayCandle.stck_oprc.toDoubleOrNull() ?: 0.0
val current = todayCandle.stck_prpr.toDoubleOrNull() ?: 0.0
// [핵심 수정] AI 분석 전 필요한 차트 데이터(30분, 주봉, 월봉)를 모두 가져와 TechnicalAnalyzer에 주입
// 비동기로 동시에 요청하여 속도 향상
val min30Def = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) }
val weekDef = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) }
val monthDef = async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) }
if (open > 0) {
val riseRate = (current - open) / open * 100
val min30Data = min30Def.await()
val weeklyData = weekDef.await()
val monthlyData = monthDef.await()
// [조건 3] 상승 중(양봉)이면서 20% 이하 상승
if (riseRate > 0 && riseRate <= 20.0) {
println(
"✨ [발굴] ${stock.name} (+${
String.format(
"%.1f",
riseRate
)
}%) -> 데이터 수집 분석"
)
// TechnicalAnalyzer 상태 업데이트 (싱글톤이므로 순차 처리 필수)
val t = TechnicalAnalyzer()
t.daily = dailyData
t.weekly = weeklyData
t.monthly = monthlyData
t.min30 = min30Data
// 데이터 준비 완료 후 AI 분석 요청 (suspend 함수이므로 완료될 때까지 대기 -> 데이터 섞임 방지)
RagService.processStock(t,stock.name, stock.code) { decision, isSuccess ->
if (decision != null) {
decision.stockName = stock.name
decision.currentPrice = current // 차트에서 확인한 최신 현재가 주입
// [핵심 수정] AI 분석 전 필요한 차트 데이터(30분, 주봉, 월봉)를 모두 가져와 TechnicalAnalyzer에 주입
// 비동기로 동시에 요청하여 속도 향상
val min30Def = async {
tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList())
}
val weekDef = async {
tradeService.fetchPeriodChartData(stock.code, "W", true)
.getOrDefault(emptyList())
}
val monthDef = async {
tradeService.fetchPeriodChartData(stock.code, "M", true)
.getOrDefault(emptyList())
}
callback(decision, isSuccess) // DashboardScreen으로 전달
}
// 분석 후 잠시 대기 (서버 부하 조절)
delay(2000)
val min30Data = min30Def.await()
val weeklyData = weekDef.await()
val monthlyData = monthDef.await()
// TechnicalAnalyzer 상태 업데이트 (싱글톤이므로 순차 처리 필수)
val t = TechnicalAnalyzer()
t.daily = dailyData
t.weekly = weeklyData
t.monthly = monthlyData
t.min30 = min30Data
try {
withTimeout(60000L) { // 60초 타임아웃 설정
RagService.processStock(t, stock.name, stock.code) { decision, isSuccess ->
if (decision != null) {
decision.stockName = stock.name
decision.currentPrice = current
}
callback(decision, isSuccess)
}
}
} catch (e: TimeoutCancellationException) {
println("⏳ [Timeout] ${stock.name} AI 분석 시간 초과로 스킵합니다.")
} catch (e: Exception) {
println("⚠️ [Error] AI 분석 중 오류: ${e.message}")
}
// 데이터 준비 완료 후 AI 분석 요청 (suspend 함수이므로 완료될 때까지 대기 -> 데이터 섞임 방지)
// 분석 후 잠시 대기 (서버 부하 조절)
delay(2000)
}
}
}
delay(300) // 종목 간 API 호출 간격
} catch (e: Exception) {
println("⚠️ [오류] ${stock.name} 분석 중 예외 발생: ${e.message}")
}
delay(300) // 종목 간 API 호출 간격
}
@ -164,9 +180,10 @@ object AutoTradingManager {
delay(tickMillis)
currentWait += tickMillis
val leftSec = (totalWaitMillis - currentWait) / 1000
// 1분 단위 혹은 10초 단위로 자유롭게 로그 조절 가능
if (leftSec % 30 == 0L || leftSec <= 30) {
println("📡 [AutoTrading] 시스템 정상 작동 중... (다음 분석 ${leftSec}초 전)")
if (leftSec % 60 == 0L) {
val runtime = Runtime.getRuntime()
val usedMem = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024
println("📡 [System] 정상 작동 중... (남은 시간: ${leftSec}초 | 메모리 사용: ${usedMem}MB)")
}
}
@ -199,19 +216,19 @@ object AutoTradingManager {
println("🔥 [주문 집행] $code $type 완료")
}
}
data class InvestmentScores(
val ultraShort: Int, // 초단기 (분봉/에너지)
val shortTerm: Int, // 단기 (일봉/뉴스)
val midTerm: Int, // 중기 (주봉/재무)
val longTerm: Int // 장기 (월봉/펀더멘털)
)
class TechnicalAnalyzer {
var monthly: List<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList()
var daily: List<CandleData> = emptyList()
var min30: List<CandleData> = emptyList()
data class InvestmentScores(
val ultraShort: Int, // 초단기 (분봉/에너지)
val shortTerm: Int, // 단기 (일봉/뉴스)
val midTerm: Int, // 중기 (주봉/재무)
val longTerm: Int // 장기 (월봉/펀더멘털)
)
fun calculateScores(
financialScore: Int // 재무제표 점수 (성장률 등 기반)

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import model.NewsItem
import network.CorpInfo
import java.net.URL
import kotlin.random.Random
object DynamicNewsScraper {
@ -91,20 +92,25 @@ object DynamicNewsScraper {
return page.evaluate(script) as String
}
var failDomainList = arrayListOf<String>()
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 ""
}
return try {
context.use { ctx ->
ctx.newPage().use { page ->
delay(Random.nextInt(1000).toLong())
delay(Random.nextInt(2000).toLong())
// 1. 리스너 설정 시 예외 처리 강화
blockUnnecessaryResources(page)
// 2. 타임아웃을 설정하여 무한 대기 방지
val options = Page.NavigateOptions().setTimeout(30000.0)
val options = Page.NavigateOptions().setTimeout(8000.0)
page.navigate(url, options)
// 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기
@ -119,6 +125,7 @@ object DynamicNewsScraper {
}
}
} catch (e: Exception) {
failDomainList.add(domain)
println("❌ [Playwright] 스크래핑 실패 ($url): ${e.message}")
""
} finally {

View File

@ -1,6 +1,9 @@
package service
import java.util.concurrent.TimeUnit
import org.slf4j.LoggerFactory
import ch.qos.logback.classic.Level
import ch.qos.logback.classic.Logger
object SystemSleepPreventer {
private var process: Process? = null
@ -9,6 +12,9 @@ object SystemSleepPreventer {
* 맥의 절전 모드 디스플레이 취침을 방지하는 명령 실행
*/
fun start() {
val root = LoggerFactory.getLogger("Exposed") as Logger
root.level = Level.ERROR
if (process?.isAlive == true) return
try {

View File

@ -159,7 +159,6 @@ fun DashboardScreen() {
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
scope.launch {
println("$orderNo, $code, $price, $qty, $isBuy")
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
executionCache[orderNo] = exec
syncAndExecute(orderNo)

View File

@ -82,12 +82,12 @@ fun IntegratedOrderSection(
// 계산용 변수
val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?) {
fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?,confidence : Boolean = false) {
scope.launch {
val tickSize = MarketUtil.getTickSize(basePrice)
val oneTickLowerPrice = basePrice - tickSize
val oneTickLowerPrice = basePrice - (tickSize * if (confidence) { 1} else {2})
// 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용)
val finalPrice = if (orderPrice.isBlank()) {
@ -114,7 +114,7 @@ fun IntegratedOrderSection(
// 4. 보정된 수익률을 적용하여 목표가 계산
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
// 5. DB 저장 (effectiveProfitRate를 저장하여 분석 시 실제 목표치를 확인 가능하게 함)
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = realOrderNo,
@ -146,13 +146,7 @@ fun IntegratedOrderSection(
completeTradingDecision.stockCode.equals(stockCode)) {
fun resultCheck(completeTradingDecision :TradingDecision) {
println("""
corpName : ${completeTradingDecision.corpName}
confidence : ${completeTradingDecision.confidence + append}
shortPossible : ${completeTradingDecision.shortPossible() + append}
profitPossible : ${completeTradingDecision.profitPossible()+ append}
safePossible : ${completeTradingDecision.safePossible()+ append}
""".trimIndent())
val weights = mapOf(
"short" to 0.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소
@ -169,8 +163,15 @@ fun IntegratedOrderSection(
// 3. 매수 결정 문턱값 (예: 70점 이상이면 매수 가능)
val MIN_PURCHASE_SCORE = 68.0
val HIGH_QUALITY_SCORE = 85.0 // 강력 추천 기준
if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence > MIN_CONFIDENCE) {
println("""
corpName : ${completeTradingDecision.corpName}
confidence : ${completeTradingDecision.confidence + append}
shortPossible : ${completeTradingDecision.shortPossible() + append}
profitPossible : ${completeTradingDecision.profitPossible()+ append}
safePossible : ${completeTradingDecision.safePossible()+ append}
totalScore : ${totalScore}
""".trimIndent())
if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
// 4. 점수에 따른 가변 마진 적용
// 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용
@ -182,16 +183,23 @@ fun IntegratedOrderSection(
}
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
val MAX_BUDGET = 25000.0
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
val calculatedQty = if (basePrice > 0) {
(MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)
} else {
1
}
// 5. 매수 실행 (계산된 finalMargin 전달)
excuteTrade(
willEnableAutoSell = true,
orderQty = "1",
profitRate1 = finalMargin
orderQty = calculatedQty.toString(),
profitRate1 = finalMargin,
confidence = totalScore >= HIGH_QUALITY_SCORE
)
} else {
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)})가 기준치($MIN_PURCHASE_SCORE) 미달")
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)})가 기준치($MIN_PURCHASE_SCORE) 미달 또는 신뢰도 ${completeTradingDecision.confidence}가 기준치 ${MIN_CONFIDENCE} 미달")
}
}
when (completeTradingDecision?.decision) {
@ -228,7 +236,7 @@ fun IntegratedOrderSection(
modifier = Modifier.weight(1f)
)
}
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
// 수익률 시뮬레이션 표
if (basePrice > 0 && inputQty > 0) {
SimulationCard(basePrice, inputQty.toDouble())

View File

@ -0,0 +1,7 @@
<configuration>
<logger name="Exposed" level="OFF" />
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>