This commit is contained in:
lunaticbum 2026-01-23 17:05:09 +09:00
parent ac50737ea8
commit d89d793efa
16 changed files with 665 additions and 256 deletions

View File

@ -33,7 +33,8 @@ dependencies {
// Database (Exposed & SQLite)
// H2 Database (네이티브 라이브러리 없는 순수 자바 DB)
implementation("com.h2database:h2:2.2.224")
// Source: https://mvnrepository.com/artifact/com.microsoft.playwright/playwright
implementation("com.microsoft.playwright:playwright:1.57.0")
implementation("io.ktor:ktor-client-websockets:${ktorVersion}")
// SQL 프레임워크 (Exposed)

View File

@ -21,7 +21,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
import model.AppConfig
import model.KisSession
import network.DartCodeManager
import network.LlamaServerManager
import service.LlamaServerManager
import network.NewsService
import org.jetbrains.exposed.sql.selectAll
import ui.DashboardScreen

View File

@ -25,7 +25,20 @@ data class CandleData(
val stck_prpr: String, // 현제가
val cntg_vol: String,
val acml_tr_pbmn: String,
)
) {
override fun toString(): String {
return """
stck_cntg_hour : $stck_cntg_hour
stck_bsop_date : $stck_bsop_date
stck_oprc : $stck_oprc
stck_hgpr : $stck_hgpr
stck_lwpr : $stck_lwpr
stck_prpr : $stck_prpr
cntg_vol : $cntg_vol
acml_tr_pbmn : $acml_tr_pbmn
""".trimIndent()
}
}
@Serializable
data class OverseasCandleData(
val o_sign: String = "", // 대비 기호

View File

@ -8,8 +8,15 @@ import java.io.File
import java.util.zip.ZipInputStream
import javax.xml.parsers.DocumentBuilderFactory
data class CorpInfo(
var cCode : String = "",
var cName : String = "",
var stockCode : String = "",
var stockName : String = "",
)
object DartCodeManager {
private val corpCodeMap = mutableMapOf<String, String>()
private val corpCodeMap = mutableMapOf<String, CorpInfo>()
private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력
private fun saveXmlDebugFile(xmlBytes: ByteArray) {
@ -63,10 +70,11 @@ object DartCodeManager {
val element = nodeList.item(i) as org.w3c.dom.Element
val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: ""
val corpCode = element.getElementsByTagName("corp_code").item(0)?.textContent ?: ""
println("stockCode: $stockCode , corpCode: $corpCode")
val corpName = element.getElementsByTagName("corp_name").item(0)?.textContent ?: ""
// println("[$corpName]stockCode: $stockCode , corpCode: $corpCode")
// 종목코드(stock_code)가 있는 상장사만 매핑에 추가
if (stockCode.isNotEmpty()) {
corpCodeMap[stockCode] = corpCode
corpCodeMap[stockCode] = CorpInfo(corpCode, corpName, stockCode)
}
}
}
@ -74,7 +82,7 @@ object DartCodeManager {
/**
* 6자리 종목코드로 8자리 법인코드 반환
*/
fun getCorpCode(stockCode: String): String? {
fun getCorpCode(stockCode: String): CorpInfo? {
// 1. 직접 매칭 시도
corpCodeMap[stockCode]?.let { return it }

View File

@ -220,12 +220,12 @@ object KisTradeService {
val body = response.body<JsonObject>()
val output2 = body["output2"]?.jsonArray
println("output2 ${output2}")
val candles = output2?.map { element ->
val obj = element.jsonObject
CandleData(
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "",
stck_prpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
stck_prpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0",

View File

@ -15,65 +15,56 @@ import io.ktor.client.request.parameter
import io.ktor.http.ContentType.Application.Json
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import model.CorpInfo
import model.DartFinancialResponse
import model.NaverNewsResponse
import service.DynamicNewsScraper
import service.SafeScraper
import service.UrlCacheManager
object NewsService {
private val client = HttpClient<CIOEngineConfig>(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true })
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
}
suspend fun fetchAndIngestNews(query: String) {
suspend fun fetchAndIngestNews(corpInfo: CorpInfo) {
val clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요
val clientSecret = "DODCxb1M4Z"
try {
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
parameter("query", query)
parameter("display", 10) // 최근 10개 뉴스
parameter("sort", "sim") // 유사도 순 (또는 date 발간순)
header("X-Naver-Client-Id", clientId)
header("X-Naver-Client-Secret", clientSecret)
}.body()
response.items.forEach { item ->
// HTML 태그 제거 및 텍스트 정제
val cleanTitle = item.title.replace(Regex("<[^>]*>"), "")
val cleanDesc = item.description.replace(Regex("<[^>]*>"), "")
val fullText = "[$cleanTitle] $cleanDesc"
println(fullText)
// RAG 서비스에 학습(Ingest) 시키기
RagService.ingest(
text = fullText,
newsLink = item.originallink,
pubDate = item.pubDate
)
var qlist = listOf<String>("${corpInfo.stockName} 분석","${corpInfo.stockName}[${corpInfo.stockCode}]", "${corpInfo.cName} 최근 동향", "${corpInfo.cName}")
qlist.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 발간순)
header("X-Naver-Client-Id", clientId)
header("X-Naver-Client-Secret", clientSecret)
}.body()
SafeScraper.scrapeParallel(corpInfo,response.items)
} catch (e: Exception) {
println("❌ 뉴스 가져오기 실패: ${e.message}")
}
println("📰 '${query}' 관련 뉴스 10개 학습 완료")
} catch (e: Exception) {
println("❌ 뉴스 가져오기 실패: ${e.message}")
}
}
suspend fun fetchCorpInfo(corpCode: String): String {
val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc"
val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode"
return try {
val response = client.get(url).body<CorpInfo>()
"기업명: ${response.corp_name}, 주요사업: ${response.main_business}"
} catch (e: Exception) {
"기업 정보 로드 실패"
}
}
// suspend fun fetchCorpInfo(corpCode: String): String {
// val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc"
// val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode"
//
// return try {
// val response = client.get(url).body<CorpInfo>()
// "기업명: ${response.corp_name}, 주요사업: ${response.main_business}"
// } catch (e: Exception) {
// "기업 정보 로드 실패"
// }
// }
suspend fun fetchFinancialGrowth(corpCode: String?): String {
if (corpCode != null) {
@ -84,15 +75,12 @@ object NewsService {
return try {
val response = client.get(url).body<DartFinancialResponse>()
val accounts = response.list ?: return "재무 데이터 없음"
val revenue = accounts.find { it.account_nm == "매출액" }
val opProfit = accounts.find { it.account_nm == "영업이익" }
"""
[재무 분석 데이터]
- 매출액: (당기)${revenue?.thstrm_amount}, (전기)${revenue?.frmtrm_amount}
- 영업이익: (당기)${opProfit?.thstrm_amount}, (전기)${opProfit?.frmtrm_amount}
""".trimIndent()
var buffer : StringBuffer = StringBuffer()
buffer.append("[재무 분석 데이터]")
response.list.forEach { it
buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n")
}
return buffer.toString()
} catch (e: Exception) {
"재무 API 연동 실패: ${e.message}"
}

View File

@ -7,22 +7,23 @@ import dev.langchain4j.data.segment.TextSegment
import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import model.CandleData
import network.DartCodeManager
import network.KisTradeService
import network.NewsService
import org.apache.lucene.store.MMapDirectory
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import service.TechnicalAnalyzer
import service.TradingDecisionCallback
import service.UrlCacheManager
import java.nio.file.Paths
import java.time.Duration
object RagService {
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
private val embeddingModel = OpenAiEmbeddingModel.builder()
.baseUrl("http://127.0.0.1:8081/v1")
@ -32,6 +33,7 @@ object RagService {
private val chatModel = OpenAiChatModel.builder()
.baseUrl("http://127.0.0.1:8080/v1")
.apiKey("unused")
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
.timeout(Duration.ofSeconds(60))
.build()
@ -45,40 +47,104 @@ object RagService {
LuceneEmbeddingStore.builder()
.directory(directory)
.build()
}
fun active() {
println("[Cache] Active")
if (UrlCacheManager.isInitialized()) return
println("[Cache] initialize")
UrlCacheManager.initialize(embeddingStore, embeddingModel)
}
/**
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
*/
fun ingest(text: String, newsLink: String = "", pubDate: String = "") {
// 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성
val metadata = Metadata()
metadata.put("link", newsLink)
metadata.put("date", pubDate)
fun ingestWithChunking(
text: String,
newsLink: String = "",
pubDate: String = "",
stcokName: String,
corpCode: String,
corpName: String,
stockCode: String
) {
val MAX_CHUNK_SIZE = 500 // 안전하게 500자 내외로 설정
// TextSegment.from(text, metadata) 팩토리 메서드 활용
val segment = TextSegment.from(text, metadata)
val embedding = embeddingModel.embed(segment).content()
// 1. 문단 단위로 먼저 분리
val paragraphs = text.split(Regex("\n\n+"))
val chunks = mutableListOf<String>()
var currentChunk = StringBuilder()
// LuceneEmbeddingStore.add(Embedding, TextSegment) 호출
embeddingStore.add(embedding, segment)
println("🔎 [Lucene] 인덱싱 성공: ${text.take(20)}...")
for (para in paragraphs) {
// 현재 청크에 문단을 더했을 때 제한을 넘으면 지금까지의 내용을 확정
if (currentChunk.length + para.length > MAX_CHUNK_SIZE && currentChunk.isNotEmpty()) {
chunks.add(currentChunk.toString().trim())
currentChunk = StringBuilder()
}
currentChunk.append(para).append("\n\n")
// 문단 하나 자체가 너무 긴 경우 글자 수로 강제 분할
if (currentChunk.length > MAX_CHUNK_SIZE) {
val longPara = currentChunk.toString()
longPara.chunked(MAX_CHUNK_SIZE).forEach { chunks.add(it.trim()) }
currentChunk = StringBuilder()
}
}
if (currentChunk.isNotEmpty()) chunks.add(currentChunk.toString().trim())
// 2. 쪼개진 각 청크를 루씬에 개별 임베딩하여 저장
chunks.forEachIndexed { index, chunk ->
if (chunk.length > 10) { // 너무 짧은 노이즈 제외
val metadata = Metadata()
metadata.put("link", newsLink)
metadata.put("date", pubDate)
metadata.put("chunk_idx", index) // 순서 정보 유지
metadata.put("stcokName",stcokName)
metadata.put("corpCode",corpCode)
metadata.put("corpName",corpName)
metadata.put("stockCode",stockCode)
val segment = TextSegment.from(chunk, metadata)
val embedding = embeddingModel.embed(segment).content()
embeddingStore.add(embedding, segment)
}
}
println("🔎 [Lucene] ${chunks.size}개의 청크로 인덱싱 완료")
}
suspend fun processStock(stockCode: String,result :(String, Boolean)->Unit,decide : (String,TradingDecision?)->Unit) {
object JsonSanitizer {
fun formatJson(raw: String): String {
val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL)
return raw.trim()
.removePrefix("```json")
.removePrefix("```")
.removeSuffix("```")
.trim()
}
}
suspend fun processStock(stockName: String,stockCode: String,result : TradingDecisionCallback) {
// 1. 10분간의 데이터 가져오기 (API 호출)
coroutineScope {
var tradingDecision : TradingDecision = TradingDecision()
val corpCode = DartCodeManager.getCorpCode(stockCode)
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpCode) }
tradingDecision.stockCode = stockCode
var corpInfo = DartCodeManager.getCorpCode(stockCode)
corpInfo?.stockName = stockName
corpInfo?.let { NewsService.fetchAndIngestNews(it) }
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
tradingDecision.financialData = financialDataDeferred.await()
result(tradingDecision.toString(),false)
result(tradingDecision,false)
tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport()
result(tradingDecision.toString(),false)
result(tradingDecision,false)
val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘."
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
val questionEmbedding = embeddingModel.embed(question).content()
val searchResult = embeddingStore.search(
EmbeddingSearchRequest.builder()
@ -87,12 +153,28 @@ object RagService {
.build()
)
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
result(tradingDecision.toString(),false)
decide(stockCode,decideTrading(stockCode, tradingDecision.techSummary ?: "", tradingDecision.newsContext ?: "",tradingDecision.financialData ?: ""))
result(tradingDecision,false)
result(decideTrading(stockCode, tradingDecision),true)
}
}
fun isUrlAlreadyIndexed(url: String): Boolean {
// 1. 메타데이터의 'link' 필드가 해당 URL과 일치하는지 필터 구성
val filter = MetadataFilterBuilder.metadataKey("link").isEqualTo(url)
// 2. 검색 요청 생성 (벡터 유사도와 상관없이 필터 조건에 맞는 것 1개만 찾음)
// 주의: 인터페이스에 따라 더미 벡터(0,0,...)가 필요할 수 있습니다.
val searchRequest = EmbeddingSearchRequest.builder()
.filter(filter)
.maxResults(1)
.build()
val result = embeddingStore.search(searchRequest)
// 결과가 비어있지 않다면 이미 저장된 URL입니다.
return result.matches().isNotEmpty()
}
/**
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
@ -144,57 +226,80 @@ object RagService {
suspend fun decideTrading(
stockName: String,
techSummary: String,
newsContext: String,
financialData: String
tempDecision: TradingDecision
): TradingDecision? {
val prompt = """
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 단기 데이트레이딩 전문가이자 전문 애널리스트입니다.
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
아래 데이터를 분석하여 '매수', '매도', '관망' 하나를 결정하세요.
[데이터 요약]
- 종목: $stockName
$techSummary
- 기업/재무: $financialData
- 시장 심리: $newsContext
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다.
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
아래 데이터를 분석하여 '매수', '매도', '관망' 하나를 결정하세요.
[데이터 요약]
- 종목: $stockName
- 분석: ${tempDecision.techSummary}
- 기업/재무: ${tempDecision.financialData}
- 시장 심리: ${tempDecision.newsContext}
[스코어 산출 가이드 (0-100)]
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80 이상.
2. 단기: 일봉 이평선 정배열 3 변동률 양수일 70 이상.
3. 중기: 주봉 추세와 재무 성장성(매출/영익) 동반 상승 75 이상.
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
[스코어 산출 가이드 (0-100)]
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80 이상.
2. 단기: 일봉 이평선 정배열 3 변동률 양수일 70 이상.
3. 중기: 주봉 추세와 재무 성장성(매출/영익) 동반 상승 75 이상.
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
[응답 형식]
반드시 아래 JSON 형식으로만 답변하십시오:
{
"ultraShortScore": (숫자),
"shortTermScore": (숫자),
"midTermScore": (숫자),
"longTermScore": (숫자),
"decision": "BUY" | "SELL" | "HOLD",
"reason": "결정적 근거 한 줄",
"confidence": 0~100
}
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
[응답 지침 - 엄격 준수]
1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오.
2. 오직 JSON 데이터만 출력하십시오.
3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다.
4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다.
[응답 형식]
반드시 아래 JSON 형식으로만 답변하십시오:
{
"ultraShortScore": (숫자),
"shortTermScore": (숫자),
"midTermScore": (숫자),
"longTermScore": (숫자),
"decision": "BUY" | "SELL" | "HOLD",
"reason": "결정적 근거 한 줄",
"confidence": 0~100
}
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
""".trimIndent()
val response = chatModel.chat(UserMessage.from(prompt))
val jsonResponse = response.aiMessage().text()
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 = Json.decodeFromString<TradingDecision>(jsonResponse)
decision.financialData = financialData
decision.newsContext = newsContext
decision.techSummary = techSummary
val decision = lenientJson.decodeFromString<TradingDecision>(jsonResponse)
decision.financialData = tempDecision.financialData
decision.newsContext = tempDecision.newsContext
decision.techSummary = tempDecision.techSummary
decision.stockCode = tempDecision.stockCode
decision
} catch (e: dev.langchain4j.exception.InternalServerException) {
// 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리
println("🚨 [AI Server Error] ${e.message}")
if (e.message?.contains("Context size") == true) {
println("⚠️ 데이터가 너무 많습니다. 요약 로직을 점검하세요.")
}
tempDecision
null
} catch (e: Exception) {
println("❌ [General Error] ${e.message}")
null
}
}
@ -203,18 +308,28 @@ object RagService {
}
@Serializable
class TradingDecision {
val ultraShortScore: Int = 0 // 초단기 (분봉/에너지)
val shortTermScore: Int = 0 // 단기 (일봉/뉴스)
val midTermScore: Int = 0 // 중기 (주봉/재무)
val longTermScore: Int = 0
val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
val midTermScore: Double = 0.0 // 중기 (주봉/재무)
val longTermScore: Double = 0.0
var stockCode: String = ""
var decision: String? = null
var reason: String? = null
var confidence: Int = 0
var confidence: Double = 0.0
var techSummary : String? = null
var newsContext : String? = null
var financialData : String? = null
fun profitPossible() =
listOf<Double>(ultraShortScore,
shortTermScore,
midTermScore,
longTermScore).average()
override fun toString(): String {
return """
수익실현 가능성 : ${profitPossible()}
ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore
midTermScore :$midTermScore
@ -222,9 +337,9 @@ longTermScore :$longTermScore
decision: $decision
reason: $reason
confidence: $confidence
techSummary: $techSummary
newsContext: $newsContext
financialData: $financialData
기술 분석: $techSummary
뉴스: $newsContext
재무재표: $financialData
""".trimIndent()
}
}

View File

@ -3,13 +3,8 @@ package service
import TradingDecision
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import model.CandleData
import network.KisTradeService
import network.NewsService
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
@ -18,31 +13,34 @@ import kotlin.collections.List
import kotlin.math.*
// service/AutoTradingManager.kt
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
object AutoTradingManager {
private val scope = CoroutineScope(Dispatchers.Default)
val targetStocks = mutableListOf<String>()
val targetStocks = mutableListOf<Pair<String, String>>()
fun addStock(stockCode : String, result :(String, Boolean)->Unit) {
targetStocks.add(stockCode)
startTradingLoop(result)
fun addStock(stockName : String,stockCode : String, result :TradingDecisionCallback) {
targetStocks.add(Pair(stockName, stockCode))
startTradingLoop(stockName,stockCode,result)
}
fun startTradingLoop(result :(String, Boolean)->Unit) {
fun startTradingLoop(stockName : String, stockCode : String, result :TradingDecisionCallback) {
scope.launch {
println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}")
targetStocks.forEach { stockCode ->
// targetStocks.forEach { stockCode ->
launch { // 종목별 병렬 분석 (M3 Pro 파워 활용)
RagService.processStock(stockCode,result) {code ,decision ->
when (decision?.decision) {
"BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수")
"SELL" -> executeOrder(stockCode, "매도")
else -> println("[$stockCode] 관망 유지: ${decision?.reason}")
}
result(decision.toString(),true)
}
RagService.processStock(stockName, stockCode,result)
// {decision,b ->
//// when (decision?.decision) {
//// "BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수")
//// "SELL" -> executeOrder(stockCode, "매도")
//// else -> println("[$stockCode] 관망 유지: ${decision?.reason}")
//// }
// result(decision,b)
// }
}
}
delay(10 * 60 * 1000) // 10분 대기
// }
// targetStocks.re
// delay(10 * 60 * 1000) // 10분 대기
}
}
@ -112,7 +110,7 @@ object TechnicalAnalyzer {
// [3] 이평선 및 가격 위치
val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average()
val currentPrice = min30.last().stck_prpr.toDouble()
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList())
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish())
// [4] 거래량 강도
val avgVol30 = min30.map { it.cntg_vol.toLong() }.average()
val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average()
@ -121,30 +119,29 @@ object TechnicalAnalyzer {
val stochK = calculateStochastic(min30)
val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() }
return """
[초단기 기술적 스켈핑 분석]
- 종합 스코어: ${signal.compositeScore} / 100
- 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
- 성공 확률 예측: ${signal.successProbPct}%
- 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
- RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}
- 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}), 익절가(${signal.suggestedTpPrice.toInt()})
- /단타 종합 스코어: ${signal.compositeScore} / 100
- /단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
- /단타 성공 확률 예측: ${signal.successProbPct}%
- /단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
- /단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}
- /단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}), 익절가(${signal.suggestedTpPrice.toInt()})
- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"}
- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동
- 30 대비: ${ "%.2f".format(change30) }% 변동
- 10 대비: ${ "%.2f".format(change10) }% 변동
- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"}
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } (${if(obv > 0) "누적 매수 우위" else "누적 매도 우위"})
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } (과매수 기준: 80 / 과매도 기준: 20)
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } (종가 형성 위치와 거래량 결합 수치)
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) }
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) }
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) }
- 거래량 강도: 최근 5 평균이 30 평균의 ${ "%.1f".format(volStrength) } 수준
- ATR (평균 변동폭): ${"%.0f".format(atr)} (최근 캔들 하나가 평균적으로 움직이는 크기)
- 30 최대 진폭: ${"%.0f".format(priceRange30)} (최고가-최저가 차이)
- 스토캐스틱(%K): ${"%.1f".format(stochK)} (100 가까울수록 최근 파동의 고점, 0 가까울수록 저점)
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)} 수준으로 전개
- ATR (평균 변동폭): ${"%.0f".format(atr)}
- 30 최대 진폭: ${"%.0f".format(priceRange30)}
- 스토캐스틱(%K): ${"%.1f".format(stochK)}
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)} 수준
- 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }}
- 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }}
- RSI(14): ${ "%.1f".format(calculateRSI(min30)) }
""".trimIndent()
""".trimIndent()
}
/**
@ -194,6 +191,25 @@ object TechnicalAnalyzer {
return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100
}
fun isDailyBullish(): Boolean {
if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리
val currentPrice = daily.last().stck_prpr.toDouble()
// 1. MA20 (한 달 생명선) 계산
val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
// 2. MA5 (단기 가속도) 계산
val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
// 3. 방향성 (어제 MA5 vs 오늘 MA5)
val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
val isMa5Rising = ma5 > prevMa5
// [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주
return currentPrice > ma20 && isMa5Rising
}
fun calculateOBV(candles: List<CandleData>): Double {
var obv = 0.0
for (i in 1 until candles.size) {
@ -298,7 +314,7 @@ class ScalpingAnalyzer {
return Triple(upper, sma, lower)
}
fun analyze(candles: List<Candle>): ScalpingSignalModel {
fun analyze(candles: List<Candle>, isDailyBullish: Boolean): ScalpingSignalModel {
if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요")
val closes = candles.map { it.close }
@ -323,16 +339,34 @@ class ScalpingAnalyzer {
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
} else 0.5
// 신호 조건
val maBull = currentClose > sma10Now && sma10Now > sma20Now
val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high }
val isBreakout = currentClose > nearHigh
// [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인)
val bodySize = abs(current.close - current.open)
val lowerShadow = minOf(current.close, current.open) - current.low
val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우
// 신호 조건 고도화
// 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수
// val maBull = currentClose > sma10Now && sma10Now > sma20Now
val rsiBull = rsiNow > RSI_THRESHOLD
val volSurge = volRatioNow > VOL_SURGE_THRESHOLD
val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS
val buySignal = maBull && rsiBull && volSurge && bbGood
val maBull = currentClose > sma10Now && sma10Now > sma20Now
val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout
// val buySignal = maBull && rsiBull && volSurge && bbGood
// 종합 스코어 (가중: MA 30%, RSI 20%, Vol 30%, BB 20%)
val score = (if (maBull) 30 else 0) + (if (rsiBull) 20 else 0) +
(minOf((volRatioNow - 1.0) * 30, 30.0)).toInt() + (if (bbGood) 20 else 0)
val score = (if (maBull) 25 else 0) +
(if (rsiBull) 15 else 0) +
(if (isBreakout) 20 else 0) + // 돌파 에너지 가중치
(minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() +
(if (bbGood) 10 else 0) +
(if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수
// 위험도 (ATR proxy)
val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 }
@ -351,6 +385,8 @@ class ScalpingAnalyzer {
val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100)
val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
return ScalpingSignalModel(
currentPrice = currentClose,
buySignal = buySignal,

View File

@ -0,0 +1,160 @@
package service
import com.microsoft.playwright.Playwright
import com.microsoft.playwright.BrowserType
import com.microsoft.playwright.Page
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import model.NewsItem
import network.CorpInfo
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))
}
fun extractSmartContentWithLineFilter(page: Page): String {
val script = """
() => {
// 1. 선제적 노이즈 제거: 분석에 방해되는 태그들을 DOM에서 아예 삭제
const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV'];
document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove());
const MIN_LINE_LENGTH = 10;
const MIN_TOTAL_LENGTH = 100;
const CONSECUTIVE_THRESHOLD = 2;
// 2. 라인별 정제 함수 (짧은 라인 연속 시 예외 처리)
const getRefinedText = (el) => {
// 실제 텍스트만 추출하여 라인별로 분리
const lines = el.innerText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
let resultLines = [];
let tempBuffer = [];
let consecutiveShort = 0;
lines.forEach(line => {
if (line.length <= MIN_LINE_LENGTH) {
consecutiveShort++;
tempBuffer.push(line);
} else {
// 짧은 줄이 연속되지 않았을 때만 버퍼를 결과에 합침
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
resultLines = resultLines.concat(tempBuffer);
}
resultLines.push(line);
tempBuffer = [];
consecutiveShort = 0;
}
});
// 마지막 남은 버퍼 처리 (본문 끝에 짧은 정보가 있을 경우 대비)
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
resultLines = resultLines.concat(tempBuffer);
}
return resultLines.join('\n');
};
// 3. 후보 블록 탐색 및 텍스트 밀도 기반 분석
const candidates = Array.from(document.querySelectorAll('div, section, article, p, main, td'))
.map(el => ({
el: el,
refinedText: getRefinedText(el)
}))
.filter(item => {
if (item.refinedText.length < MIN_TOTAL_LENGTH) return false;
// 링크 밀도 체크: 기사 본문은 보통 링크보다 텍스트 비중이 훨씬 높음
const linkLength = Array.from(item.el.querySelectorAll('a'))
.reduce((acc, a) => acc + (a.innerText || "").length, 0);
return (linkLength / item.refinedText.length) < 0.3;
});
// 4. 가장 최적의(가장 깊은 계층의) 본문 컨테이너 선정
const best = candidates.find(parent =>
!candidates.some(child =>
parent.el !== child.el &&
parent.el.contains(child.el) &&
child.refinedText.length > parent.refinedText.length * 0.8
)
);
return best ? best.refinedText : (candidates.sort((a,b) => b.refinedText.length - a.refinedText.length)[0]?.refinedText || "");
}
""".trimIndent()
return page.evaluate(script) as String
}
suspend fun fetchFullContent(url: String): String {
val context = browser.newContext()
val page = context.newPage()
delay(Random.nextInt(1000).toLong())
return try {
// 1. 페이지 이동 및 네트워크 유휴 상태까지 대기
blockUnnecessaryResources(page)
page.navigate(url)
// println(url)
page.waitForLoadState()
var finded = cleanText(extractSmartContentWithLineFilter(page))
println("finded : $finded")
finded
} catch (e: Exception) {
println("❌ [Playwright] 스크래핑 실패: ${e.message}")
""
} finally {
page.close()
context.close()
}
}
private fun blockUnnecessaryResources(page: Page) {
// 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단
page.route("**/*.{png,jpg,jpeg,gif,webp,svg,css,woff,woff2}") { route ->
route.abort()
}
}
private fun cleanText(text: String): String {
return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거
.replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거
.trim()
}
}
object SafeScraper {
// 동시 실행 브라우저 탭을 5개로 제한 (M3 Pro라면 10~20개도 여유롭습니다)
private val semaphore = Semaphore(5)
suspend fun scrapeParallel(corpInfo: CorpInfo,urls: List<NewsItem>) = coroutineScope {
var query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}"
urls.map { item ->
async {
if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) {
semaphore.withPermit {
RagService.ingestWithChunking(
text = DynamicNewsScraper.fetchFullContent(item.originallink),
newsLink = item.originallink,
pubDate = item.pubDate,
stockCode = corpInfo.stockCode,
corpName = corpInfo.cName,
corpCode = corpInfo.cCode,
stcokName = corpInfo.stockName
)
}
println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링")
} else {
println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵")
}
}
}.awaitAll()
println("$query 관련 뉴스 ${urls.size}개 학습 완료")
}
}

View File

@ -1,9 +1,12 @@
package network
package service
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
object LlamaServerManager {
@ -24,7 +27,7 @@ object LlamaServerManager {
binPath,
"-m", modelPath,
"--port", port.toString(),
"-c", if (port == 8081) "512" else "4096", // 임베딩용은 컨텍스트가 짧아도 충분합니다.
"-c", if (port == 8081) "512" else "8192", // 임베딩용은 컨텍스트가 짧아도 충분합니다.
"-ngl", nGpuLayers.toString(),
"-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장
"--embedding" // 임베딩 기능을 활성화합니다.
@ -45,15 +48,20 @@ object LlamaServerManager {
var line: String?
while (reader.readLine().also { line = it } != null) {
// 로그 출력 (디버깅용)
println("[Server $port] $line")
// println("[Server $port] $line")
if (line?.contains("server is listening") == true) {
println("🚀 AI 서버 준비 완료 (Port: $port)")
if (processes.size > 1) {
println("[Cache] ${processes.size}")
RagService.active()
}
}
}
} catch (e: Exception) {
println("❌ AI 서버 실행 실패 (Port: $port): ${e.message}")
processes.remove(port)
}
}
}

View File

@ -0,0 +1,55 @@
package service
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
import dev.langchain4j.model.embedding.EmbeddingModel
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
import java.util.concurrent.ConcurrentHashMap
object UrlCacheManager {
// 1. Thread-safe한 메모리 캐시 (M3 Pro의 멀티코어 환경 대응)
private val processedUrls = ConcurrentHashMap.newKeySet<String>()
/**
* Lucene 저장소에서 모든 link 메타데이터를 읽어와 캐시를 초기화합니다.
*/
fun initialize(embeddingStore: LuceneEmbeddingStore, embeddingModel: EmbeddingModel) {
try {
// 1. 더미 텍스트로 기준 벡터 생성 (또는 0으로 채워진 리스트 생성)
// 모델에게 아무 단어나 던져서 기준이 될 벡터 하나를 임시로 만듭니다.
val dummyEmbedding = embeddingModel.embed("initial_load").content()
// 2. 검색 요청에 더미 벡터 포함
val searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(dummyEmbedding) // [필수] 기준 벡터 주입으로 에러 해결
.maxResults(2000)
.build()
val result = embeddingStore.search(searchRequest)
result.matches().forEach { match ->
val url = match.embedded().metadata().getString("link") as? String
if (url != null) {
processedUrls.add(url)
}
}
println("✅ [Cache] Lucene으로부터 ${processedUrls.size}개의 URL 로드 완료")
} catch (e: Exception) {
println("⚠️ [Cache] 초기화 실패: ${e.message}")
}
}
fun isInitialized(): Boolean {
return processedUrls.isNotEmpty()
}
/**
* 중복 여부 확인 (O(1) 속도)
*/
fun isAlreadyProcessed(url: String): Boolean = processedUrls.contains(url)
/**
* 캐시 업데이트
*/
fun addToCache(url: String) {
processedUrls.add(url)
}
}

View File

@ -26,9 +26,10 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import model.KisSession
import service.AutoTradingManager
import service.TradingDecisionCallback
@Composable
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) {
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
var code by remember(stockCode) {
aiOpinion = ""
@ -66,18 +67,10 @@ fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, tra
scope.launch {
isAnalyzing = true
try {
AutoTradingManager.addStock(stockCode) { msg,success ->
aiOpinion = msg
AutoTradingManager.addStock(stockName,stockCode) { decision,success ->
aiOpinion = decision.toString()
isAnalyzing = !success
}
// 실시간 데이터 수집부터 분석까지 한 번에 실행
// StockAnalysisManager.analyzeStockWithMultiData(
// stockCode = stockCode,
// stockName = stockName,
// result = {
// aiOpinion = it
// }
// )
} catch (e: Exception) {
aiOpinion = "분석 중 오류 발생: ${e.message}"
println(aiOpinion)

View File

@ -152,10 +152,6 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
}
Spacer(Modifier.width(4.dp))
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
Text(
"매수: ${String.format("%,.0f", avgPrice)}${holding.quantity}",
fontSize = 11.sp, color = Color.Gray
)
}
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
}
@ -174,6 +170,10 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Text(
"매수: ${String.format("%,.0f", avgPrice)}${holding.quantity}",
fontSize = 11.sp, color = Color.Gray
)
}
}
}

View File

@ -2,6 +2,7 @@
package ui
import AutoTradeItem
import TradingDecision
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@ -16,7 +17,6 @@ import model.KisSession
import model.StockBasicInfo
import network.KisTradeService
import network.KisWebSocketManager
import util.MarketUtil
@Composable
fun DashboardScreen() {
@ -30,6 +30,8 @@ fun DashboardScreen() {
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
// 중앙 관리용 상태들
var refreshTrigger by remember { mutableStateOf(0) }
@ -141,7 +143,8 @@ fun DashboardScreen() {
scope.launch {
syncAndExecute(orderNo) // 매칭 시도
}
}
},
completeTradingDecision
)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -156,7 +159,12 @@ fun DashboardScreen() {
stockCode = selectedStockCode,
stockName = selectedStockName,
currentPrice = wsManager.currentPrice.value,
trades = wsManager.tradeLogs
trades = wsManager.tradeLogs,
tradingDecisionCallback = { decision,bool ->
if (bool && decision != null && KisSession.config.isSimulation) {
completeTradingDecision = decision
}
}
)
}
VerticalDivider()
@ -196,8 +204,12 @@ fun DashboardScreen() {
}
}
}
}
@Composable
fun VerticalDivider() {
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))

