ㅎㅎㅎ
This commit is contained in:
parent
dfc5de7cdc
commit
99804b892a
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 상태로 시작)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 // 전전기 금액
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
65
src/main/kotlin/network/DartCodeManager.kt
Normal file
65
src/main/kotlin/network/DartCodeManager.kt
Normal 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
163
src/main/kotlin/service/AutoTradingManager.kt
Normal file
163
src/main/kotlin/service/AutoTradingManager.kt
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//}
|
||||||
@ -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("분석 요청")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user