From 99804b892a39b785c17e4a1f86e7685241cfa91c Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Thu, 22 Jan 2026 16:21:18 +0900 Subject: [PATCH] =?UTF-8?q?=E3=85=8E=E3=85=8E=E3=85=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 11 +- src/main/kotlin/Main.kt | 35 ++- src/main/kotlin/database/DatabaseFactory.kt | 43 +--- src/main/kotlin/model/ChartModels.kt | 6 +- src/main/kotlin/model/NewsModel.kt | 27 ++- src/main/kotlin/model/StockModels.kt | 2 +- src/main/kotlin/network/DartCodeManager.kt | 65 ++++++ src/main/kotlin/network/KisTradeService.kt | 16 +- src/main/kotlin/network/NewsService.kt | 46 +++- src/main/kotlin/network/RagService.kt | 205 ++++++++++++++---- src/main/kotlin/service/AutoTradingManager.kt | 163 ++++++++++++++ .../kotlin/service/StockAnalysisManager.kt | 65 +++--- src/main/kotlin/ui/AiAnalysisView.kt | 47 ++-- src/main/kotlin/ui/CandleChart.kt | 2 +- src/main/kotlin/ui/DashboardScreen.kt | 19 +- src/main/kotlin/ui/IntegratedOrderSection.kt | 92 +++++++- src/main/kotlin/ui/PeriodTrendCard.kt | 4 +- src/main/kotlin/ui/SettingsScreen.kt | 2 +- src/main/kotlin/ui/StockDetailArea.kt | 42 ++-- 19 files changed, 714 insertions(+), 178 deletions(-) create mode 100644 src/main/kotlin/network/DartCodeManager.kt create mode 100644 src/main/kotlin/service/AutoTradingManager.kt diff --git a/build.gradle.kts b/build.gradle.kts index eb01cc2..7b056c2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,11 +47,16 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") - val langchain4jVersion = "0.31.0" - implementation("dev.langchain4j:langchain4j:$langchain4jVersion") + val langchain4jVersion = "1.10.0" +// implementation("dev.langchain4j:langchain4j:$langchain4jVersion") +// implementation("dev.langchain4j:langchain4j-open-ai:$langchain4jVersion") // llama.cpp 서버가 OpenAI API와 호환되므로 이 라이브러리를 사용합니다. - implementation("dev.langchain4j:langchain4j-open-ai:$langchain4jVersion") + + implementation("dev.langchain4j:langchain4j:${langchain4jVersion}") + implementation("dev.langchain4j:langchain4j-core:${langchain4jVersion}") + implementation("dev.langchain4j:langchain4j-open-ai:${langchain4jVersion}") + implementation("dev.langchain4j:langchain4j-community-lucene:1.10.0-beta18") } compose.desktop { diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index b901a6e..6d77b38 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -4,11 +4,23 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.DEFAULT +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.transaction import model.AppConfig import model.KisSession +import network.DartCodeManager import network.LlamaServerManager import network.NewsService import org.jetbrains.exposed.sql.selectAll @@ -19,11 +31,28 @@ import ui.SettingsScreen enum class AppScreen { Settings, Dashboard } fun main() = application { - + LaunchedEffect(Unit) { + // NewsService나 KisTradeService에서 사용하는 client를 전달 + DartCodeManager.updateCorpCodes(HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요 + }) + } + // [수정] 모든 로그(Headers + Body)를 찍도록 설정 + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.BODY + } + }) + } // 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치) val binPath = "./src/main/resources/bin/llama-server" - - Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매") { + val windowState = rememberWindowState( + placement = WindowPlacement.Maximized + ) + Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) { var currentScreen by remember { mutableStateOf(AppScreen.Settings) } var isLoaded by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 8c27fdd..fe93df9 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -8,6 +8,7 @@ import org.jetbrains.exposed.sql.transactions.transaction import java.io.File import java.time.LocalDateTime + object TradeStatus { const val PENDING_BUY = "PENDING_BUY" // 매수 주문 중 const val MONITORING = "MONITORING" // 매수 체결 후 감시 중 @@ -62,41 +63,6 @@ object TradeLogTable : Table("trade_logs") { override val primaryKey = PrimaryKey(id) } -class VectorColumnType(private val dimension: Int) : ColumnType() { - override fun sqlType(): String = "FLOAT8 ARRAY" - - override fun valueFromDB(value: Any): DoubleArray { - return when (value) { - // H2 드라이버에서 반환된 객체를 처리 - is java.sql.Array -> { - val array = value.array as Array<*> - array.map { (it as Number).toDouble() }.toDoubleArray() - } - is Array<*> -> value.map { (it as Number).toDouble() }.toDoubleArray() - is DoubleArray -> value - else -> DoubleArray(0) - } - } - - // [핵심 수정 부분] - // primitive double[]을 Object Double[]로 변환하여 반환합니다. - // 이렇게 해야 H2 JDBC 드라이버가 직렬화 대신 SQL ARRAY로 인식합니다. - override fun notNullValueToDB(value: DoubleArray): Any { - return value.toTypedArray() - } -} - -object VectorStoreTable : Table("VECTOR_STORE") { - val id = integer("id").autoIncrement() - val content = text("content") - val metadata = text("metadata") - - // 이제 이 컬럼은 DoubleArray 데이터를 직접 주고받습니다. - val embedding = registerColumn("embedding", VectorColumnType(1024)) - - override val primaryKey = PrimaryKey(id) -} - object DatabaseFactory { fun init() { val dbPath = File("db/autotrade_db").absolutePath @@ -106,16 +72,13 @@ object DatabaseFactory { ) transaction { + // 테이블 생성 (AutoTradeTable 포함) - SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable,VectorStoreTable) + SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable) } } - - // --- 자동매매(감시) 관련 함수 --- - - /** * 새로운 자동매매 건 등록 (주로 PENDING_BUY 상태로 시작) */ diff --git a/src/main/kotlin/model/ChartModels.kt b/src/main/kotlin/model/ChartModels.kt index 5bc77c9..6c119f8 100644 --- a/src/main/kotlin/model/ChartModels.kt +++ b/src/main/kotlin/model/ChartModels.kt @@ -17,12 +17,14 @@ data class ChartItem( @Serializable data class CandleData( + val stck_cntg_hour : String, val stck_bsop_date: String, // 영업 일자 val stck_oprc: String, // 시가 val stck_hgpr: String, // 고가 val stck_lwpr: String, // 저가 - val stck_clpr: String, // 종가 - val acml_vol: String ="" // 누적 거래량 + val stck_prpr: String, // 현제가 + val cntg_vol: String, + val acml_tr_pbmn: String, ) @Serializable data class OverseasCandleData( diff --git a/src/main/kotlin/model/NewsModel.kt b/src/main/kotlin/model/NewsModel.kt index 0c2d979..4af3a65 100644 --- a/src/main/kotlin/model/NewsModel.kt +++ b/src/main/kotlin/model/NewsModel.kt @@ -13,4 +13,29 @@ data class NewsItem( val originallink: String, val description: String, val pubDate: String -) \ No newline at end of file +) + + +// service/CorporateService.kt +@kotlinx.serialization.Serializable +data class CorpInfo( + val corp_name: String, // 법인명 + val induty_code: String, // 업종코드 + val main_business: String, // 주요 사업 + val total_stock: String // 상장주식수 등 +) + +@kotlinx.serialization.Serializable +data class DartFinancialResponse( + val status: String, + val list: List? = null +) + +@kotlinx.serialization.Serializable +data class FinancialAccount( + val account_nm: String, // 계정명 (매출액, 영업이익 등) + val thstrm_amount: String, // 당기 금액 + val frmtrm_amount: String, // 전기 금액 + val bfefrmtrm_amount: String // 전전기 금액 +) + diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index a04894d..8c9d111 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -171,4 +171,4 @@ data class ExecutionData( val price: String, val qty: String, val isFilled: Boolean -) \ No newline at end of file +) \ No newline at end of file diff --git a/src/main/kotlin/network/DartCodeManager.kt b/src/main/kotlin/network/DartCodeManager.kt new file mode 100644 index 0000000..92a78c5 --- /dev/null +++ b/src/main/kotlin/network/DartCodeManager.kt @@ -0,0 +1,65 @@ +package network + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream +import javax.xml.parsers.DocumentBuilderFactory + +object DartCodeManager { + private val corpCodeMap = mutableMapOf() + private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력 + + /** + * 앱 실행 시 호출하여 매핑 테이블 업데이트 + */ + suspend fun updateCorpCodes(client: HttpClient) { + println("📂 [DART] 법인코드 매핑 데이터 업데이트 시작...") + + try { + 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() + + ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (entry.name == "CORPCODE.xml") { + parseXml(zis.readAllBytes()) + break + } + entry = zis.nextEntry + } + } + println("✅ [DART] 매핑 완료: ${corpCodeMap.size}개의 상장사 로드됨") + } catch (e: Exception) { + println("❌ [DART] 법인코드 업데이트 실패: ${e.message}") + } + } + + private fun parseXml(xmlBytes: ByteArray) { + val factory = DocumentBuilderFactory.newInstance() + val builder = factory.newDocumentBuilder() + val doc = builder.parse(ByteArrayInputStream(xmlBytes)) + val nodeList = doc.getElementsByTagName("list") + + for (i in 0 until nodeList.length) { + 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 ?: "" + + // 종목코드(stock_code)가 있는 상장사만 매핑에 추가 + if (stockCode.isNotEmpty()) { + corpCodeMap[stockCode] = corpCode + } + } + } + + /** + * 6자리 종목코드로 8자리 법인코드 반환 + */ + fun getCorpCode(stockCode: String): String? { + return corpCodeMap[stockCode] + } +} \ No newline at end of file diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 67a9cf5..279510a 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -30,7 +30,7 @@ import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter -class KisTradeService { +object KisTradeService { private val client = HttpClient(CIO) { install(ContentNegotiation) { json(Json { @@ -225,11 +225,13 @@ class KisTradeService { val obj = element.jsonObject CandleData( stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", - stck_clpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0", + stck_prpr = obj["stck_prpr"]?.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", - acml_vol = obj["acml_vol"]?.jsonPrimitive?.content ?: "0" + cntg_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0", + acml_tr_pbmn = obj["acml_tr_pbmn"]?.jsonPrimitive?.content ?: "0", + stck_cntg_hour = obj["stck_cntg_hour"]?.jsonPrimitive?.content ?: "0", ) }?.reversed() ?: emptyList() @@ -448,7 +450,7 @@ class KisTradeService { val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice" - val now = LocalTime.now() + val now = LocalTime.now().minusMinutes(30) val searchTime = if (now.isAfter(LocalTime.of(15, 30))) { "153000" } else { @@ -478,11 +480,13 @@ class KisTradeService { val obj = element.jsonObject CandleData( stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", - stck_clpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가 + stck_prpr = obj["stck_prpr"]?.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", - acml_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0" // 필수 필드 누락 방지 + cntg_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0", + acml_tr_pbmn = obj["acml_tr_pbmn"]?.jsonPrimitive?.content ?: "0", + stck_cntg_hour = obj["stck_cntg_hour"]?.jsonPrimitive?.content ?: "0", ) }?.reversed() ?: emptyList() diff --git a/src/main/kotlin/network/NewsService.kt b/src/main/kotlin/network/NewsService.kt index 954592f..dd3bbde 100644 --- a/src/main/kotlin/network/NewsService.kt +++ b/src/main/kotlin/network/NewsService.kt @@ -15,6 +15,8 @@ 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 object NewsService { @@ -50,7 +52,8 @@ object NewsService { // RAG 서비스에 학습(Ingest) 시키기 RagService.ingest( text = fullText, - meta = "{\"link\": \"${item.originallink}\", \"date\": \"${item.pubDate}\"}" + newsLink = item.originallink, + pubDate = item.pubDate ) } println("📰 '${query}' 관련 뉴스 10개 학습 완료") @@ -58,4 +61,45 @@ object NewsService { 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() + "기업명: ${response.corp_name}, 주요사업: ${response.main_business}" + } catch (e: Exception) { + "기업 정보 로드 실패" + } + } + + suspend fun fetchFinancialGrowth(corpCode: String?): String { + if (corpCode != null) { + val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc" + // 단일회사 주요계정 API (재무상태표, 손익계산서 주요 항목) + val url = "https://opendart.fss.or.kr/api/fnlttSinglAcnt.json?crtfc_key=$apiKey&corp_code=$corpCode&bsns_year=2024&reprt_code=11011" + + return try { + val response = client.get(url).body() + 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() + } catch (e: Exception) { + "재무 API 연동 실패: ${e.message}" + } + } else { + return "" + } + } + + } \ No newline at end of file diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index 49443b2..203c58f 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -1,12 +1,25 @@ // src/main/kotlin/network/RagService.kt -import VectorStoreTable.metadata +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.model.openai.OpenAiChatModel import dev.langchain4j.model.openai.OpenAiEmbeddingModel +import dev.langchain4j.store.embedding.EmbeddingSearchRequest +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.SqlExpressionBuilder.plus import org.jetbrains.exposed.sql.transactions.transaction +import service.TechnicalAnalyzer +import java.nio.file.Paths import java.time.Duration object RagService { @@ -22,60 +35,168 @@ object RagService { .timeout(Duration.ofSeconds(60)) .build() + + private val embeddingStore: LuceneEmbeddingStore by lazy { + val path = Paths.get("db/lucene_idx") + // FSDirectory.open(path)도 가능하지만, 64bit 시스템(Mac)에선 MMapDirectory가 가장 빠릅니다. + val directory = MMapDirectory(path) + + // 제공해주신 소스의 Builder 사용 + LuceneEmbeddingStore.builder() + .directory(directory) + .build() + } + /** * 텍스트를 임베딩하여 H2 DB에 저장합니다. */ - fun ingest(text: String, meta: String = "") { - val embeddingVector: DoubleArray = embeddingModel.embed(text).content().vector().map { it.toDouble() }.toDoubleArray() - transaction { - VectorStoreTable.insert { - it[content] = text - it[metadata] = meta - // [수정] 문자열 변환 없이 객체 그대로 전달 - it[embedding] = embeddingVector - } - } - println("💾 H2 벡터 저장 완료: ${text.take(15)}...") + fun ingest(text: String, newsLink: String = "", pubDate: String = "") { + // 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성 + val metadata = Metadata() + metadata.put("link", newsLink) + metadata.put("date", pubDate) + + // TextSegment.from(text, metadata) 팩토리 메서드 활용 + val segment = TextSegment.from(text, metadata) + val embedding = embeddingModel.embed(segment).content() + + // LuceneEmbeddingStore.add(Embedding, TextSegment) 호출 + embeddingStore.add(embedding, segment) + println("🔎 [Lucene] 인덱싱 성공: ${text.take(20)}...") } + suspend fun processStock(stockCode: String,result :(String, Boolean)->Unit,decide : (String,TradingDecision?)->Unit) { + // 1. 10분간의 데이터 가져오기 (API 호출) + coroutineScope { + var tradingDecision : TradingDecision = TradingDecision() + val financialDataDeferred = async { NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)) } + + tradingDecision.financialData = financialDataDeferred.await() + result(tradingDecision.toString(),false) + + tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport() + result(tradingDecision.toString(),false) + + val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘." + val questionEmbedding = embeddingModel.embed(question).content() + val searchResult = embeddingStore.search( + EmbeddingSearchRequest.builder() + .queryEmbedding(questionEmbedding) + .maxResults(3) + .build() + ) + tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } + result(tradingDecision.toString(),false) + decide(stockCode,decideTrading(stockCode, tradingDecision.techSummary ?: "", tradingDecision.newsContext ?: "",tradingDecision.financialData ?: "")) + + } + } + + /** * 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다. */ - fun askWithContext(question: String): String { - val queryVector = embeddingModel.embed(question).content().vector() - // H2 ARRAY 포맷에 맞춰 (v1, v2, ...) 형태로 변환 - val vectorStr = queryVector.joinToString(",", "(", ")") - - val context = transaction { - // 코사인 유사도 기준 상위 5개 뉴스 추출 - val query = """ - SELECT CONTENT FROM VECTOR_STORE - ORDER BY VECTOR_COSINE_SIMILARITY(EMBEDDING, CAST('$vectorStr' AS FLOAT8 ARRAY)) DESC - LIMIT 5 - """.trimIndent() - - val results = mutableListOf() - exec(query) { rs -> - while (rs.next()) { - results.add(rs.getString("CONTENT")) - } - } - results.joinToString("\n\n") - } + fun askWithContext(question: String, + corpInfo: String, + financialData: String, + days : List, + weeks : List, + monthly : List): String { + val questionEmbedding = embeddingModel.embed(question).content() + val searchResult = embeddingStore.search( + EmbeddingSearchRequest.builder() + .queryEmbedding(questionEmbedding) + .maxResults(5) + .build() + ) + val newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } + // 2. 종합 분석 프롬프트 구성 val finalPrompt = """ <|begin_of_text|><|start_header_id|>system<|end_header_id|> - 당신은 실시간 뉴스 분석에 능통한 20년 경력의 주식 전문가입니다. - 제공된 [참고 자료]를 바탕으로 사용자의 질문에 전문적이고 단호하게 답하세요.<|eot_id|> - <|start_header_id|>user<|end_header_id|> - [참고 자료] - $context + 당신은 뉴스(심리), 재무(본질), 차트(추세)를 통합 분석하는 'AI 수석 애널리스트'입니다. + 제공된 데이터를 바탕으로 아래 형식을 엄격히 지켜 분석 리포트를 작성하세요. - [질문] - $question + [데이터 세트] + 1. 기업 기본 정보: $corpInfo + 2. 재무 성장성: $financialData + 3. 기술적 추세: ${monthly}, ${weeks}, ${days} + 4. 최신 이슈(뉴스): $newsContext + + [분석 요청 사항] + 1. **업계 상황**: 해당 종목이 속한 업종의 현재 전체적인 흐름을 먼저 정리하세요. + 2. **종목 이슈 분석**: 뉴스에서 포착된 핵심 키워드와 시장의 반응을 요약하세요. + 3. **장기/단기 전략**: + - 장기(재무/월봉 기반): 추천 혹은 비추천 사유 + - 단기(뉴스/일봉 기반): 추천 혹은 비추천 사유 + 4. **최종 결론**: '매수/관망/매도' 의견과 그에 따른 근거를 단호하게 제시하세요. + <|eot_id|> + <|start_header_id|>user<|end_header_id|> + 질문: $question <|eot_id|><|start_header_id|>assistant<|end_header_id|> """.trimIndent() - return chatModel.generate(finalPrompt) + val response = chatModel.chat(UserMessage.from(finalPrompt)) + println(response) + return response.aiMessage().text() + } + + suspend fun decideTrading( + stockName: String, + techSummary: String, + newsContext: String, + financialData: String + ): TradingDecision? { + val prompt = """ + 당신은 단기 데이트레이딩 전문가입니다. 아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요. + + [종목]: $stockName + $techSummary + [관련 뉴스]: $newsContext + [재무 기초]: $financialData + + 반드시 아래 JSON 형식으로만 답변하세요: + { + "decision": "BUY" | "SELL" | "HOLD", + "reason": "결정적 근거 한 줄", + "confidence": 0~100 + } + """.trimIndent() + + val response = chatModel.chat(UserMessage.from(prompt)) + val jsonResponse = response.aiMessage().text() + + // JSON 파싱 (Kotlinx Serialization 활용) + return try { + println(jsonResponse) + val decision = Json.decodeFromString(jsonResponse) + decision.financialData = financialData + decision.newsContext = newsContext + decision.techSummary = techSummary + decision + } catch (e: Exception) { + null + } + } + + +} +@Serializable +class TradingDecision { + var decision: String? = null + var reason: String? = null + var confidence: Int = 0 + var techSummary : String? = null + var newsContext : String? = null + var financialData : String? = null + override fun toString(): String { + return """ +decision: $decision +reason: $reason +confidence: $confidence +techSummary: $techSummary +newsContext: $newsContext +financialData: $financialData + """.trimIndent() } } \ No newline at end of file diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt new file mode 100644 index 0000000..c62690b --- /dev/null +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -0,0 +1,163 @@ +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.LocalTime + +// service/AutoTradingManager.kt +object AutoTradingManager { + private val scope = CoroutineScope(Dispatchers.Default) + val targetStocks = mutableListOf() + + fun addStock(stockCode : String, result :(String, Boolean)->Unit) { + targetStocks.add(stockCode) + startTradingLoop(result) + } + + fun startTradingLoop(result :(String, Boolean)->Unit) { + scope.launch { + println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}") + 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) + } + } + } + delay(10 * 60 * 1000) // 10분 대기 + } + } + + + + private fun executeOrder(code: String, type: String) { + // 실제 증권사 API 호출 로직 (한국투자증권, 키움 등) + println("🔥 [주문 집행] $code $type 완료") + } +} + +object TechnicalAnalyzer { + var monthly: List = emptyList() + var weekly: List = emptyList() + var daily: List = emptyList() + var min30: List = emptyList() + fun generateComprehensiveReport(): String { + // [1] 단기 에너지 지표 계산 (최근 30분봉 기준) + val obv = calculateOBV(min30) + val mfi = calculateMFI(min30, 14) + val adLine = calculateADLine(min30) + + // [2] 시계열별 가격 변동 및 추세 요약 + val m10 = min30.takeLast(10) + val change10 = calculateChange(m10) + val change30 = calculateChange(min30) + val changeDaily = calculateChange(daily.takeLast(2)) // 전일 대비 + + // [3] 이평선 및 가격 위치 + val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average() + val currentPrice = min30.last().stck_prpr.toDouble() + + // [4] 거래량 강도 + val avgVol30 = min30.map { it.cntg_vol.toLong() }.average() + val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average() + val volStrength = if (avgVol30 > 0) recentVol5 / avgVol30 else 1.0 + + return """ + [종합 시계열 및 에너지 분석 보고서] + + 1. 가격 및 추세 현황 + - 월봉/주봉 위치: ${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 "하단 위치"} + + 2. 자금 흐름 및 에너지 지표 + - OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } (${if(obv > 0) "누적 매수 우위" else "누적 매도 우위"}) + - MFI (자금 유입 지수): ${ "%.1f".format(mfi) } (과매수 기준: 80 / 과매도 기준: 20) + - A/D (누적 분산 라인): ${ "%.0f".format(adLine) } (종가 형성 위치와 거래량 결합 수치) + - 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준 + + 3. 가격 변동 범위 + - 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }} + - 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }} + - RSI(14): ${ "%.1f".format(calculateRSI(min30)) } + """.trimIndent() + } + + private fun calculateChange(list: List): Double { + val start = list.first().stck_oprc.toDouble() + val end = list.last().stck_prpr.toDouble() + return if (start != 0.0) ((end - start) / start) * 100 else 0.0 + } + + private fun calculateRSI(list: List): Double { + if (list.size < 2) return 50.0 + var gains = 0.0 + var losses = 0.0 + for (i in 1 until list.size) { + val diff = list[i].stck_prpr.toDouble() - list[i - 1].stck_prpr.toDouble() + if (diff > 0) gains += diff else losses -= diff + } + return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100 + } + + fun calculateOBV(candles: List): Double { + var obv = 0.0 + for (i in 1 until candles.size) { + val prevClose = candles[i - 1].stck_prpr.toDouble() + val currClose = candles[i].stck_prpr.toDouble() + val currVol = candles[i].cntg_vol.toDouble() + + when { + currClose > prevClose -> obv += currVol + currClose < prevClose -> obv -= currVol + } + } + return obv + } + + /** + * MFI (Money Flow Index) 계산 (기간: 보통 14일) + */ + fun calculateMFI(candles: List, period: Int = 14): Double { + val subList = candles.takeLast(period + 1) + var posFlow = 0.0 + var negFlow = 0.0 + + for (i in 1 until subList.size) { + val prevTypical = (subList[i-1].stck_hgpr.toDouble() + subList[i-1].stck_lwpr.toDouble() + subList[i-1].stck_prpr.toDouble()) / 3 + val currTypical = (subList[i].stck_hgpr.toDouble() + subList[i].stck_lwpr.toDouble() + subList[i].stck_prpr.toDouble()) / 3 + val moneyFlow = currTypical * subList[i].cntg_vol.toDouble() + + if (currTypical > prevTypical) posFlow += moneyFlow + else if (currTypical < prevTypical) negFlow += moneyFlow + } + + return if (negFlow == 0.0) 100.0 else 100 - (100 / (1+ (posFlow / negFlow))) + } + + private fun calculateADLine(candles: List): Double { + var ad = 0.0 + candles.forEach { + val high = it.stck_hgpr.toDouble(); val low = it.stck_lwpr.toDouble(); val close = it.stck_prpr.toDouble() + val mfv = if (high != low) ((close - low) - (high - close)) / (high - low) else 0.0 + ad += mfv * it.cntg_vol.toDouble() + } + return ad + } + +} \ No newline at end of file diff --git a/src/main/kotlin/service/StockAnalysisManager.kt b/src/main/kotlin/service/StockAnalysisManager.kt index 5910720..dd74c8d 100644 --- a/src/main/kotlin/service/StockAnalysisManager.kt +++ b/src/main/kotlin/service/StockAnalysisManager.kt @@ -1,25 +1,40 @@ -package service - -import network.NewsService - -object StockAnalysisManager { - - suspend fun analyzeStockWithRealTimeData(stockName: String, currentPrice: String): String { - println("🔍 [1/3] '${stockName}' 실시간 뉴스 수집 및 학습 시작...") - - // 1. 실시간 뉴스 검색 및 DB 저장 (Embedding 서버 8081 활용) - // 키워드를 "종목명 주가 전망"으로 최적화하여 검색 - NewsService.fetchAndIngestNews("$stockName 주가 전망") - - println("🧠 [2/3] 관련 컨텍스트 추출 중...") - - // 2. 방금 저장된 뉴스를 포함하여 DB에서 관련성 높은 정보 추출 - val question = "${stockName}의 현재가 ${currentPrice}원 기준, 최근 뉴스 수급 상황과 향후 단기 전망을 분석해줘." - val context = RagService.askWithContext(question) - - println("🤖 [3/3] AI 분석 생성 중 (Chat 서버 8080)...") - - // 3. 최종 분석 결과 반환 - return context - } -} \ No newline at end of file +//package service +// +//import kotlinx.coroutines.async +//import kotlinx.coroutines.coroutineScope +//import model.CandleData +//import model.RealTimeTrade +//import network.NewsService +// +//object StockAnalysisManager { +// var days : List = emptyList() +// var weeks : List = emptyList() +// var monthly : List = emptyList() +// var mins : List = 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) +// } +// } +// +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/AiAnalysisView.kt b/src/main/kotlin/ui/AiAnalysisView.kt index 444af3d..2e818e2 100644 --- a/src/main/kotlin/ui/AiAnalysisView.kt +++ b/src/main/kotlin/ui/AiAnalysisView.kt @@ -1,9 +1,12 @@ package ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -20,16 +23,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import model.KisSession -import model.RealTimeTrade -import network.AiService -import service.StockAnalysisManager +import service.AutoTradingManager @Composable -fun AiAnalysisView(stockName: String, currentPrice: String, trades: List) { +fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List) { var aiOpinion by remember { mutableStateOf("분석 대기 중...") } + var code by remember(stockCode) { + mutableStateOf(stockCode.isNotEmpty()) + } var isAnalyzing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -44,10 +47,15 @@ fun AiAnalysisView(stockName: String, currentPrice: String, trades: List + aiOpinion = msg + isAnalyzing = !success + } // 실시간 데이터 수집부터 분석까지 한 번에 실행 - aiOpinion = StockAnalysisManager.analyzeStockWithRealTimeData( - stockName = stockName, - currentPrice = currentPrice - ) +// StockAnalysisManager.analyzeStockWithMultiData( +// stockCode = stockCode, +// stockName = stockName, +// result = { +// aiOpinion = it +// } +// ) } catch (e: Exception) { aiOpinion = "분석 중 오류 발생: ${e.message}" - } finally { + println(aiOpinion) isAnalyzing = false + } finally { +// isAnalyzing = false } } }, - enabled = !isAnalyzing + enabled = !isAnalyzing && code ) { if (isAnalyzing) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) Spacer(Modifier.width(8.dp)) Text("뉴스 분석 중...") } else { - Text("AI 실시간 전략 분석") + Text("분석 요청") } } } diff --git a/src/main/kotlin/ui/CandleChart.kt b/src/main/kotlin/ui/CandleChart.kt index 8d24a09..89014f3 100644 --- a/src/main/kotlin/ui/CandleChart.kt +++ b/src/main/kotlin/ui/CandleChart.kt @@ -33,7 +33,7 @@ fun CandleChart(data: List, modifier: Modifier = Modifier) { data.forEachIndexed { index, candle -> val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0 - val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0 + val close = candle.stck_prpr.toDoubleOrNull() ?: 0.0 val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0 val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0 diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index 9c7e059..e7c41ce 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -20,7 +20,7 @@ import util.MarketUtil @Composable fun DashboardScreen() { - val tradeService = remember { KisTradeService() } + val tradeService = remember { KisTradeService } val wsManager = remember { KisWebSocketManager() } val scope = rememberCoroutineScope() var selectedStockCode by remember { mutableStateOf("") } @@ -113,7 +113,7 @@ fun DashboardScreen() { Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { // [좌측 25%] 내 자산 및 통합 잔고 - Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { + Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) { BalanceSection(tradeService, onRefresh = { refreshTrigger++ }, refreshTrigger = refreshTrigger) { code, name, isDom,qty -> @@ -128,7 +128,7 @@ fun DashboardScreen() { VerticalDivider() // [중앙 45%] 실시간 정보 및 주문 - Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White)) { + Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) { if (selectedStockCode.isNotEmpty()) { StockDetailSection( stockCode = selectedStockCode, @@ -151,7 +151,16 @@ fun DashboardScreen() { } VerticalDivider() - Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { + Column(modifier = Modifier.weight(0.2f).fillMaxHeight().padding(8.dp)) { + AiAnalysisView( + stockCode = selectedStockCode, + stockName = selectedStockName, + currentPrice = wsManager.currentPrice.value, + trades = wsManager.tradeLogs + ) + } + VerticalDivider() + Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) { AutoTradeSection( isDomestic = isDomestic, tradeService = tradeService, @@ -172,7 +181,7 @@ fun DashboardScreen() { } VerticalDivider() // [우측 30%] 시장 추천 TOP 20 (실전 데이터) - Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { + Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) { MarketSection(tradeService) { code, name, isDom -> val info = StockBasicInfo( code = code, diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 06561ab..90233ea 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -3,14 +3,20 @@ package ui import AutoTradeItem import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +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.sp import kotlinx.coroutines.launch @@ -78,14 +84,14 @@ fun IntegratedOrderSection( Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) // 가격 및 수량 입력 필드 - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - OutlinedTextField( + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + AutoResizeOutlinedTextField( value = orderQty, onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it }, label = { Text("수량") }, - modifier = Modifier.weight(1f).padding(end = 4.dp) + modifier = Modifier.weight(1f) ) - OutlinedTextField( + AutoResizeOutlinedTextField( value = orderPrice, onValueChange = { if (it.all { c -> c.isDigit() }) orderPrice = it }, label = { Text("가격") }, @@ -99,11 +105,11 @@ fun IntegratedOrderSection( SimulationCard(basePrice, inputQty.toDouble()) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(4.dp)) // 실시간 AI 매도 감시 설정 카드 Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) { - Column(modifier = Modifier.padding(8.dp)) { + Column(modifier = Modifier.padding(4.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( checked = willEnableAutoSell, @@ -146,21 +152,19 @@ fun IntegratedOrderSection( } Row { - OutlinedTextField( + AutoResizeOutlinedTextField( value = profitRate, onValueChange = { profitRate = it }, label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp), - enabled = !willEnableAutoSell ) - OutlinedTextField( + AutoResizeOutlinedTextField( value = stopLossRate, onValueChange = { stopLossRate = it }, label = { Text("손절 %") }, modifier = Modifier.weight(1f), - enabled = !willEnableAutoSell ) } } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(4.dp)) // 매수 / 매도 실행 버튼 Row(modifier = Modifier.fillMaxWidth()) { @@ -252,4 +256,70 @@ fun SimulationColumn(title: String, items: List) { Text(text = text, fontSize = 11.sp, color = color, modifier = Modifier.padding(vertical = 1.dp)) } } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AutoResizeOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: @Composable (() -> Unit)? = null, // 라벨 추가 + placeholder: @Composable (() -> Unit)? = null, // 플레이스홀더 추가 + maxFontSize: TextUnit = 20.sp, + minFontSize: TextUnit = 8.sp +) { + val textMeasurer = rememberTextMeasurer() + var fontSize by remember { mutableStateOf(maxFontSize) } + val interactionSource = remember { MutableInteractionSource() } + + BoxWithConstraints(modifier = modifier) { + val maxWidthPx = constraints.maxWidth + + // 텍스트 너비에 따른 폰트 크기 자동 축소 로직 + LaunchedEffect(value) { + var currentSize = maxFontSize + while (currentSize > minFontSize) { + val layoutResult = textMeasurer.measure( + text = value, + style = TextStyle(fontSize = currentSize) + ) + if (layoutResult.size.width <= maxWidthPx) break + currentSize = (currentSize.value - 0.5f).sp + } + fontSize = currentSize + } + + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle(fontSize = fontSize, color = Color.Black), + modifier = Modifier.fillMaxWidth(), + interactionSource = interactionSource, + singleLine = true, + decorationBox = { innerTextField -> + TextFieldDefaults.OutlinedTextFieldDecorationBox( + value = value, + innerTextField = innerTextField, + enabled = true, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + // [핵심] 사용자가 정의한 라벨과 플레이스홀더 연결 + label = label, + placeholder = placeholder, + // [핵심] 내부 패딩 0.dp 설정 + contentPadding = PaddingValues(0.dp), + border = { + TextFieldDefaults.BorderBox( + enabled = true, + isError = false, + interactionSource = interactionSource, + colors = TextFieldDefaults.outlinedTextFieldColors() + ) + } + ) + } + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/ui/PeriodTrendCard.kt b/src/main/kotlin/ui/PeriodTrendCard.kt index 5de5354..8afaada 100644 --- a/src/main/kotlin/ui/PeriodTrendCard.kt +++ b/src/main/kotlin/ui/PeriodTrendCard.kt @@ -17,7 +17,7 @@ import model.CandleData @Composable fun PeriodTrendCard(label: String, data: List, modifier: Modifier = Modifier) { val avgPrice = if (data.isEmpty()) "0" - else String.format("%,d", data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }.average().toLong()) + else String.format("%,d", data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average().toLong()) Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) { Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { @@ -31,7 +31,7 @@ fun PeriodTrendCard(label: String, data: List, modifier: Modifier = Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) { if (data.isNotEmpty()) { Canvas(modifier = Modifier.fillMaxSize()) { - val prices = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 } + val prices = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 } val max = prices.maxOrNull() ?: 1.0 val min = prices.minOrNull() ?: 0.0 val range = if (max == min) 1.0 else max - min diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt index 0d5b44c..aff5560 100644 --- a/src/main/kotlin/ui/SettingsScreen.kt +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -123,7 +123,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { KisSession.config = config DatabaseFactory.saveConfig(config) val authService = KisAuthService() - val tradeService = KisTradeService() + val tradeService = KisTradeService val authSuccess = authService.refreshAllTokens() val wsKeySuccess = tradeService.refreshWebsocketKey() diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index 3730e11..0e80b96 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -32,6 +32,9 @@ import model.RankingStock import model.StockHolding import network.KisTradeService import network.KisWebSocketManager +import service.TechnicalAnalyzer +import java.time.LocalTime +import java.time.format.DateTimeFormatter import kotlin.collections.isNotEmpty @Composable @@ -59,12 +62,12 @@ fun StockDetailSection( daySummary.lastOrNull()?.stck_oprc ?: "0" } val previousClose = remember(daySummary) { - if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_clpr else "0" + if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0" } fun calculateAvg(data: List): String { if (data.isEmpty()) return "0" - val avg = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }.average() + val avg = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average() return String.format("%,d", avg.toLong()) } @@ -92,17 +95,23 @@ fun StockDetailSection( .onSuccess { data -> println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력 chartData = data + TechnicalAnalyzer.min30 = chartData } .onFailure { error -> println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") chartData = emptyList() }} - launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { daySummary = it.takeLast(7) } } // 최근 7일 - launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) } } // 최근 4주 + launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { + daySummary = it.takeLast(7) } + TechnicalAnalyzer.daily = daySummary + } // 최근 7일 + launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) } + TechnicalAnalyzer.weekly = weekSummary} // 최근 4주 launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess { monthSummary = it.takeLast(6) // 최근 6개월 yearSummary = it.takeLast(36) // 최근 3년 - } } + TechnicalAnalyzer.monthly = yearSummary + }} } isLoading = false } @@ -115,7 +124,7 @@ fun StockDetailSection( val lastCandle = chartData.last() // 현재 시간(분 단위) 확인 - val currentMinute = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HHmm00")) + val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00")) if (lastCandle.stck_bsop_date != currentMinute) { // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과) @@ -124,15 +133,17 @@ fun StockDetailSection( stck_oprc = latestPrice, stck_hgpr = latestPrice, stck_lwpr = latestPrice, - stck_clpr = latestPrice, - acml_vol = "0" + stck_prpr = latestPrice, + stck_cntg_hour = currentMinute, + cntg_vol = "1", + acml_tr_pbmn = "1", ) // 최대 100개까지만 유지하여 성능 최적화 chartData = (chartData + newCandle).takeLast(100) } else { // 같은 분 내에서는 기존 마지막 캔들만 업데이트 val updatedCandle = lastCandle.copy( - stck_clpr = latestPrice, + stck_prpr = latestPrice, stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr, stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr ) @@ -178,10 +189,10 @@ fun StockDetailSection( PeriodTrendCard("3년", yearSummary, Modifier.weight(1f)) } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(4.dp)) // [중앙] 캔들 차트 (Card 내부) Card( - modifier = Modifier.fillMaxWidth().height(300.dp), + modifier = Modifier.fillMaxWidth().height(320.dp), backgroundColor = Color(0xFF121212) ) { if (isLoading) { @@ -191,16 +202,9 @@ fun StockDetailSection( } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(4.dp)) - // [중앙 하단] AI 투자 전략 - AiAnalysisView( - stockName = stockName, - currentPrice = wsManager.currentPrice.value, - trades = wsManager.tradeLogs - ) - Spacer(modifier = Modifier.height(12.dp)) // [하단] 실시간 체결 내역 및 주문 섹션 Row(modifier = Modifier.weight(1f)) {