This commit is contained in:
lunaticbum 2026-02-10 15:08:52 +09:00
parent 4dff629861
commit 4bf055fa68
12 changed files with 302 additions and 399 deletions

View File

@ -5,7 +5,9 @@ import java.time.LocalDateTime
const val feesAndTaxRate = 0.33
const val minimumNetProfit = 0.35
const val buyWeight = 2.0
val MAX_BUDGET = 40000.0
val MAX_PRICE = 20000
val MIN_PRICE = 1500
data class AppConfig(
// [DB 저장 데이터]
// 실전 3종

View File

@ -1,125 +0,0 @@
package network
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import model.RealTimeTrade
object AiService {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 60_000 // 전체 요청 대기 시간을 60초로 설정
connectTimeoutMillis = 10_000 // 서버 연결 대기 시간 10초
socketTimeoutMillis = 60_000 // 데이터 수신 대기 시간 60초
}
}
// private const val LLM_URL = "http://localhost:8080/completion"
private const val LLM_URL = "http://127.0.0.1:8080/completion"
/**
* 종목명, 현재가, 실시간 체결내역을 바탕으로 AI 분석 결과를 가져옵니다.
*/
suspend fun fetchAnalysis(
stockName: String,
currentPrice: String,
trades: List<RealTimeTrade>
): String {
// 최근 체결 내역 10개를 텍스트로 요약
val tradeSummary = trades.take(10).joinToString("\n") { trade ->
"- ${trade.time}: ${trade.price}원 (${trade.volume}${if (trade.type.name == "BUY") "매수" else "매도"})"
}
// Gemma에게 전달할 프롬프트 구성
val prompt = """
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 20 경력의 주식 트레이더입니다. 데이터를 분석하여 짧고 단호하게 조언합니다.<|eot_id|><|start_header_id|>user<|end_header_id|>
다음 데이터를 분석하여 '수급 상황' '단기 전망' 3 이내로 요약하세요.
[종목] $stockName ($currentPrice)
[최근 체결]
$tradeSummary
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
""".trimIndent()
return try {
val response = client.post(LLM_URL) {
contentType(ContentType.Application.Json)
setBody(LlamaRequest(prompt = prompt))
}
if (response.status == HttpStatusCode.OK) {
val result: LlamaResponse = response.body()
result.content.trim()
} else {
"AI 서버 응답 오류: ${response.status}"
}
} catch (e: Exception) {
var msg = "분석 실패: 로컬 AI 서버(llama.cpp)가 실행 중인지 확인하세요. (${e.message})"
println(msg)
msg
}
}
suspend fun getEmbedding(text: String): List<Double>? {
return try {
val response = client.post("http://127.0.0.1:8080/embedding") {
contentType(ContentType.Application.Json)
setBody(EmbeddingRequest(content = text))
}
if (response.status == HttpStatusCode.OK) {
val res: EmbeddingResponse = response.body()
res.embedding
} else null
} catch (e: Exception) {
null
}
}
}
@Serializable
data class EmbeddingRequest(val content: String)
@Serializable
data class EmbeddingResponse(val embedding: List<Double>)
/**
* llama.cpp 서버 요청 데이터 구조
*/
@Serializable
data class LlamaRequest(
val prompt: String,
val n_predict: Int = 256, // 답변 길이를 엄격히 제한
val temperature: Double = 0.4, // M3 Pro에서 더 일관된 답변을 위해 낮춤
val stop: List<String> = listOf(
"<|eot_id|>",
"<|end_of_text|>",
"<|start_header_id|>",
"user",
"model"
) // [중요] AI가 멈춰야 할 지점들을 명확히 지정
)
/**
* llama.cpp 서버 응답 데이터 구조
*/
@Serializable
data class LlamaResponse(
val content: String
)

View File

