ㅎㅎㅎ

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-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
val langchain4jVersion = "0.31.0" val langchain4jVersion = "1.10.0"
implementation("dev.langchain4j:langchain4j:$langchain4jVersion") // implementation("dev.langchain4j:langchain4j:$langchain4jVersion")
// implementation("dev.langchain4j:langchain4j-open-ai:$langchain4jVersion")
// llama.cpp 서버가 OpenAI API와 호환되므로 이 라이브러리를 사용합니다. // 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 { compose.desktop {

View File

@ -4,11 +4,23 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application 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.*
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import model.AppConfig import model.AppConfig
import model.KisSession import model.KisSession
import network.DartCodeManager
import network.LlamaServerManager import network.LlamaServerManager
import network.NewsService import network.NewsService
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
@ -19,11 +31,28 @@ import ui.SettingsScreen
enum class AppScreen { Settings, Dashboard } enum class AppScreen { Settings, Dashboard }
fun main() = application { 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" val binPath = "./src/main/resources/bin/llama-server"
val windowState = rememberWindowState(
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매") { placement = WindowPlacement.Maximized
)
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) {
var currentScreen by remember { mutableStateOf(AppScreen.Settings) } var currentScreen by remember { mutableStateOf(AppScreen.Settings) }
var isLoaded by remember { mutableStateOf(false) } var isLoaded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()

View File

@ -8,6 +8,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
import java.io.File import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
object TradeStatus { object TradeStatus {
const val PENDING_BUY = "PENDING_BUY" // 매수 주문 중 const val PENDING_BUY = "PENDING_BUY" // 매수 주문 중
const val MONITORING = "MONITORING" // 매수 체결 후 감시 중 const val MONITORING = "MONITORING" // 매수 체결 후 감시 중
@ -62,41 +63,6 @@ object TradeLogTable : Table("trade_logs") {
override val primaryKey = PrimaryKey(id) 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 { object DatabaseFactory {
fun init() { fun init() {
val dbPath = File("db/autotrade_db").absolutePath val dbPath = File("db/autotrade_db").absolutePath
@ -106,16 +72,13 @@ object DatabaseFactory {
) )
transaction { transaction {
// 테이블 생성 (AutoTradeTable 포함) // 테이블 생성 (AutoTradeTable 포함)
SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable,VectorStoreTable) SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable)
} }
} }
// --- 자동매매(감시) 관련 함수 ---
/** /**
* 새로운 자동매매 등록 (주로 PENDING_BUY 상태로 시작) * 새로운 자동매매 등록 (주로 PENDING_BUY 상태로 시작)
*/ */

View File

@ -17,12 +17,14 @@ data class ChartItem(
@Serializable @Serializable
data class CandleData( data class CandleData(
val stck_cntg_hour : String,
val stck_bsop_date: String, // 영업 일자 val stck_bsop_date: String, // 영업 일자
val stck_oprc: String, // 시가 val stck_oprc: String, // 시가
val stck_hgpr: String, // 고가 val stck_hgpr: String, // 고가
val stck_lwpr: String, // 저가 val stck_lwpr: String, // 저가
val stck_clpr: String, // 종가 val stck_prpr: String, // 현제가
val acml_vol: String ="" // 누적 거래량 val cntg_vol: String,
val acml_tr_pbmn: String,
) )
@Serializable @Serializable
data class OverseasCandleData( data class OverseasCandleData(

View File

@ -13,4 +13,29 @@ data class NewsItem(
val originallink: String, val originallink: String,
val description: String, val description: String,
val pubDate: 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

@ -171,4 +171,4 @@ data class ExecutionData(
val price: String, val price: String,
val qty: String, val qty: String,
val isFilled: Boolean val isFilled: Boolean
) )

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.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
class KisTradeService { object KisTradeService {
private val client = HttpClient(CIO) { private val client = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
@ -225,11 +225,13 @@ class KisTradeService {
val obj = element.jsonObject val obj = element.jsonObject
CandleData( CandleData(
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", 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_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0", stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
stck_lwpr = obj["stck_lwpr"]?.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() }?.reversed() ?: emptyList()
@ -448,7 +450,7 @@ class KisTradeService {
val path = if (isDomestic) val path = if (isDomestic)
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
else "/uapi/overseas-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))) { val searchTime = if (now.isAfter(LocalTime.of(15, 30))) {
"153000" "153000"
} else { } else {
@ -478,11 +480,13 @@ class KisTradeService {
val obj = element.jsonObject val obj = element.jsonObject
CandleData( CandleData(
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", 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_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0", stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
stck_lwpr = obj["stck_lwpr"]?.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() }?.reversed() ?: emptyList()

View File

@ -15,6 +15,8 @@ import io.ktor.client.request.parameter
import io.ktor.http.ContentType.Application.Json import io.ktor.http.ContentType.Application.Json
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import model.CorpInfo
import model.DartFinancialResponse
import model.NaverNewsResponse import model.NaverNewsResponse
object NewsService { object NewsService {
@ -50,7 +52,8 @@ object NewsService {
// RAG 서비스에 학습(Ingest) 시키기 // RAG 서비스에 학습(Ingest) 시키기
RagService.ingest( RagService.ingest(
text = fullText, text = fullText,
meta = "{\"link\": \"${item.originallink}\", \"date\": \"${item.pubDate}\"}" newsLink = item.originallink,
pubDate = item.pubDate
) )
} }
println("📰 '${query}' 관련 뉴스 10개 학습 완료") println("📰 '${query}' 관련 뉴스 10개 학습 완료")
@ -58,4 +61,45 @@ object NewsService {
println("❌ 뉴스 가져오기 실패: ${e.message}") 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 // 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.data.segment.TextSegment
import dev.langchain4j.model.openai.OpenAiChatModel import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel 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.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.plus
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import service.TechnicalAnalyzer
import java.nio.file.Paths
import java.time.Duration import java.time.Duration
object RagService { object RagService {
@ -22,60 +35,168 @@ object RagService {
.timeout(Duration.ofSeconds(60)) .timeout(Duration.ofSeconds(60))
.build() .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에 저장합니다. * 텍스트를 임베딩하여 H2 DB에 저장합니다.
*/ */
fun ingest(text: String, meta: String = "") { fun ingest(text: String, newsLink: String = "", pubDate: String = "") {
val embeddingVector: DoubleArray = embeddingModel.embed(text).content().vector().map { it.toDouble() }.toDoubleArray() // 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성
transaction { val metadata = Metadata()
VectorStoreTable.insert { metadata.put("link", newsLink)
it[content] = text metadata.put("date", pubDate)
it[metadata] = meta
// [수정] 문자열 변환 없이 객체 그대로 전달 // TextSegment.from(text, metadata) 팩토리 메서드 활용
it[embedding] = embeddingVector val segment = TextSegment.from(text, metadata)
} val embedding = embeddingModel.embed(segment).content()
}
println("💾 H2 벡터 저장 완료: ${text.take(15)}...") // 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 답변을 생성합니다. * 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
*/ */
fun askWithContext(question: String): String { fun askWithContext(question: String,
val queryVector = embeddingModel.embed(question).content().vector() corpInfo: String,
// H2 ARRAY 포맷에 맞춰 (v1, v2, ...) 형태로 변환 financialData: String,
val vectorStr = queryVector.joinToString(",", "(", ")") days : List<CandleData>,
weeks : List<CandleData>,
val context = transaction { monthly : List<CandleData>): String {
// 코사인 유사도 기준 상위 5개 뉴스 추출 val questionEmbedding = embeddingModel.embed(question).content()
val query = """ val searchResult = embeddingStore.search(
SELECT CONTENT FROM VECTOR_STORE EmbeddingSearchRequest.builder()
ORDER BY VECTOR_COSINE_SIMILARITY(EMBEDDING, CAST('$vectorStr' AS FLOAT8 ARRAY)) DESC .queryEmbedding(questionEmbedding)
LIMIT 5 .maxResults(5)
""".trimIndent() .build()
)
val results = mutableListOf<String>() val newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
exec(query) { rs ->
while (rs.next()) {
results.add(rs.getString("CONTENT"))
}
}
results.joinToString("\n\n")
}
// 2. 종합 분석 프롬프트 구성
val finalPrompt = """ val finalPrompt = """
<|begin_of_text|><|start_header_id|>system<|end_header_id|> <|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 실시간 뉴스 분석에 능통한 20 경력의 주식 전문가입니다. 당신은 뉴스(심리), 재무(본질), 차트(추세) 통합 분석하는 'AI 수석 애널리스트'입니다.
제공된 [참고 자료] 바탕으로 사용자의 질문에 전문적이고 단호하게 답하세요.<|eot_id|> 제공된 데이터를 바탕으로 아래 형식을 엄격히 지켜 분석 리포트를 작성하세요.
<|start_header_id|>user<|end_header_id|>
[참고 자료]
$context
[질문] [데이터 세트]
$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|> <|eot_id|><|start_header_id|>assistant<|end_header_id|>
""".trimIndent() """.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 //package service
//
import network.NewsService //import kotlinx.coroutines.async
//import kotlinx.coroutines.coroutineScope
object StockAnalysisManager { //import model.CandleData
//import model.RealTimeTrade
suspend fun analyzeStockWithRealTimeData(stockName: String, currentPrice: String): String { //import network.NewsService
println("🔍 [1/3] '${stockName}' 실시간 뉴스 수집 및 학습 시작...") //
//object StockAnalysisManager {
// 1. 실시간 뉴스 검색 및 DB 저장 (Embedding 서버 8081 활용) // var days : List<CandleData> = emptyList()
// 키워드를 "종목명 주가 전망"으로 최적화하여 검색 // var weeks : List<CandleData> = emptyList()
NewsService.fetchAndIngestNews("$stockName 주가 전망") // var monthly : List<CandleData> = emptyList()
// var mins : List<CandleData> = emptyList()
println("🧠 [2/3] 관련 컨텍스트 추출 중...") //
// suspend fun analyzeStockWithMultiData(stockCode : String, stockName: String, result : (String)-> Unit) {
// 2. 방금 저장된 뉴스를 포함하여 DB에서 관련성 높은 정보 추출 // coroutineScope {
val question = "${stockName}의 현재가 ${currentPrice}원 기준, 최근 뉴스 수급 상황과 향후 단기 전망을 분석해줘." // println("🔍 [1/3] '${stockName}' 실시간 뉴스 수집 및 학습 시작...")
val context = RagService.askWithContext(question) //
// val corpInfoDeferred = async { NewsService.fetchCorpInfo(stockCode) }
println("🤖 [3/3] AI 분석 생성 중 (Chat 서버 8080)...") // val financialDataDeferred = async { NewsService.fetchFinancialGrowth(stockCode) }
//
// 3. 최종 분석 결과 반환 // val corpInfo = corpInfoDeferred.await()
return context // 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 package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.* 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.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider import androidx.compose.material.Divider
@ -20,16 +23,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.KisSession import model.KisSession
import model.RealTimeTrade import service.AutoTradingManager
import network.AiService
import service.StockAnalysisManager
@Composable @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 aiOpinion by remember { mutableStateOf("분석 대기 중...") }
var code by remember(stockCode) {
mutableStateOf(stockCode.isNotEmpty())
}
var isAnalyzing by remember { mutableStateOf(false) } var isAnalyzing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() 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), backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
modifier = Modifier.fillMaxWidth() 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = if (isModelConfigured) "🤖 AI 투자 전략" else "⚠️ AI 설정 필요", text = if (isModelConfigured) "${stockName} AI 투자 전략" else "⚠️ AI 설정 필요",
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
) )
@ -57,26 +65,35 @@ fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<model.R
scope.launch { scope.launch {
isAnalyzing = true isAnalyzing = true
try { try {
AutoTradingManager.addStock(stockCode) { msg,success ->
aiOpinion = msg
isAnalyzing = !success
}
// 실시간 데이터 수집부터 분석까지 한 번에 실행 // 실시간 데이터 수집부터 분석까지 한 번에 실행
aiOpinion = StockAnalysisManager.analyzeStockWithRealTimeData( // StockAnalysisManager.analyzeStockWithMultiData(
stockName = stockName, // stockCode = stockCode,
currentPrice = currentPrice // stockName = stockName,
) // result = {
// aiOpinion = it
// }
// )
} catch (e: Exception) { } catch (e: Exception) {
aiOpinion = "분석 중 오류 발생: ${e.message}" aiOpinion = "분석 중 오류 발생: ${e.message}"
} finally { println(aiOpinion)
isAnalyzing = false isAnalyzing = false
} finally {
// isAnalyzing = false
} }
} }
}, },
enabled = !isAnalyzing enabled = !isAnalyzing && code
) { ) {
if (isAnalyzing) { if (isAnalyzing) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("뉴스 분석 중...") Text("뉴스 분석 중...")
} else { } else {
Text("AI 실시간 전략 분석") Text("분석 요청")
} }
} }
} }

View File

@ -33,7 +33,7 @@ fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
data.forEachIndexed { index, candle -> data.forEachIndexed { index, candle ->
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0 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 high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0
val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0 val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0

View File

@ -20,7 +20,7 @@ import util.MarketUtil
@Composable @Composable
fun DashboardScreen() { fun DashboardScreen() {
val tradeService = remember { KisTradeService() } val tradeService = remember { KisTradeService }
val wsManager = remember { KisWebSocketManager() } val wsManager = remember { KisWebSocketManager() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var selectedStockCode by remember { mutableStateOf("") } var selectedStockCode by remember { mutableStateOf("") }
@ -113,7 +113,7 @@ fun DashboardScreen() {
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 내 자산 및 통합 잔고 // [좌측 25%] 내 자산 및 통합 잔고
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) {
BalanceSection(tradeService, BalanceSection(tradeService,
onRefresh = { refreshTrigger++ }, onRefresh = { refreshTrigger++ },
refreshTrigger = refreshTrigger) { code, name, isDom,qty -> refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
@ -128,7 +128,7 @@ fun DashboardScreen() {
VerticalDivider() VerticalDivider()
// [중앙 45%] 실시간 정보 및 주문 // [중앙 45%] 실시간 정보 및 주문
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White)) { Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) {
if (selectedStockCode.isNotEmpty()) { if (selectedStockCode.isNotEmpty()) {
StockDetailSection( StockDetailSection(
stockCode = selectedStockCode, stockCode = selectedStockCode,
@ -151,7 +151,16 @@ fun DashboardScreen() {
} }
VerticalDivider() 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( AutoTradeSection(
isDomestic = isDomestic, isDomestic = isDomestic,
tradeService = tradeService, tradeService = tradeService,
@ -172,7 +181,7 @@ fun DashboardScreen() {
} }
VerticalDivider() VerticalDivider()
// [우측 30%] 시장 추천 TOP 20 (실전 데이터) // [우측 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 -> MarketSection(tradeService) { code, name, isDom ->
val info = StockBasicInfo( val info = StockBasicInfo(
code = code, code = code,

View File

@ -3,14 +3,20 @@ package ui
import AutoTradeItem import AutoTradeItem
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -78,14 +84,14 @@ fun IntegratedOrderSection(
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
// 가격 및 수량 입력 필드 // 가격 및 수량 입력 필드
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
OutlinedTextField( AutoResizeOutlinedTextField(
value = orderQty, value = orderQty,
onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it }, onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it },
label = { Text("수량") }, label = { Text("수량") },
modifier = Modifier.weight(1f).padding(end = 4.dp) modifier = Modifier.weight(1f)
) )
OutlinedTextField( AutoResizeOutlinedTextField(
value = orderPrice, value = orderPrice,
onValueChange = { if (it.all { c -> c.isDigit() }) orderPrice = it }, onValueChange = { if (it.all { c -> c.isDigit() }) orderPrice = it },
label = { Text("가격") }, label = { Text("가격") },
@ -99,11 +105,11 @@ fun IntegratedOrderSection(
SimulationCard(basePrice, inputQty.toDouble()) SimulationCard(basePrice, inputQty.toDouble())
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(4.dp))
// 실시간 AI 매도 감시 설정 카드 // 실시간 AI 매도 감시 설정 카드
Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) { Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) {
Column(modifier = Modifier.padding(8.dp)) { Column(modifier = Modifier.padding(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox( Checkbox(
checked = willEnableAutoSell, checked = willEnableAutoSell,
@ -146,21 +152,19 @@ fun IntegratedOrderSection(
} }
Row { Row {
OutlinedTextField( AutoResizeOutlinedTextField(
value = profitRate, onValueChange = { profitRate = it }, value = profitRate, onValueChange = { profitRate = it },
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp), label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp),
enabled = !willEnableAutoSell
) )
OutlinedTextField( AutoResizeOutlinedTextField(
value = stopLossRate, onValueChange = { stopLossRate = it }, value = stopLossRate, onValueChange = { stopLossRate = it },
label = { Text("손절 %") }, modifier = Modifier.weight(1f), label = { Text("손절 %") }, modifier = Modifier.weight(1f),
enabled = !willEnableAutoSell
) )
} }
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(4.dp))
// 매수 / 매도 실행 버튼 // 매수 / 매도 실행 버튼
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
@ -252,4 +256,70 @@ fun SimulationColumn(title: String, items: List<String>) {
Text(text = text, fontSize = 11.sp, color = color, modifier = Modifier.padding(vertical = 1.dp)) 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()
)
}
)
}
)
}
} }

View File

@ -17,7 +17,7 @@ import model.CandleData
@Composable @Composable
fun PeriodTrendCard(label: String, data: List<CandleData>, modifier: Modifier = Modifier) { fun PeriodTrendCard(label: String, data: List<CandleData>, modifier: Modifier = Modifier) {
val avgPrice = if (data.isEmpty()) "0" 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) { Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) {
Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { 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()) { Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) {
if (data.isNotEmpty()) { if (data.isNotEmpty()) {
Canvas(modifier = Modifier.fillMaxSize()) { 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 max = prices.maxOrNull() ?: 1.0
val min = prices.minOrNull() ?: 0.0 val min = prices.minOrNull() ?: 0.0
val range = if (max == min) 1.0 else max - min val range = if (max == min) 1.0 else max - min

View File

@ -123,7 +123,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
KisSession.config = config KisSession.config = config
DatabaseFactory.saveConfig(config) DatabaseFactory.saveConfig(config)
val authService = KisAuthService() val authService = KisAuthService()
val tradeService = KisTradeService() val tradeService = KisTradeService
val authSuccess = authService.refreshAllTokens() val authSuccess = authService.refreshAllTokens()
val wsKeySuccess = tradeService.refreshWebsocketKey() val wsKeySuccess = tradeService.refreshWebsocketKey()

View File

@ -32,6 +32,9 @@ import model.RankingStock
import model.StockHolding import model.StockHolding
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import service.TechnicalAnalyzer
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlin.collections.isNotEmpty import kotlin.collections.isNotEmpty
@Composable @Composable
@ -59,12 +62,12 @@ fun StockDetailSection(
daySummary.lastOrNull()?.stck_oprc ?: "0" daySummary.lastOrNull()?.stck_oprc ?: "0"
} }
val previousClose = remember(daySummary) { 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 { fun calculateAvg(data: List<CandleData>): String {
if (data.isEmpty()) return "0" 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()) return String.format("%,d", avg.toLong())
} }
@ -92,17 +95,23 @@ fun StockDetailSection(
.onSuccess { data -> .onSuccess { data ->
println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력 println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력
chartData = data chartData = data
TechnicalAnalyzer.min30 = chartData
} }
.onFailure { error -> .onFailure { error ->
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
chartData = emptyList() chartData = emptyList()
}} }}
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { daySummary = it.takeLast(7) } } // 최근 7일 launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) } } // 최근 4주 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 { launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
monthSummary = it.takeLast(6) // 최근 6개월 monthSummary = it.takeLast(6) // 최근 6개월
yearSummary = it.takeLast(36) // 최근 3년 yearSummary = it.takeLast(36) // 최근 3년
} } TechnicalAnalyzer.monthly = yearSummary
}}
} }
isLoading = false isLoading = false
} }
@ -115,7 +124,7 @@ fun StockDetailSection(
val lastCandle = chartData.last() 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) { if (lastCandle.stck_bsop_date != currentMinute) {
// [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과) // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
@ -124,15 +133,17 @@ fun StockDetailSection(
stck_oprc = latestPrice, stck_oprc = latestPrice,
stck_hgpr = latestPrice, stck_hgpr = latestPrice,
stck_lwpr = latestPrice, stck_lwpr = latestPrice,
stck_clpr = latestPrice, stck_prpr = latestPrice,
acml_vol = "0" stck_cntg_hour = currentMinute,
cntg_vol = "1",
acml_tr_pbmn = "1",
) )
// 최대 100개까지만 유지하여 성능 최적화 // 최대 100개까지만 유지하여 성능 최적화
chartData = (chartData + newCandle).takeLast(100) chartData = (chartData + newCandle).takeLast(100)
} else { } else {
// 같은 분 내에서는 기존 마지막 캔들만 업데이트 // 같은 분 내에서는 기존 마지막 캔들만 업데이트
val updatedCandle = lastCandle.copy( 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_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 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)) PeriodTrendCard("3년", yearSummary, Modifier.weight(1f))
} }
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(4.dp))
// [중앙] 캔들 차트 (Card 내부) // [중앙] 캔들 차트 (Card 내부)
Card( Card(
modifier = Modifier.fillMaxWidth().height(300.dp), modifier = Modifier.fillMaxWidth().height(320.dp),
backgroundColor = Color(0xFF121212) backgroundColor = Color(0xFF121212)
) { ) {
if (isLoading) { 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)) { Row(modifier = Modifier.weight(1f)) {