ㅎㅎㅎ

This commit is contained in:
lunaticbum 2026-01-22 16:21:18 +09:00
parent dfc5de7cdc
commit 99804b892a
19 changed files with 714 additions and 178 deletions

View File

@ -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 {

View File

@ -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()

View File

@ -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<DoubleArray>() {
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<DoubleArray>("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 상태로 시작)
*/

View File

@ -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(

View File

@ -14,3 +14,28 @@ data class NewsItem(
val description: String,
val pubDate: String
)
// 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<FinancialAccount>? = null
)
@kotlinx.serialization.Serializable
data class FinancialAccount(
val account_nm: String, // 계정명 (매출액, 영업이익 등)
val thstrm_amount: String, // 당기 금액
val frmtrm_amount: String, // 전기 금액
val bfefrmtrm_amount: String // 전전기 금액
)

View File

@ -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<String, String>()
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]
}
}

View File

@ -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()

View File

@ -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<CorpInfo>()
"기업명: ${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<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()
} catch (e: Exception) {
"재무 API 연동 실패: ${e.message}"
}
} else {
return ""
}
}
}

View File

@ -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
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 ?: ""))
}
}
println("💾 H2 벡터 저장 완료: ${text.take(15)}...")
}
/**
* 질문과 가장 유사한 정보를 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<String>()
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<CandleData>,
weeks : List<CandleData>,
monthly : List<CandleData>): 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<TradingDecision>(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()
}
}

View File

@ -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<String>()
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<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList()
var daily: List<CandleData> = emptyList()
var min30: List<CandleData> = 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<CandleData>): 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<CandleData>): 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<CandleData>): 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<CandleData>, 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<CandleData>): 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
}
}

View File

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

View File

@ -1,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<model.RealTimeTrade>) {
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
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<model.R
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp)) {
Column(modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.verticalScroll(rememberScrollState()) // 스크롤 활성화
.padding(16.dp)
.background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (isModelConfigured) "🤖 AI 투자 전략" else "⚠️ AI 설정 필요",
text = if (isModelConfigured) "${stockName} AI 투자 전략" else "⚠️ AI 설정 필요",
fontWeight = FontWeight.Bold,
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
)
@ -57,26 +65,35 @@ fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<model.R
scope.launch {
isAnalyzing = true
try {
AutoTradingManager.addStock(stockCode) { msg,success ->
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("분석 요청")
}
}
}

View File

@ -33,7 +33,7 @@ fun CandleChart(data: List<CandleData>, 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

View File

@ -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,

View File

@ -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()) {
@ -253,3 +257,69 @@ fun SimulationColumn(title: String, items: List<String>) {
}
}
}
@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()
)
}
)
}
)
}
}

View File

@ -17,7 +17,7 @@ import model.CandleData
@Composable
fun PeriodTrendCard(label: String, data: List<CandleData>, 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<CandleData>, 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

View File

@ -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()

View File

@ -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<CandleData>): 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,16 +95,22 @@ 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)) {