View File

@ -2,7 +2,7 @@
package ui
import AutoTradeItem
import androidx.compose.foundation.background
import TradingDecision
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@ -21,7 +21,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import network.KisTradeService
import network.KisWebSocketManager
import util.MarketUtil
/**
@ -40,7 +39,8 @@ fun IntegratedOrderSection(
holdingQuantity: String,
tradeService: KisTradeService,
onOrderSaved: (String) -> Unit,
onOrderResult: (String, Boolean) -> Unit
onOrderResult: (String, Boolean) -> Unit,
completeTradingDecision: TradingDecision?
) {
val scope = rememberCoroutineScope()
@ -60,6 +60,7 @@ fun IntegratedOrderSection(
}
// UI 입력 상태
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
var orderQty by remember(holdingQuantity) {
@ -80,6 +81,48 @@ fun IntegratedOrderSection(
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
fun excuteTrade(willEnableAutoSell: Boolean,orderQty: String) {
scope.launch {
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
onOrderResult("주문 성공: $realOrderNo", true)
if (willEnableAutoSell) {
val pRate = profitRate.toDoubleOrNull() ?: 0.0
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
code = stockCode,
name = stockName,
quantity = inputQty,
profitRate = pRate,
stopLossRate = sRate,
targetPrice = calculatedTarget,
stopLossPrice = calculatedStop,
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
isDomestic = isDomestic
))
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
onOrderSaved(realOrderNo)
onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true)
}
}
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
}
}
LaunchedEffect(completeTradingDecision) {
if (completeTradingDecision != null &&
completeTradingDecision.stockCode.equals(stockCode)) {
when (completeTradingDecision?.decision) {
"BUY" -> if (completeTradingDecision.confidence > 70) excuteTrade(true, "1")
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
else -> println("[$stockCode] 관망 유지: ${completeTradingDecision?.reason}")
}
}
}
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
@ -171,36 +214,7 @@ fun IntegratedOrderSection(
// 매수 버튼
Button(
onClick = {
scope.launch {
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
onOrderResult("주문 성공: $realOrderNo", true)
if (willEnableAutoSell) {
val pRate = profitRate.toDoubleOrNull() ?: 0.0
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
code = stockCode,
name = stockName,
quantity = inputQty,
profitRate = pRate,
stopLossRate = sRate,
targetPrice = calculatedTarget,
stopLossPrice = calculatedStop,
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
isDomestic = isDomestic
))
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
onOrderSaved(realOrderNo)
onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true)
}
}
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
}
excuteTrade(willEnableAutoSell,orderQty)
},
modifier = Modifier.weight(1f).padding(end = 4.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
@ -224,6 +238,8 @@ fun IntegratedOrderSection(
) { Text("매도", color = Color.White) }
}
}
}
@Composable

View File

@ -2,15 +2,10 @@ package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import TradingDecision
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
@ -18,20 +13,15 @@ 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.coroutineScope
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.DartCodeManager
import network.KisTradeService
import network.KisWebSocketManager
import network.NewsService
import service.TechnicalAnalyzer
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@ -41,11 +31,12 @@ import kotlin.collections.isNotEmpty
fun StockDetailSection(
stockCode: String,
stockName: String,
holdingQuantity : String,
holdingQuantity: String,
isDomestic: Boolean,
tradeService: KisTradeService,
wsManager: KisWebSocketManager,
onOrderSaved: (String) -> Unit
onOrderSaved: (String) -> Unit,
completeTradingDecision: TradingDecision?
) {
var openPrice by remember { mutableStateOf("0") }
@ -65,11 +56,7 @@ fun StockDetailSection(
if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0"
}
fun calculateAvg(data: List<CandleData>): String {
if (data.isEmpty()) return "0"
val avg = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average()
return String.format("%,d", avg.toLong())
}
// 이전 종목 코드를 기억하기 위한 상태
var previousCode by remember { mutableStateOf("") }
@ -88,6 +75,7 @@ fun StockDetailSection(
wsManager.subscribeStock(stockCode)
previousCode = stockCode
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
coroutineScope {
@ -101,18 +89,33 @@ fun StockDetailSection(
.onFailure { error ->
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
chartData = emptyList()
}}
}
}
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
daySummary = it.takeLast(7) }
TechnicalAnalyzer.daily = daySummary
daySummary = it.takeLast(7)
TechnicalAnalyzer.daily = it
println("daySummary ${daySummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
}
} // 최근 7일
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) }
TechnicalAnalyzer.weekly = weekSummary} // 최근 4주
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess {
weekSummary = it.takeLast(4)
TechnicalAnalyzer.weekly = it
println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
}
} // 최근 4주
launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
monthSummary = it.takeLast(6) // 최근 6개월
yearSummary = it.takeLast(36) // 최근 3년
TechnicalAnalyzer.monthly = yearSummary
}}
TechnicalAnalyzer.monthly = it
println("monthSummary ${monthSummary.size} yearSummary ${yearSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
}
}
launch {
DartCodeManager.getCorpCode(stockCode)?.let {
it.stockName = stockName
NewsService.fetchAndIngestNews(it)
}
}
}
isLoading = false
}
@ -230,7 +233,8 @@ fun StockDetailSection(
onOrderResult = { msg, success ->
resultMessage = msg
isSuccess = success
}
},
completeTradingDecision
)
}
}