This commit is contained in:
lun_admin 2026-03-17 10:50:13 +09:00
parent 44e14dd207
commit 1eb89bcc3f
49 changed files with 207 additions and 81 deletions

View File

@ -80,7 +80,7 @@ fun main() = application {
// 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
val binPath = getLlamaBinPath()
val windowState = rememberWindowState(
placement = WindowPlacement.Fullscreen
placement = WindowPlacement.Floating
)
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) {
var currentScreen by remember { mutableStateOf(AppScreen.Settings) }

View File

@ -1,6 +1,7 @@
import androidx.compose.runtime.mutableStateListOf
import kotlinx.serialization.Serializable
import model.AppConfig
import network.TradingDecision
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.javatime.datetime
@ -413,7 +414,7 @@ object TradingLogStore {
}
}
fun addLog(tradingDecision: TradingDecision , decision: String, log: String) {
fun addLog(tradingDecision: TradingDecision, decision: String, log: String) {
synchronized(this) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
decisionLogs.add(

View File

@ -1,23 +1,36 @@
// src/main/kotlin/network/RagService.kt
package network// src/main/kotlin/network/RagService.kt
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
import dev.langchain4j.data.document.Metadata
import dev.langchain4j.data.message.UserMessage
import dev.langchain4j.data.segment.TextSegment
import dev.langchain4j.exception.InternalServerException
import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
import dev.langchain4j.service.AiServices
import dev.langchain4j.service.SystemMessage
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
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 kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.lucene.store.MMapDirectory
import org.slf4j.MDC.put
import service.FinancialAnalyzer
import service.InvestmentScores
import service.TechnicalAnalyzer
@ -25,9 +38,10 @@ import service.TradingDecisionCallback
import service.UrlCacheManager
import java.nio.file.Paths
import java.time.Duration
import java.util.concurrent.TimeUnit
interface TradingAnalyst {
@dev.langchain4j.service.SystemMessage("""
@SystemMessage("""
You are a Senior Stock Analyst.
Analyze the data and provide a decision in JSON format.
You must respond ONLY with a valid JSON object.
@ -48,13 +62,13 @@ object RagService {
.apiKey("unused")
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
.timeout(Duration.ofSeconds(60))
.frequencyPenalty(1.1)
.maxTokens(500) // 👈 루프 방지를 위해 반드시 짧게 제한!
// .frequencyPenalty(1.1)
.maxTokens(400) // 👈 루프 방지를 위해 반드시 짧게 제한!
// 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요
.responseFormat("json_object")
.build()
private val analyst = dev.langchain4j.service.AiServices.builder(TradingAnalyst::class.java)
private val analyst = AiServices.builder(TradingAnalyst::class.java)
.chatModel(chatModel)
.build()
@ -139,8 +153,18 @@ object RagService {
object JsonSanitizer {
fun formatJson(raw: String): String {
// 실제 응답 로그 출력 (디버깅용)
println("📥 [AI Raw Response]:\n$raw")
val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL)
return raw.trim()
val match = regex.find(raw)?.value
if (match == null) {
println("⚠️ [JsonSanitizer] JSON 형식을 찾을 수 없습니다.")
return "{}" // 빈 객체라도 반환하여 EOF 방지
}
return match.trim()
.removePrefix("```json")
.removePrefix("```")
.removeSuffix("```")
@ -264,13 +288,80 @@ object RagService {
//// println(response)
// return response.aiMessage().text()
// }
private const val LLM_API_URL = "http://127.0.0.1:8080/v1/chat/completions"
private suspend fun callLlamaWithSchema(prompt: String): String {
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
// 문자열 치환 대신 안전한 JSON 객체 빌더 사용
val requestBodyJson = buildJsonObject {
put("model", "local-model")
put("temperature", 0.1) // 0.1 유지 (결정론적 응답)
put("top_p", 0.9)
put("max_tokens", 500)
putJsonArray("messages") {
addJsonObject {
put("role", "system")
put("content", "You are a helpful AI financial analyst. You must output responses ONLY in valid JSON format.")
}
addJsonObject {
put("role", "user")
put("content", prompt)
}
}
// 💡 복잡한 json_schema를 지우고, 단순히 JSON 형식으로만 내보내라고 지시합니다.
putJsonObject("response_format") {
put("type", "json_object")
}
}.toString()
println("requestBodyJson =>> $requestBodyJson")
val request = Request.Builder()
.url(LLM_API_URL)
.post(requestBodyJson.toRequestBody(jsonMediaType))
.build()
return kotlinx.coroutines.Dispatchers.IO.let {
kotlinx.coroutines.withContext(it) {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("LLM API Error: ${response.code} ${response.message}")
val responseBody = response.body?.string() ?: "{}"
val json = Json.parseToJsonElement(responseBody).jsonObject
json["choices"]?.jsonArray?.get(0)?.jsonObject?.get("message")?.jsonObject?.get("content")?.jsonPrimitive?.content ?: "{}"
}
}
}
}
private val httpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
suspend fun decideTrading(
stockName: String,
scores: InvestmentScores, // 직접 계산한 점수 객체
financialStmt: FinancialStatement, // 매핑된 재무 수치 객체
tempDecision: TradingDecision
): TradingDecision? {
// 💡 1. 뉴스 데이터가 유효한지(100자 이상인지) 확인
val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }
// 💡 2. 동적 데이터 섹션 구성
val newsDataSection = if (validNews != null) {
"3. News Context: $validNews"
} else {
"3. News Context: None available. Base your decision ONLY on System Scores and Financials."
}
// 💡 3. 동적 제약 조건 구성
val newsConstraint = if (validNews != null) {
"- Match Financials with News: If profit is negative but news is hyped, stay CAUTIOUS (HOLD)."
} else {
"- No news data is available. Rely strictly on Financials and System Scores for your 'decision' and 'reason'."
}
val prompt = """
# Task: Senior AI Investment Analyst
Analyze the stock '$stockName' and determine the final trading decision based on the data below.
@ -278,51 +369,52 @@ Analyze the stock '$stockName' and determine the final trading decision based on
# Data
1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm})
2. Financials: Operating Profit ${if(financialStmt.isOperatingProfitPositive) "PROFIT" else "LOSS"} (Growth: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%), ROE: ${"%.2f".format(financialStmt.roe)}%, Debt: ${"%.2f".format(financialStmt.debtRatio)}%
3. News Context: ${tempDecision.newsContext?.take(400)} // 👈 뉴스 길이를 물리적으로 제한
$newsDataSection
# Constraints
1. 모든 점수와 confidence는 0에서 100 사이의 **정수(Integer)**로만 작성하십시오.
- Copy the exact 'System Scores' from the Data section into the output JSON.
- Match Financials with News: If profit is negative but news is hyped, stay CAUTIOUS (HOLD).
- Synchronization: High scalping score + positive news momentum = Higher BUY confidence.
- Output: Response ONLY in valid JSON format. No extra text.
- The "reason" field MUST be written in KOREAN and MUST NOT exceed 50 characters. Keep it concise.
- Output ONLY a valid JSON object matching the exact structure below. DO NOT output placeholder text like '(integer)'.
# Output JSON Format (Reason must be in Korean)
# Example Output JSON Format
{
"ultraShortScore": ${scores.ultraShort},
"shortTermScore": ${scores.shortTerm},
"midTermScore": ${scores.midTerm},
"longTermScore": ${scores.longTerm},
"decision": "BUY/SELL/HOLD",
"reason": "재무와 뉴스를 대조한 분석 결과 (한국어)",
"confidence": 0~100
"ultraShortScore": 0,
"shortTermScore": 0,
"midTermScore": 0,
"longTermScore": 0,
"decision": "HOLD",
"reason": "적자 지속 및 네수파립 임상 대기 중으로 관망 필요",
"confidence": 50
}
""".trimIndent()
val response = chatModel.chat(UserMessage.from(prompt))
val rawResponse = response.aiMessage().text()
val jsonResponse = JsonSanitizer.formatJson(rawResponse)
// println("📥 [AI Raw JSON]:\n$jsonResponse")
// 2. 유연한 파서 설정 (소수점 및 예외 상황 대응)
val lenientJson = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
// JSON 파싱 (Kotlinx Serialization 활용)
return try {
// println(jsonResponse)
val decision = lenientJson.decodeFromString<TradingDecision>(jsonResponse)
decision.financialData = tempDecision.financialData
decision.newsContext = tempDecision.newsContext
decision.techSummary = tempDecision.techSummary
decision.stockCode = tempDecision.stockCode
decision.corpName = tempDecision.corpName
decision.stockName = tempDecision.stockName
val rawResponse = callLlamaWithSchema(prompt)
println("📥 [AI Strict JSON]:\n$rawResponse")
// 엄격한 스키마가 적용되었으므로 JsonSanitizer 없이 바로 파싱 가능
val lenientJson = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
val decision = lenientJson.decodeFromString<TradingDecision>(rawResponse)
// 데이터 매핑
decision.apply {
financialData = tempDecision.financialData
newsContext = tempDecision.newsContext
techSummary = tempDecision.techSummary
stockCode = tempDecision.stockCode
corpName = tempDecision.corpName
this.stockName = tempDecision.stockName
}
decision
} catch (e: dev.langchain4j.exception.InternalServerException) {
} catch (e: InternalServerException) {
// 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리
println("🚨 [AI Server Error] ${e.message}")
if (e.message?.contains("Context size") == true) {

View File

@ -1,9 +1,8 @@
package service
import AutoTradeItem
import TradingDecision
import network.TradingDecision
import TradingLogStore
import androidx.compose.runtime.remember
import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -27,11 +26,11 @@ import model.RankingStock
import model.RankingType
import model.UnifiedBalance
import network.DartCodeManager
import network.FinancialMapper
import network.FinancialStatement
import network.KisAuthService
import network.KisTradeService
import network.KisWebSocketManager
import network.RagService
import network.StockUniverseLoader
import util.MarketUtil
import java.time.LocalDateTime

View File

@ -2,13 +2,9 @@ package service
import com.microsoft.playwright.Browser
import com.microsoft.playwright.Playwright
import com.microsoft.playwright.BrowserType
import com.microsoft.playwright.Page
import com.microsoft.playwright.options.LoadState
import com.microsoft.playwright.options.WaitUntilState
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
@ -18,6 +14,7 @@ import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout
import model.NewsItem
import network.CorpInfo
import network.RagService
import java.net.URL
import kotlin.random.Random

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import network.RagService
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
@ -31,7 +32,7 @@ object LlamaServerManager {
else -> 0 to 4 // 인텔 맥 2017 등
}
val command = listOf(
val command = mutableListOf(
binPath,
"-m", modelPath,
"--port", port.toString(),
@ -40,7 +41,13 @@ object LlamaServerManager {
"-t", threads.toString(),
"--embedding"
)
if (port != 8081) { // 텍스트 생성용 모델에만 적용
command.addAll(listOf(
"-b", "512", // Batch size (토큰 병렬 처리량 제한으로 연산 안정화)
"--threads-batch", threads.toString(),
"-fa","on" // Flash Attention 활성화 (메모리 절약 및 긴 컨텍스트 연산 안정성 증가)
))
}
scope.launch {
try {
val pb = ProcessBuilder(command)

View File

@ -59,18 +59,25 @@ object SystemSleepPreventer {
* 맥의 절전 모드 디스플레이 취침을 방지하는 명령 실행
*/
fun start() {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
val isWin = os.contains("win")
val root = LoggerFactory.getLogger("Exposed") as Logger
root.level = Level.ERROR
checkAndRequestAccessibility()
if (process?.isAlive == true) return
if (!isWin) {
checkAndRequestAccessibility()
}
try {
// -i: 시스템 절전 방지, -d: 디스플레이 취침 방지, -m: 디스크 유휴 상태 방지
val command = listOf("caffeinate", "-i", "-d", "-m")
process = ProcessBuilder(command).start()
println("☕ [System] caffeinate 실행됨: 앱이 켜져 있는 동안 절전 모드가 방지됩니다.")
} catch (e: Exception) {
println("⚠️ [System] caffeinate 실행 실패: ${e.message}")
if (process?.isAlive == true) return
if (!isWin) {
try {
// -i: 시스템 절전 방지, -d: 디스플레이 취침 방지, -m: 디스크 유휴 상태 방지
val command = listOf("caffeinate", "-i", "-d", "-m")
process = ProcessBuilder(command).start()
println("☕ [System] caffeinate 실행됨: 앱이 켜져 있는 동안 절전 모드가 방지됩니다.")
} catch (e: Exception) {
println("⚠️ [System] caffeinate 실행 실패: ${e.message}")
}
}
}

View File

@ -2,7 +2,7 @@
package ui
import AutoTradeItem
import TradingDecision
import network.TradingDecision
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
@ -19,10 +19,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import model.CandleData
import model.ConfigIndex

View File

@ -2,7 +2,7 @@
package ui
import AutoTradeItem
import TradingDecision
import network.TradingDecision
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@ -18,7 +18,6 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.ConfigIndex

View File

@ -128,32 +128,60 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
){
) {
Box(
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state ->
val data = state.dragData
if (data is DragData.FilesList) {
val path = data.readFiles().firstOrNull()?.removePrefix("file:")
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path,)
val rawUri = data.readFiles().firstOrNull()
if (rawUri != null) {
// 1. file:// 또는 file: 접두사 제거
var path = rawUri.removePrefix("file://").removePrefix("file:")
// 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거
if (path.startsWith("/") && path.getOrNull(2) == ':') {
path = path.drop(1)
}
if (path.endsWith(".gguf")) config = config.copy(modelPath = path)
}
}
}),
contentAlignment = Alignment.Center
) {
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
Text(
if (config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath,
fontSize = 12.sp
)
}
Box(
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state ->
val data = state.dragData
if (data is DragData.FilesList) {
val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:")
if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,)
val rawUri = data.readFiles().firstOrNull()
if (rawUri != null) {
// 1. file:// 또는 file: 접두사 제거
var embedModelPath = rawUri.removePrefix("file://").removePrefix("file:")
// 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거
if (embedModelPath.startsWith("/") && embedModelPath.getOrNull(2) == ':') {
embedModelPath = embedModelPath.drop(1)
}
if (embedModelPath.endsWith(".gguf")) config =
config.copy(embedModelPath = embedModelPath)
}
}
}),
contentAlignment = Alignment.Center
) {
Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp)
Text(
if (config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath,
fontSize = 12.sp
)
}
}
Spacer(Modifier.height(10.dp))

View File

@ -2,7 +2,7 @@ package ui
import TradingDecision
import network.TradingDecision
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*