@ -40,14 +40,14 @@ object DartCodeManager {
val url = "https://opendart.fss.or.kr/api/corpCode.xml?crtfc_key=$DART_API_KEY"
val response: HttpResponse = client.get(url)
val zipBytes = response.readBytes()
val zipFile = File("dart_corp_codes.zip")
zipFile.writeBytes(zipBytes)
println("💾 [디버그] 원본 ZIP 저장 완료: ${zipFile.absolutePath} (${zipBytes.size} bytes)")
// val zipFile = File("dart_corp_codes.zip")
// zipFile.writeBytes(zipBytes)
// println("💾 [디버그] 원본 ZIP 저장 완료: ${zipFile.absolutePath} (${zipBytes.size} bytes)")
ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (entry.name == "CORPCODE.xml") {
saveXmlDebugFile(zipBytes)
// saveXmlDebugFile(zipBytes)
parseXml(zis.readAllBytes())
break
}

View File

@ -43,7 +43,7 @@ class KisAuthService {
*/
suspend fun refreshAllTokens(): Boolean = coroutineScope {
val config = KisSession.config
println("refreshAllTokens")
// 1. 실전 시세용 토큰 발급 (Market Token)
val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) }
@ -75,6 +75,7 @@ class KisAuthService {
private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result<TokenResponse> {
return try {
println("fetchAccessToken")
val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") {
contentType(ContentType.Application.Json)
setBody(TokenRequest("client_credentials", appKey, secretKey))
@ -82,6 +83,7 @@ class KisAuthService {
if (response.status == HttpStatusCode.OK) Result.success(response.body())
else Result.failure(Exception("인증 실패: ${response.status}"))
} catch (e: Exception) {
println("fetchAccessToken ${e.message}")
Result.failure(e)
}
}

View File

@ -1,6 +1,5 @@
package network
import AutoTradeItem
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO
@ -11,7 +10,6 @@ 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
@ -55,11 +53,9 @@ object KisTradeService {
*/
suspend fun fetchIntegratedBalance(): Result<UnifiedBalance> = coroutineScope {
val config = KisSession.config
// 국내와 해외 잔고를 비동기로 동시 호출
val domesticJob = async { fetchDomesticRawBalance() }
val overseasJob = async { fetchOverseasRawBalance() }
try {
val domRes = domesticJob.await().getOrNull()
val ovsRes = overseasJob.await().getOrNull()
@ -87,14 +83,15 @@ object KisTradeService {
val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) +
(ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L)
val depositAmt = domRes?.output2?.firstOrNull()?.dnca_tot_amt?.toLongOrNull() ?: 0L
println("fetchIntegratedBalance O")
Result.success(UnifiedBalance(
totalAsset = String.format("%,d", totalAmt),
totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0",
deposit = String.format("%,d", depositAmt),
holdings = combinedHoldings
))
} catch (e: Exception) { Result.failure(e) }
} catch (e: Exception) {
Result.failure(e) }
}
/**
@ -505,8 +502,8 @@ object KisTradeService {
// --- 내부 Raw 호출용 (통합 잔고에서 사용) ---
private suspend fun fetchDomesticRawBalance(): Result<StockBalanceResponse> {
val config = KisSession.config
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R"
val baseUrl = prodUrl
val trId = "TTTC8434R"
var pureAccount = config.accountNo.replace("-", "").trim()
if (pureAccount.length == 8) pureAccount += "01"
@ -522,7 +519,7 @@ object KisTradeService {
parameter("ACNT_PRDT_CD", acntPrdtCd)
parameter("AFHR_FLPR_YN", "N")
parameter("OFL_YN", "N")
parameter("INQR_DVSN", "02")
parameter("INQR_DVSN", "0")
parameter("UNPR_DVSN", "01")
parameter("FUND_STTL_ICLD_YN", "N")
parameter("FNCG_AMT_AUTO_RDPT_YN", "N")
@ -530,7 +527,8 @@ object KisTradeService {
parameter("CTX_AREA_FK100", "")
parameter("CTX_AREA_NK100", "")
}
Result.success(response.body())
val body = response.body<StockBalanceResponse>()
Result.success(body)
} catch (e: Exception) { Result.failure(e) }
}

View File

@ -15,12 +15,14 @@ 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.Serializable
import kotlinx.serialization.json.Json
import model.DartFinancialResponse
import model.NaverNewsResponse
import service.DynamicNewsScraper
import service.SafeScraper
import service.UrlCacheManager
import kotlin.Double
object NewsService {
private val client = HttpClient<CIOEngineConfig>(CIO) {
@ -41,25 +43,25 @@ object NewsService {
"${corpInfo.stockName} 주가",
"${corpInfo.stockName} 실적",
"${corpInfo.stockName} 공시",
"${corpInfo.stockName} 이벤트"
// "${corpInfo.stockName} 이벤트"
)
val qlistCorpTrend = listOf(
"${corpInfo.cName} 최근 동향",
"${corpInfo.cName} 이슈",
"${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", 5) // 최근 10개 뉴스
parameter("display", 4) // 최근 10개 뉴스
parameter("sort", "date") // 유사도 순 (또는 date 발간순)
header("X-Naver-Client-Id", clientId)
header("X-Naver-Client-Secret", clientSecret)
}.body()
SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(5) )
SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(2) )
} catch (e: Exception) {
println("❌ 뉴스 가져오기 실패: ${e.message}")
}
@ -89,7 +91,7 @@ object NewsService {
val response = client.get(url).body<DartFinancialResponse>()
val accounts = response.list ?: return "재무 데이터 없음"
var buffer : StringBuffer = StringBuffer()
buffer.append("[재무 분석 데이터]")
buffer.append("[재무 분석 데이터]").append("\n")
response.list.forEach { it
buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n")
}
@ -101,6 +103,75 @@ object NewsService {
return ""
}
}
}
object FinancialMapper {
/**
* 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환
*/
fun mapRawTextToStatement(rawText: String): FinancialStatement {
if (rawText.isBlank()) {
return FinancialStatement()
}
val currentValues = extractYearlyValues(rawText, "당기")
val previousValues = extractYearlyValues(rawText, "전기")
// 1. 영업이익 증가율: (당기 - 전기) / |전기| * 100
val opCurrent = currentValues["영업이익"] ?: 0.0
val opPrevious = previousValues["영업이익"] ?: 0.0
val opGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / Math.abs(opPrevious)) * 100 else 0.0
// 2. 당기순이익 증가율
val niCurrent = currentValues["당기순이익(손실)"] ?: 0.0
val niPrevious = previousValues["당기순이익(손실)"] ?: 0.0
val niGrowth = if (niPrevious != 0.0) ((niCurrent - niPrevious) / Math.abs(niPrevious)) * 100 else 0.0
// 3. ROE: 당기순이익 / 당기 자본총계 * 100
val equityCurrent = currentValues["자본총계"] ?: 1.0
val roe = (niCurrent / equityCurrent) * 100
// 4. 부채비율: 당기 부채총계 / 당기 자본총계 * 100
val debtCurrent = currentValues["부채총계"] ?: 0.0
val debtRatio = (debtCurrent / equityCurrent) * 100
// 5. 당좌비율(유동성): 당기 유동자산 / 당기 유동부채 * 100
val currentAssets = currentValues["유동자산"] ?: 0.0
val currentLiabilities = currentValues["유동부채"] ?: 1.0
val quickRatio = (currentAssets / currentLiabilities) * 100
return FinancialStatement(
operatingProfitGrowth = opGrowth,
netIncomeGrowth = niGrowth,
roe = roe,
debtRatio = debtRatio,
quickRatio = quickRatio,
isOperatingProfitPositive = opCurrent > 0,
isNetIncomePositive = niCurrent > 0
)
}
private fun extractYearlyValues(text: String, type: String): Map<String, Double> {
val result = mutableMapOf<String, Double>()
// 정규식 설명: 항목명 뒤의 (당기/전기) 괄호 안의 숫자와 콤마를 찾아 숫자로 변환
val regex = Regex("""([가-힣\s()]+)\s\(?$type\)?([-0-9,.]+)""")
regex.findAll(text).forEach { match ->
val key = match.groupValues[1].trim()
val value = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0
result[key] = value
}
return result
}
}
@Serializable
data class FinancialStatement(
val revenueGrowth: Double = 0.0, // 매출액 증가율
val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율
val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율
val roe: Double = 0.0, // ROE
val debtRatio: Double = 0.0, // 부채비율
val quickRatio: Double = 0.0, // 당좌비율
val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부
val isNetIncomePositive: Boolean = false
)

View File

@ -14,8 +14,12 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import model.CandleData
import network.DartCodeManager
import network.FinancialMapper
import network.FinancialStatement
import network.NewsService
import org.apache.lucene.store.MMapDirectory
import service.FinancialAnalyzer
import service.InvestmentScores
import service.TechnicalAnalyzer
import service.TradingDecisionCallback
import service.UrlCacheManager
@ -131,23 +135,27 @@ object RagService {
// 1. 10분간의 데이터 가져오기 (API 호출)
coroutineScope {
try {
var tradingDecision: TradingDecision = TradingDecision()
tradingDecision.stockCode = stockCode
var corpInfo = DartCodeManager.getCorpCode(stockCode)
corpInfo?.stockName = stockName
tradingDecision.stockName = stockName
tradingDecision.corpName = corpInfo?.cName ?: ""
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
tradingDecision.financialData = financialDataDeferred.await()
val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "")
if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
corpInfo?.let {
try {
NewsService.fetchAndIngestNews(it)
} catch (e: Exception) {}
}
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
val financialScore = FinancialAnalyzer.calculateScore(financialStmt)
val scores = technicalAnalyzer.calculateScores(financialScore)
tradingDecision.financialData = financialDataDeferred.await()
result(tradingDecision, false)
tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
@ -163,7 +171,10 @@ object RagService {
)
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
result(tradingDecision, false)
result(decideTrading(stockCode, tradingDecision), true)
result(decideTrading(stockCode, scores,financialStmt,tradingDecision), true)
} else {
result(tradingDecision, false)
}
}catch (e: Exception) {
e.printStackTrace()
}
@ -237,45 +248,55 @@ object RagService {
suspend fun decideTrading(
stockName: String,
scores: InvestmentScores, // 직접 계산한 점수 객체
financialStmt: FinancialStatement, // 매핑된 재무 수치 객체
tempDecision: TradingDecision
): TradingDecision? {
val prompt = """
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다.
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
아래 데이터를 분석하여 '매수', '매도', '관망' 하나를 결정하세요.
당신은 정량적 수치와 정성적 뉴스를 통합 분석하는 'AI 수석 애널리스트'입니다.
시스템이 계산한 지표 점수와 실제 재무제표 요약본을 바탕으로 최종 매매 전략을 수립하십시오.
[데이터 요약]
- 종목: $stockName
- 분석: ${tempDecision.techSummary}
- 기업/재무: ${tempDecision.financialData}
- 시장 심리: ${tempDecision.newsContext}
[종목 정보]
- 종목명: $stockName
[스코어 산출 가이드 (0-100)]
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80 이상.
2. 단기: 일봉 이평선 정배열 3 변동률 양수일 70 이상.
3. 중기: 주봉 추세와 재무 성장성(매출/영익) 동반 상승 75 이상.
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
[1. 시스템 산출 스코어 (0-100)]
- 초단기(Scalping): ${scores.ultraShort}
- 단기(Daily): ${scores.shortTerm}
- 중기(Weekly): ${scores.midTerm}
- 장기(Monthly): ${scores.longTerm}
[2. 핵심 재무제표 요약]
- 영업이익: ${if(financialStmt.isOperatingProfitPositive) "흑자" else "적자"} (성장률: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%)
- 당기순이익: ${if(financialStmt.isNetIncomePositive) "흑자" else "적자"} (성장률: ${"%.2f".format(financialStmt.netIncomeGrowth)}%)
- 수익성(ROE): ${"%.2f".format(financialStmt.roe)}%
- 안정성(부채비율): ${"%.2f".format(financialStmt.debtRatio)}%
- 유동성(당좌비율): ${"%.2f".format(financialStmt.quickRatio)}%
[3. 시장 심리 및 뉴스 컨텍스트]
${tempDecision.newsContext}
[분석 지침]
1. **재무-뉴스 정합성**: 재무제표상 영업이익이 적자임에도 뉴스가 장기적 장밋빛 전망만 내놓는다면 '신중(HOLD)' 의견을 제시하십시오.
2. **기술-심리 동기화**: 초단기 점수가 높고 뉴스에서 수급 급증 키워드가 포착되면 'BUY' 신뢰도를 높이십시오.
3. **종합 결정**: 모든 수치와 컨텍스트를 고려하여 최종 Decision을 내리고, 근거를 핵심만 기술하십시오.
[응답 지침]
- JSON 데이터만 출력하십시오. 설명이나 서론은 생략합니다.
- 반드시 아래 형식을 엄격히 준수하십시오.
[응답 지침 - 엄격 준수]
1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오.
2. 오직 JSON 데이터만 출력하십시오.
3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다.
4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다.
[응답 형식]
반드시 아래 JSON 형식으로만 답변하십시오:
{
"ultraShortScore": (숫자),
"shortTermScore": (숫자),
"midTermScore": (숫자),
"longTermScore": (숫자),
"ultraShortScore": ${scores.ultraShort},
"shortTermScore": ${scores.shortTerm},
"midTermScore": ${scores.midTerm},
"longTermScore": ${scores.longTerm},
"decision": "BUY" | "SELL" | "HOLD",
"reason": "결정적 근거 한 줄",
"reason": "재무 수치와 뉴스 심리를 대조한 최종 결론 한 줄",
"confidence": 0~100
}
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
모든 데이터를 종합하여 스코어링 리포트를 성하십시오.
상기 데이터를 통합 분석하여 최종 리포트를 성하십시오.
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
""".trimIndent()

View File

@ -14,9 +14,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import model.CandleData
import model.MAX_PRICE
import model.MIN_PRICE
import model.RankingStock
import model.RankingType
import network.DartCodeManager
import network.FinancialMapper
import network.FinancialStatement
import network.KisTradeService
import java.time.LocalDateTime
import java.time.LocalTime
@ -39,11 +43,13 @@ object AutoTradingManager {
// 설정 상수
private const val MIN_RISE_RATE = 0.1
private const val MAX_RISE_RATE = 15.0
private const val CYCLE_TIMEOUT = 10 * 60 * 1000L // 한 사이클 최대 10분
private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
fun isRunning(): Boolean = discoveryJob?.isActive == true
private var remainingCandidates = mutableListOf<RankingStock>()
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
/**
* 자동 발굴 루프 시작 Watchdog 실행
@ -82,36 +88,47 @@ object AutoTradingManager {
// [프로세스 1] 장 마감 및 잔고 체크
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
//&& now.isBefore(LocalTime.of(15, 30))
if (now.isAfter(LocalTime.of(15, 30)) ) {
executeClosingLiquidation(tradeService)
return@withTimeout
}
// if (now.isAfter(LocalTime.of(15, 30)) ) {
// executeClosingLiquidation(tradeService)
// return@withTimeout
// }
val balance = tradeService.fetchIntegratedBalance().getOrNull()
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
// [프로세스 2] 후보군 수집
if (remainingCandidates.isEmpty()) {
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 }
.sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) }
.apply {
println("후보군 조건 충족 총 개수 : $size")
}
// [프로세스 3] 종목별 순회 분석
candidates.forEach { stock ->
try {
lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고
processSingleStock(stock, myCash, tradeService, callback)
} catch (e: Exception) {
}finally {
delay(300)
remainingCandidates.addAll(candidates)
} else {
println("미확인 데이터 ${remainingCandidates.size}")
}
// [프로세스 3] 종목별 순회 분석
val iterator = remainingCandidates.iterator()
while (iterator.hasNext()) {
val stock = iterator.next()
try {
processSingleStock(stock, myCash, tradeService, callback)
// 성공적으로 처리(또는 분석 완료) 후 리스트에서 제거
} catch (e: Exception) {
println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}")
// 오류 시 리스트에 남겨둘지, 제거할지 결정
// (심각한 에러면 remove하고 다음 루프에서 다시 받는게 안전)
} finally {
iterator.remove()
}
delay(300)
}
println("⏱️ [Cycle End] ${LocalTime.now()}")
@ -142,7 +159,7 @@ object AutoTradingManager {
val today = dailyData.lastOrNull() ?: return@withTimeout
val currentPrice = today.stck_prpr.toDouble()
if (currentPrice > myCash || currentPrice > 15000 || currentPrice < 900) return@withTimeout
if (currentPrice > myCash || currentPrice > MAX_PRICE || currentPrice < MIN_PRICE) return@withTimeout
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
callback(TradingDecision().apply {
@ -163,6 +180,7 @@ object AutoTradingManager {
}
}
RagService.processStock(analyzer, stock.name, stock.code) { decision, isSuccess ->
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
}
@ -186,7 +204,7 @@ object AutoTradingManager {
// 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.NEW_HIGH, 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()) },
@ -252,12 +270,89 @@ object AutoTradingManager {
}
}
object FinancialAnalyzer {
fun isSafetyBeltMet(fs: FinancialStatement): Boolean {
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
return isDebtSafe && isLiquiditySafe && isNotDeficit
}
/**
* [매수 고려] 우량 기업 요건 확인
* 모든 조건 충족 적극적인 분석(AI/차트) 단계로 진입합니다.
*/
fun isBuyConsiderationMet(fs: FinancialStatement): Boolean {
val highProfitability = fs.roe >= 10.0 // ROE 10% 이상
val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자
return highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy
}
/**
* 종합 상태 반환 (UI 또는 로그용)
*/
fun getInvestmentStatus(fs: FinancialStatement): String {
return when {
isBuyConsiderationMet(fs) -> "🚀 [매수 검토 권장] 재무 건전성 및 성장성 우수"
isSafetyBeltMet(fs) -> "⚖️ [관망/보류] 생존 요건은 충족하나 성장성 부족"
else -> "🚨 [위험/제외] 재무 안정성 미달 또는 적자 기업"
}
}
fun calculateScore(fs: FinancialStatement): Int {
var score = 50.0 // 기본 점수
// 성장성 (영업이익 증가율)
score += when {
fs.operatingProfitGrowth > 20 -> 20
fs.operatingProfitGrowth > 0 -> 10
else -> -10 // 역성장 시 감점
}
// 수익성 (ROE)
score += when {
fs.roe > 15 -> 15
fs.roe > 5 -> 5
fs.roe < 0 -> -15 // 적자 시 큰 감점
else -> 0
}
// 안정성 (부채비율)
score += when {
fs.debtRatio < 100 -> 15
fs.debtRatio < 200 -> 5
else -> -10
}
// 유동성 (당좌비율)
if (fs.quickRatio < 100) score -= 10 // 단기 채무 지급 능력 부족 시 감점
return score.coerceIn(0.0, 100.0).toInt()
}
}
data class InvestmentScores(
val ultraShort: Int, // 초단기 (분봉/에너지)
val shortTerm: Int, // 단기 (일봉/뉴스)
val midTerm: Int, // 중기 (주봉/재무)
val longTerm: Int // 장기 (월봉/펀더멘털)
)
) {
override fun toString(): String {
return """
ultraShort $ultraShort
shortTerm $shortTerm
midTerm $midTerm
longTerm $longTerm
""".trimIndent()
}
}
class TechnicalAnalyzer {
var monthly: List<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList()

View File

@ -1,40 +0,0 @@
//package service
//
//import kotlinx.coroutines.async
//import kotlinx.coroutines.coroutineScope
//import model.CandleData
//import model.RealTimeTrade
//import network.NewsService
//
//object StockAnalysisManager {
// var days : List<CandleData> = emptyList()
// var weeks : List<CandleData> = emptyList()
// var monthly : List<CandleData> = emptyList()
// var mins : List<CandleData> = emptyList()
//
// suspend fun analyzeStockWithMultiData(stockCode : String, stockName: String, result : (String)-> Unit) {
// coroutineScope {
// println("🔍 [1/3] '${stockName}' 실시간 뉴스 수집 및 학습 시작...")
//
// val corpInfoDeferred = async { NewsService.fetchCorpInfo(stockCode) }
// val financialDataDeferred = async { NewsService.fetchFinancialGrowth(stockCode) }
//
// val corpInfo = corpInfoDeferred.await()
// val financialData = financialDataDeferred.await()
//
// NewsService.fetchAndIngestNews("$stockName 주가 전망")
//
// println("🧠 [2/3] 관련 컨텍스트 추출 중...")
//
// // 2. 방금 저장된 뉴스를 포함하여 DB에서 관련성 높은 정보 추출
// val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘."
// val context = RagService.askWithContext(question,corpInfo,financialData,days,weeks,monthly)
//
// println("🤖 [3/3] AI 분석 생성 중 (Chat 서버 8080)...")
//
// // 3. 최종 분석 결과 반환
// result.invoke(context)
// }
// }
//
//}

View File

@ -1,116 +0,0 @@
//package ui
//
//import AutoTradeItem
//import androidx.compose.foundation.background
//import androidx.compose.foundation.clickable
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material.*
//import androidx.compose.runtime.*
//import io.ktor.client.engine.cio.CIO
//// 아래 두 import가 'delegate' 에러를 해결합니다.
//import androidx.compose.runtime.getValue
//import androidx.compose.runtime.setValue
//import androidx.compose.ui.Alignment
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.graphics.Color
//import androidx.compose.ui.text.font.FontWeight
//import androidx.compose.ui.text.style.TextAlign
//import androidx.compose.ui.text.style.TextOverflow
//import androidx.compose.ui.unit.dp
//import androidx.compose.ui.unit.sp
//import kotlinx.coroutines.delay
//import kotlinx.coroutines.launch
//import model.AppConfig
//import model.BalanceSummary
//import model.CandleData
//import model.RankingStock
//import model.StockHolding
//import network.KisTradeService
//import network.KisWebSocketManager
//import kotlin.collections.isNotEmpty
//
//
//@Composable
//fun AutoTradeSettingCard(
// stockCode: String,
// stockName: String, // 종목명 추가
// currentPrice: String,
// isDomestic: Boolean = true
//) {
// var profitRate by remember { mutableStateOf("5.0") }
// var stopLossRate by remember { mutableStateOf("-3.0") }
//
// // DB에서 현재 감시 중인지 확인
// var isEnabled by remember(stockCode) {
// mutableStateOf(DatabaseFactory.findConfigByCode(stockCode) != null)
// }
//
// Card(
// elevation = 4.dp,
// shape = RoundedCornerShape(8.dp),
// backgroundColor = Color(0xFFF8F9FA)
// ) {
// Column(modifier = Modifier.padding(12.dp)) {
// Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2)
// Spacer(modifier = Modifier.height(8.dp))
//
// Row(verticalAlignment = Alignment.CenterVertically) {
// OutlinedTextField(
// value = profitRate,
// onValueChange = { profitRate = it },
// label = { Text("익절 %") },
// modifier = Modifier.weight(1f).padding(end = 4.dp),
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
// )
// OutlinedTextField(
// value = stopLossRate,
// onValueChange = { stopLossRate = it },
// label = { Text("손절 %") },
// modifier = Modifier.weight(1f),
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
// )
// }
//
// Spacer(modifier = Modifier.height(8.dp))
//
// Button(
// onClick = {
// if (!isEnabled) {
// // 자동 매매 시작: DB 저장
// val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
// val target = curPriceNum * (1 + profitRate.toDouble() / 100.0)
// val stopLoss = curPriceNum * (1 + stopLossRate.toDouble() / 100.0)
//
// DatabaseFactory.saveAutoTrade(
// AutoTradeItem(
// code = stockCode,
// name = stockName,
// targetPrice = target,
// stopLossPrice = stopLoss,
// status = "MONITORING",
// isDomestic = isDomestic
// )
// )
// // [중요] 웹소켓 실시간 감시 등록 로직이 이곳에 호출되어야 함
// // KisWebSocketManager.subscribe(stockCode)
// isEnabled = true
// } else {
// // 자동 매매 중단: DB 삭제
// DatabaseFactory.deleteAutoTrade(stockCode)
// // KisWebSocketManager.unsubscribe(stockCode)
// isEnabled = false
// }
// },
// modifier = Modifier.fillMaxWidth(),
// colors = ButtonDefaults.buttonColors(
// backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF)
// )
// ) {
// Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White)
// }
// }
// }
//}

View File

@ -20,6 +20,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.MAX_BUDGET
import model.buyWeight
import model.feesAndTaxRate
import model.minimumNetProfit
@ -31,42 +32,48 @@ enum class InvestmentGrade(
val description: String,
val shortWeight: Double = 0.0,
val midWeight: Double = 0.0,
val longWeight: Double = 0.0
val longWeight: Double = 0.0,
val profitGuide: Double = 0.0,
) {
LEVEL_5_STRONG_RECOMMEND(
displayName = "최상급 추천",
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
shortWeight = 1.0,
midWeight = 1.0,
longWeight = 1.0
longWeight = 1.0,
profitGuide = 1.8,
),
LEVEL_4_BALANCED_RECOMMEND(
displayName = "균형 추천",
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
shortWeight = 0.8,
midWeight = 1.0,
longWeight = 1.0
longWeight = 1.0,
profitGuide = 1.4,
),
LEVEL_3_CAUTIOUS_RECOMMEND(
displayName = "보수적 추천",
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
shortWeight = 0.6,
midWeight = 1.0,
longWeight = 1.0
longWeight = 1.0,
profitGuide = 1.0,
),
LEVEL_2_HIGH_RISK(
displayName = "고위험 추천",
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
shortWeight = 1.0,
midWeight = 0.4,
longWeight = 0.4
longWeight = 0.4,
profitGuide = 0.8,
),
LEVEL_1_SPECULATIVE(
displayName = "순수 공격적 선택",
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
shortWeight = 1.0,
midWeight = 0.2,
longWeight = 0.2
longWeight = 0.2,
profitGuide = 0.6,
)
}
@ -268,18 +275,13 @@ fun IntegratedOrderSection(
totalScore : ${totalScore}
""".trimIndent())
if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
// 4. 점수에 따른 가변 마진 적용
// 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용
val finalMargin = if (totalScore >= HIGH_QUALITY_SCORE) {
println("💎 [우량주 포착] 토탈 스코어($totalScore)가 매우 높아 목표 마진을 3.0%로 상향합니다.")
minimumNetProfit * 1.5
} else {
minimumNetProfit
}
val finalMargin = minimumNetProfit * investmentGrade.profitGuide
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
val MAX_BUDGET = 35000.0
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
val calculatedQty = if (basePrice > 0) {
(MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)
@ -291,7 +293,7 @@ fun IntegratedOrderSection(
willEnableAutoSell = true,
orderQty = calculatedQty.toString(),
profitRate1 = finalMargin,
investmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence),
investmentGrade = investmentGrade,
)
} else {

View File

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