....
This commit is contained in:
parent
ac50737ea8
commit
d89d793efa
@ -33,7 +33,8 @@ dependencies {
|
||||
// Database (Exposed & SQLite)
|
||||
// H2 Database (네이티브 라이브러리 없는 순수 자바 DB)
|
||||
implementation("com.h2database:h2:2.2.224")
|
||||
|
||||
// Source: https://mvnrepository.com/artifact/com.microsoft.playwright/playwright
|
||||
implementation("com.microsoft.playwright:playwright:1.57.0")
|
||||
implementation("io.ktor:ktor-client-websockets:${ktorVersion}")
|
||||
|
||||
// SQL 프레임워크 (Exposed)
|
||||
|
||||
@ -21,7 +21,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import model.AppConfig
|
||||
import model.KisSession
|
||||
import network.DartCodeManager
|
||||
import network.LlamaServerManager
|
||||
import service.LlamaServerManager
|
||||
import network.NewsService
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import ui.DashboardScreen
|
||||
|
||||
@ -25,7 +25,20 @@ data class CandleData(
|
||||
val stck_prpr: String, // 현제가
|
||||
val cntg_vol: String,
|
||||
val acml_tr_pbmn: String,
|
||||
)
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return """
|
||||
stck_cntg_hour : $stck_cntg_hour
|
||||
stck_bsop_date : $stck_bsop_date
|
||||
stck_oprc : $stck_oprc
|
||||
stck_hgpr : $stck_hgpr
|
||||
stck_lwpr : $stck_lwpr
|
||||
stck_prpr : $stck_prpr
|
||||
cntg_vol : $cntg_vol
|
||||
acml_tr_pbmn : $acml_tr_pbmn
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
data class OverseasCandleData(
|
||||
val o_sign: String = "", // 대비 기호
|
||||
|
||||
@ -8,8 +8,15 @@ import java.io.File
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
|
||||
data class CorpInfo(
|
||||
var cCode : String = "",
|
||||
var cName : String = "",
|
||||
var stockCode : String = "",
|
||||
var stockName : String = "",
|
||||
)
|
||||
object DartCodeManager {
|
||||
private val corpCodeMap = mutableMapOf<String, String>()
|
||||
private val corpCodeMap = mutableMapOf<String, CorpInfo>()
|
||||
private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력
|
||||
|
||||
private fun saveXmlDebugFile(xmlBytes: ByteArray) {
|
||||
@ -63,10 +70,11 @@ object DartCodeManager {
|
||||
val element = nodeList.item(i) as org.w3c.dom.Element
|
||||
val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: ""
|
||||
val corpCode = element.getElementsByTagName("corp_code").item(0)?.textContent ?: ""
|
||||
println("stockCode: $stockCode , corpCode: $corpCode")
|
||||
val corpName = element.getElementsByTagName("corp_name").item(0)?.textContent ?: ""
|
||||
// println("[$corpName]stockCode: $stockCode , corpCode: $corpCode")
|
||||
// 종목코드(stock_code)가 있는 상장사만 매핑에 추가
|
||||
if (stockCode.isNotEmpty()) {
|
||||
corpCodeMap[stockCode] = corpCode
|
||||
corpCodeMap[stockCode] = CorpInfo(corpCode, corpName, stockCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -74,7 +82,7 @@ object DartCodeManager {
|
||||
/**
|
||||
* 6자리 종목코드로 8자리 법인코드 반환
|
||||
*/
|
||||
fun getCorpCode(stockCode: String): String? {
|
||||
fun getCorpCode(stockCode: String): CorpInfo? {
|
||||
// 1. 직접 매칭 시도
|
||||
corpCodeMap[stockCode]?.let { return it }
|
||||
|
||||
|
||||
@ -220,12 +220,12 @@ object KisTradeService {
|
||||
|
||||
val body = response.body<JsonObject>()
|
||||
val output2 = body["output2"]?.jsonArray
|
||||
|
||||
println("output2 ${output2}")
|
||||
val candles = output2?.map { element ->
|
||||
val obj = element.jsonObject
|
||||
CandleData(
|
||||
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "",
|
||||
stck_prpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
|
||||
stck_prpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
|
||||
stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0",
|
||||
|
||||
@ -15,65 +15,56 @@ import io.ktor.client.request.parameter
|
||||
import io.ktor.http.ContentType.Application.Json
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import model.CorpInfo
|
||||
import model.DartFinancialResponse
|
||||
import model.NaverNewsResponse
|
||||
import service.DynamicNewsScraper
|
||||
import service.SafeScraper
|
||||
import service.UrlCacheManager
|
||||
|
||||
object NewsService {
|
||||
private val client = HttpClient<CIOEngineConfig>(CIO) {
|
||||
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true })
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
|
||||
}
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAndIngestNews(query: String) {
|
||||
suspend fun fetchAndIngestNews(corpInfo: CorpInfo) {
|
||||
val clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요
|
||||
val clientSecret = "DODCxb1M4Z"
|
||||
|
||||
try {
|
||||
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
|
||||
parameter("query", query)
|
||||
parameter("display", 10) // 최근 10개 뉴스
|
||||
parameter("sort", "sim") // 유사도 순 (또는 date 발간순)
|
||||
header("X-Naver-Client-Id", clientId)
|
||||
header("X-Naver-Client-Secret", clientSecret)
|
||||
}.body()
|
||||
|
||||
response.items.forEach { item ->
|
||||
// HTML 태그 제거 및 텍스트 정제
|
||||
val cleanTitle = item.title.replace(Regex("<[^>]*>"), "")
|
||||
val cleanDesc = item.description.replace(Regex("<[^>]*>"), "")
|
||||
val fullText = "[$cleanTitle] $cleanDesc"
|
||||
println(fullText)
|
||||
// RAG 서비스에 학습(Ingest) 시키기
|
||||
RagService.ingest(
|
||||
text = fullText,
|
||||
newsLink = item.originallink,
|
||||
pubDate = item.pubDate
|
||||
)
|
||||
var qlist = listOf<String>("${corpInfo.stockName} 분석","${corpInfo.stockName}[${corpInfo.stockCode}]", "${corpInfo.cName} 최근 동향", "${corpInfo.cName}")
|
||||
qlist.forEach { query ->
|
||||
try {
|
||||
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
|
||||
parameter("query", query)
|
||||
parameter("display", 3) // 최근 10개 뉴스
|
||||
parameter("sort", "sim") // 유사도 순 (또는 date 발간순)
|
||||
header("X-Naver-Client-Id", clientId)
|
||||
header("X-Naver-Client-Secret", clientSecret)
|
||||
}.body()
|
||||
SafeScraper.scrapeParallel(corpInfo,response.items)
|
||||
} catch (e: Exception) {
|
||||
println("❌ 뉴스 가져오기 실패: ${e.message}")
|
||||
}
|
||||
println("📰 '${query}' 관련 뉴스 10개 학습 완료")
|
||||
} catch (e: Exception) {
|
||||
println("❌ 뉴스 가져오기 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun fetchCorpInfo(corpCode: String): String {
|
||||
val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc"
|
||||
val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode"
|
||||
|
||||
return try {
|
||||
val response = client.get(url).body<CorpInfo>()
|
||||
"기업명: ${response.corp_name}, 주요사업: ${response.main_business}"
|
||||
} catch (e: Exception) {
|
||||
"기업 정보 로드 실패"
|
||||
}
|
||||
}
|
||||
// suspend fun fetchCorpInfo(corpCode: String): String {
|
||||
// val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc"
|
||||
// val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode"
|
||||
//
|
||||
// return try {
|
||||
// val response = client.get(url).body<CorpInfo>()
|
||||
// "기업명: ${response.corp_name}, 주요사업: ${response.main_business}"
|
||||
// } catch (e: Exception) {
|
||||
// "기업 정보 로드 실패"
|
||||
// }
|
||||
// }
|
||||
|
||||
suspend fun fetchFinancialGrowth(corpCode: String?): String {
|
||||
if (corpCode != null) {
|
||||
@ -84,15 +75,12 @@ object NewsService {
|
||||
return try {
|
||||
val response = client.get(url).body<DartFinancialResponse>()
|
||||
val accounts = response.list ?: return "재무 데이터 없음"
|
||||
|
||||
val revenue = accounts.find { it.account_nm == "매출액" }
|
||||
val opProfit = accounts.find { it.account_nm == "영업이익" }
|
||||
|
||||
"""
|
||||
[재무 분석 데이터]
|
||||
- 매출액: (당기)${revenue?.thstrm_amount}, (전기)${revenue?.frmtrm_amount}
|
||||
- 영업이익: (당기)${opProfit?.thstrm_amount}, (전기)${opProfit?.frmtrm_amount}
|
||||
""".trimIndent()
|
||||
var buffer : StringBuffer = StringBuffer()
|
||||
buffer.append("[재무 분석 데이터]")
|
||||
response.list.forEach { it
|
||||
buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n")
|
||||
}
|
||||
return buffer.toString()
|
||||
} catch (e: Exception) {
|
||||
"재무 API 연동 실패: ${e.message}"
|
||||
}
|
||||
|
||||
@ -7,22 +7,23 @@ import dev.langchain4j.data.segment.TextSegment
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel
|
||||
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
|
||||
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
|
||||
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import model.CandleData
|
||||
import network.DartCodeManager
|
||||
import network.KisTradeService
|
||||
import network.NewsService
|
||||
import org.apache.lucene.store.MMapDirectory
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import service.TechnicalAnalyzer
|
||||
import service.TradingDecisionCallback
|
||||
import service.UrlCacheManager
|
||||
import java.nio.file.Paths
|
||||
import java.time.Duration
|
||||
|
||||
object RagService {
|
||||
|
||||
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
|
||||
private val embeddingModel = OpenAiEmbeddingModel.builder()
|
||||
.baseUrl("http://127.0.0.1:8081/v1")
|
||||
@ -32,6 +33,7 @@ object RagService {
|
||||
private val chatModel = OpenAiChatModel.builder()
|
||||
.baseUrl("http://127.0.0.1:8080/v1")
|
||||
.apiKey("unused")
|
||||
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.build()
|
||||
|
||||
@ -45,40 +47,104 @@ object RagService {
|
||||
LuceneEmbeddingStore.builder()
|
||||
.directory(directory)
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun active() {
|
||||
println("[Cache] Active")
|
||||
if (UrlCacheManager.isInitialized()) return
|
||||
println("[Cache] initialize")
|
||||
UrlCacheManager.initialize(embeddingStore, embeddingModel)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
|
||||
*/
|
||||
fun ingest(text: String, newsLink: String = "", pubDate: String = "") {
|
||||
// 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성
|
||||
val metadata = Metadata()
|
||||
metadata.put("link", newsLink)
|
||||
metadata.put("date", pubDate)
|
||||
fun ingestWithChunking(
|
||||
text: String,
|
||||
newsLink: String = "",
|
||||
pubDate: String = "",
|
||||
stcokName: String,
|
||||
corpCode: String,
|
||||
corpName: String,
|
||||
stockCode: String
|
||||
) {
|
||||
val MAX_CHUNK_SIZE = 500 // 안전하게 500자 내외로 설정
|
||||
|
||||
// TextSegment.from(text, metadata) 팩토리 메서드 활용
|
||||
val segment = TextSegment.from(text, metadata)
|
||||
val embedding = embeddingModel.embed(segment).content()
|
||||
// 1. 문단 단위로 먼저 분리
|
||||
val paragraphs = text.split(Regex("\n\n+"))
|
||||
val chunks = mutableListOf<String>()
|
||||
var currentChunk = StringBuilder()
|
||||
|
||||
// LuceneEmbeddingStore.add(Embedding, TextSegment) 호출
|
||||
embeddingStore.add(embedding, segment)
|
||||
println("🔎 [Lucene] 인덱싱 성공: ${text.take(20)}...")
|
||||
for (para in paragraphs) {
|
||||
// 현재 청크에 문단을 더했을 때 제한을 넘으면 지금까지의 내용을 확정
|
||||
if (currentChunk.length + para.length > MAX_CHUNK_SIZE && currentChunk.isNotEmpty()) {
|
||||
chunks.add(currentChunk.toString().trim())
|
||||
currentChunk = StringBuilder()
|
||||
}
|
||||
currentChunk.append(para).append("\n\n")
|
||||
|
||||
// 문단 하나 자체가 너무 긴 경우 글자 수로 강제 분할
|
||||
if (currentChunk.length > MAX_CHUNK_SIZE) {
|
||||
val longPara = currentChunk.toString()
|
||||
longPara.chunked(MAX_CHUNK_SIZE).forEach { chunks.add(it.trim()) }
|
||||
currentChunk = StringBuilder()
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk.isNotEmpty()) chunks.add(currentChunk.toString().trim())
|
||||
|
||||
// 2. 쪼개진 각 청크를 루씬에 개별 임베딩하여 저장
|
||||
chunks.forEachIndexed { index, chunk ->
|
||||
if (chunk.length > 10) { // 너무 짧은 노이즈 제외
|
||||
val metadata = Metadata()
|
||||
metadata.put("link", newsLink)
|
||||
metadata.put("date", pubDate)
|
||||
metadata.put("chunk_idx", index) // 순서 정보 유지
|
||||
metadata.put("stcokName",stcokName)
|
||||
metadata.put("corpCode",corpCode)
|
||||
metadata.put("corpName",corpName)
|
||||
metadata.put("stockCode",stockCode)
|
||||
|
||||
val segment = TextSegment.from(chunk, metadata)
|
||||
val embedding = embeddingModel.embed(segment).content()
|
||||
embeddingStore.add(embedding, segment)
|
||||
}
|
||||
}
|
||||
println("🔎 [Lucene] ${chunks.size}개의 청크로 인덱싱 완료")
|
||||
}
|
||||
|
||||
suspend fun processStock(stockCode: String,result :(String, Boolean)->Unit,decide : (String,TradingDecision?)->Unit) {
|
||||
object JsonSanitizer {
|
||||
fun formatJson(raw: String): String {
|
||||
val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL)
|
||||
return raw.trim()
|
||||
.removePrefix("```json")
|
||||
.removePrefix("```")
|
||||
.removeSuffix("```")
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun processStock(stockName: String,stockCode: String,result : TradingDecisionCallback) {
|
||||
// 1. 10분간의 데이터 가져오기 (API 호출)
|
||||
coroutineScope {
|
||||
var tradingDecision : TradingDecision = TradingDecision()
|
||||
val corpCode = DartCodeManager.getCorpCode(stockCode)
|
||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpCode) }
|
||||
tradingDecision.stockCode = stockCode
|
||||
var corpInfo = DartCodeManager.getCorpCode(stockCode)
|
||||
corpInfo?.stockName = stockName
|
||||
corpInfo?.let { NewsService.fetchAndIngestNews(it) }
|
||||
|
||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
|
||||
|
||||
tradingDecision.financialData = financialDataDeferred.await()
|
||||
result(tradingDecision.toString(),false)
|
||||
result(tradingDecision,false)
|
||||
|
||||
tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport()
|
||||
result(tradingDecision.toString(),false)
|
||||
result(tradingDecision,false)
|
||||
|
||||
val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘."
|
||||
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
|
||||
val questionEmbedding = embeddingModel.embed(question).content()
|
||||
val searchResult = embeddingStore.search(
|
||||
EmbeddingSearchRequest.builder()
|
||||
@ -87,12 +153,28 @@ object RagService {
|
||||
.build()
|
||||
)
|
||||
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
|
||||
result(tradingDecision.toString(),false)
|
||||
decide(stockCode,decideTrading(stockCode, tradingDecision.techSummary ?: "", tradingDecision.newsContext ?: "",tradingDecision.financialData ?: ""))
|
||||
result(tradingDecision,false)
|
||||
result(decideTrading(stockCode, tradingDecision),true)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun isUrlAlreadyIndexed(url: String): Boolean {
|
||||
// 1. 메타데이터의 'link' 필드가 해당 URL과 일치하는지 필터 구성
|
||||
val filter = MetadataFilterBuilder.metadataKey("link").isEqualTo(url)
|
||||
|
||||
// 2. 검색 요청 생성 (벡터 유사도와 상관없이 필터 조건에 맞는 것 1개만 찾음)
|
||||
// 주의: 인터페이스에 따라 더미 벡터(0,0,...)가 필요할 수 있습니다.
|
||||
val searchRequest = EmbeddingSearchRequest.builder()
|
||||
.filter(filter)
|
||||
.maxResults(1)
|
||||
.build()
|
||||
|
||||
val result = embeddingStore.search(searchRequest)
|
||||
|
||||
// 결과가 비어있지 않다면 이미 저장된 URL입니다.
|
||||
return result.matches().isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
|
||||
@ -144,57 +226,80 @@ object RagService {
|
||||
|
||||
suspend fun decideTrading(
|
||||
stockName: String,
|
||||
techSummary: String,
|
||||
newsContext: String,
|
||||
financialData: String
|
||||
tempDecision: TradingDecision
|
||||
): TradingDecision? {
|
||||
val prompt = """
|
||||
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
||||
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 단기 데이트레이딩 전문가이자 전문 애널리스트입니다.
|
||||
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
|
||||
아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
|
||||
|
||||
[데이터 요약]
|
||||
- 종목: $stockName
|
||||
$techSummary
|
||||
- 기업/재무: $financialData
|
||||
- 시장 심리: $newsContext
|
||||
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
||||
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다.
|
||||
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
|
||||
아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
|
||||
|
||||
[데이터 요약]
|
||||
- 종목: $stockName
|
||||
- 분석: ${tempDecision.techSummary}
|
||||
- 기업/재무: ${tempDecision.financialData}
|
||||
- 시장 심리: ${tempDecision.newsContext}
|
||||
|
||||
[스코어 산출 가이드 (0-100)]
|
||||
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상.
|
||||
2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상.
|
||||
3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상.
|
||||
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
|
||||
[스코어 산출 가이드 (0-100)]
|
||||
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상.
|
||||
2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상.
|
||||
3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상.
|
||||
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
|
||||
|
||||
[응답 형식]
|
||||
반드시 아래 JSON 형식으로만 답변하십시오:
|
||||
{
|
||||
"ultraShortScore": (숫자),
|
||||
"shortTermScore": (숫자),
|
||||
"midTermScore": (숫자),
|
||||
"longTermScore": (숫자),
|
||||
"decision": "BUY" | "SELL" | "HOLD",
|
||||
"reason": "결정적 근거 한 줄",
|
||||
"confidence": 0~100
|
||||
}
|
||||
<|eot_id|>
|
||||
<|start_header_id|>user<|end_header_id|>
|
||||
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
|
||||
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
||||
[응답 지침 - 엄격 준수]
|
||||
1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오.
|
||||
2. 오직 JSON 데이터만 출력하십시오.
|
||||
3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다.
|
||||
4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다.
|
||||
[응답 형식]
|
||||
반드시 아래 JSON 형식으로만 답변하십시오:
|
||||
{
|
||||
"ultraShortScore": (숫자),
|
||||
"shortTermScore": (숫자),
|
||||
"midTermScore": (숫자),
|
||||
"longTermScore": (숫자),
|
||||
"decision": "BUY" | "SELL" | "HOLD",
|
||||
"reason": "결정적 근거 한 줄",
|
||||
"confidence": 0~100
|
||||
}
|
||||
<|eot_id|>
|
||||
<|start_header_id|>user<|end_header_id|>
|
||||
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
|
||||
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
||||
""".trimIndent()
|
||||
|
||||
val response = chatModel.chat(UserMessage.from(prompt))
|
||||
val jsonResponse = response.aiMessage().text()
|
||||
val rawResponse = response.aiMessage().text()
|
||||
val jsonResponse = JsonSanitizer.formatJson(rawResponse)
|
||||
println("📥 [AI Raw JSON]:\n$jsonResponse")
|
||||
|
||||
// 2. 유연한 파서 설정 (소수점 및 예외 상황 대응)
|
||||
val lenientJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
|
||||
// JSON 파싱 (Kotlinx Serialization 활용)
|
||||
return try {
|
||||
println(jsonResponse)
|
||||
val decision = Json.decodeFromString<TradingDecision>(jsonResponse)
|
||||
decision.financialData = financialData
|
||||
decision.newsContext = newsContext
|
||||
decision.techSummary = techSummary
|
||||
val decision = lenientJson.decodeFromString<TradingDecision>(jsonResponse)
|
||||
decision.financialData = tempDecision.financialData
|
||||
decision.newsContext = tempDecision.newsContext
|
||||
decision.techSummary = tempDecision.techSummary
|
||||
decision.stockCode = tempDecision.stockCode
|
||||
decision
|
||||
} catch (e: dev.langchain4j.exception.InternalServerException) {
|
||||
// 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리
|
||||
println("🚨 [AI Server Error] ${e.message}")
|
||||
if (e.message?.contains("Context size") == true) {
|
||||
println("⚠️ 데이터가 너무 많습니다. 요약 로직을 점검하세요.")
|
||||
}
|
||||
tempDecision
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
println("❌ [General Error] ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@ -203,18 +308,28 @@ object RagService {
|
||||
}
|
||||
@Serializable
|
||||
class TradingDecision {
|
||||
val ultraShortScore: Int = 0 // 초단기 (분봉/에너지)
|
||||
val shortTermScore: Int = 0 // 단기 (일봉/뉴스)
|
||||
val midTermScore: Int = 0 // 중기 (주봉/재무)
|
||||
val longTermScore: Int = 0
|
||||
|
||||
val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
|
||||
val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
|
||||
val midTermScore: Double = 0.0 // 중기 (주봉/재무)
|
||||
val longTermScore: Double = 0.0
|
||||
var stockCode: String = ""
|
||||
var decision: String? = null
|
||||
var reason: String? = null
|
||||
var confidence: Int = 0
|
||||
var confidence: Double = 0.0
|
||||
var techSummary : String? = null
|
||||
var newsContext : String? = null
|
||||
var financialData : String? = null
|
||||
|
||||
fun profitPossible() =
|
||||
listOf<Double>(ultraShortScore,
|
||||
shortTermScore,
|
||||
midTermScore,
|
||||
longTermScore).average()
|
||||
|
||||
override fun toString(): String {
|
||||
return """
|
||||
수익실현 가능성 : ${profitPossible()}
|
||||
ultraShortScore :$ultraShortScore
|
||||
shortTermScore :$shortTermScore
|
||||
midTermScore :$midTermScore
|
||||
@ -222,9 +337,9 @@ longTermScore :$longTermScore
|
||||
decision: $decision
|
||||
reason: $reason
|
||||
confidence: $confidence
|
||||
techSummary: $techSummary
|
||||
newsContext: $newsContext
|
||||
financialData: $financialData
|
||||
기술 분석: $techSummary
|
||||
뉴스: $newsContext
|
||||
재무재표: $financialData
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@ -3,13 +3,8 @@ package service
|
||||
import TradingDecision
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import model.CandleData
|
||||
import network.KisTradeService
|
||||
import network.NewsService
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
@ -18,31 +13,34 @@ import kotlin.collections.List
|
||||
|
||||
import kotlin.math.*
|
||||
// service/AutoTradingManager.kt
|
||||
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
|
||||
object AutoTradingManager {
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
val targetStocks = mutableListOf<String>()
|
||||
val targetStocks = mutableListOf<Pair<String, String>>()
|
||||
|
||||
fun addStock(stockCode : String, result :(String, Boolean)->Unit) {
|
||||
targetStocks.add(stockCode)
|
||||
startTradingLoop(result)
|
||||
fun addStock(stockName : String,stockCode : String, result :TradingDecisionCallback) {
|
||||
targetStocks.add(Pair(stockName, stockCode))
|
||||
startTradingLoop(stockName,stockCode,result)
|
||||
}
|
||||
|
||||
fun startTradingLoop(result :(String, Boolean)->Unit) {
|
||||
fun startTradingLoop(stockName : String, stockCode : String, result :TradingDecisionCallback) {
|
||||
scope.launch {
|
||||
println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}")
|
||||
targetStocks.forEach { stockCode ->
|
||||
// targetStocks.forEach { stockCode ->
|
||||
launch { // 종목별 병렬 분석 (M3 Pro 파워 활용)
|
||||
RagService.processStock(stockCode,result) {code ,decision ->
|
||||
when (decision?.decision) {
|
||||
"BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수")
|
||||
"SELL" -> executeOrder(stockCode, "매도")
|
||||
else -> println("[$stockCode] 관망 유지: ${decision?.reason}")
|
||||
}
|
||||
result(decision.toString(),true)
|
||||
}
|
||||
RagService.processStock(stockName, stockCode,result)
|
||||
// {decision,b ->
|
||||
//// when (decision?.decision) {
|
||||
//// "BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수")
|
||||
//// "SELL" -> executeOrder(stockCode, "매도")
|
||||
//// else -> println("[$stockCode] 관망 유지: ${decision?.reason}")
|
||||
//// }
|
||||
// result(decision,b)
|
||||
// }
|
||||
}
|
||||
}
|
||||
delay(10 * 60 * 1000) // 10분 대기
|
||||
// }
|
||||
// targetStocks.re
|
||||
// delay(10 * 60 * 1000) // 10분 대기
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,7 +110,7 @@ object TechnicalAnalyzer {
|
||||
// [3] 이평선 및 가격 위치
|
||||
val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList())
|
||||
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish())
|
||||
// [4] 거래량 강도
|
||||
val avgVol30 = min30.map { it.cntg_vol.toLong() }.average()
|
||||
val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average()
|
||||
@ -121,30 +119,29 @@ object TechnicalAnalyzer {
|
||||
val stochK = calculateStochastic(min30)
|
||||
val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() }
|
||||
return """
|
||||
[초단기 기술적 스켈핑 분석]
|
||||
- 종합 스코어: ${signal.compositeScore} / 100
|
||||
- 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
|
||||
- 성공 확률 예측: ${signal.successProbPct}%
|
||||
- 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
|
||||
- RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배
|
||||
- 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원)
|
||||
- 초/단타 종합 스코어: ${signal.compositeScore} / 100
|
||||
- 초/단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
|
||||
- 초/단타 성공 확률 예측: ${signal.successProbPct}%
|
||||
- 초/단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
|
||||
- 초/단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배
|
||||
- 초/단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원)
|
||||
- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"}
|
||||
- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동
|
||||
- 30분 대비: ${ "%.2f".format(change30) }% 변동
|
||||
- 10분 대비: ${ "%.2f".format(change10) }% 변동
|
||||
- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"}
|
||||
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } (${if(obv > 0) "누적 매수 우위" else "누적 매도 우위"})
|
||||
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } (과매수 기준: 80 / 과매도 기준: 20)
|
||||
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } (종가 형성 위치와 거래량 결합 수치)
|
||||
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) }
|
||||
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) }
|
||||
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) }
|
||||
- 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준
|
||||
- ATR (평균 변동폭): ${"%.0f".format(atr)}원 (최근 캔들 하나가 평균적으로 움직이는 크기)
|
||||
- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원 (최고가-최저가 차이)
|
||||
- 스토캐스틱(%K): ${"%.1f".format(stochK)} (100에 가까울수록 최근 파동의 고점, 0에 가까울수록 저점)
|
||||
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준으로 전개 중
|
||||
- ATR (평균 변동폭): ${"%.0f".format(atr)}원
|
||||
- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원
|
||||
- 스토캐스틱(%K): ${"%.1f".format(stochK)}
|
||||
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준
|
||||
- 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }}
|
||||
- 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }}
|
||||
- RSI(14): ${ "%.1f".format(calculateRSI(min30)) }
|
||||
""".trimIndent()
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -194,6 +191,25 @@ object TechnicalAnalyzer {
|
||||
return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100
|
||||
}
|
||||
|
||||
fun isDailyBullish(): Boolean {
|
||||
if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리
|
||||
|
||||
val currentPrice = daily.last().stck_prpr.toDouble()
|
||||
|
||||
// 1. MA20 (한 달 생명선) 계산
|
||||
val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||
|
||||
// 2. MA5 (단기 가속도) 계산
|
||||
val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
|
||||
// 3. 방향성 (어제 MA5 vs 오늘 MA5)
|
||||
val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
val isMa5Rising = ma5 > prevMa5
|
||||
|
||||
// [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주
|
||||
return currentPrice > ma20 && isMa5Rising
|
||||
}
|
||||
|
||||
fun calculateOBV(candles: List<CandleData>): Double {
|
||||
var obv = 0.0
|
||||
for (i in 1 until candles.size) {
|
||||
@ -298,7 +314,7 @@ class ScalpingAnalyzer {
|
||||
return Triple(upper, sma, lower)
|
||||
}
|
||||
|
||||
fun analyze(candles: List<Candle>): ScalpingSignalModel {
|
||||
fun analyze(candles: List<Candle>, isDailyBullish: Boolean): ScalpingSignalModel {
|
||||
if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요")
|
||||
|
||||
val closes = candles.map { it.close }
|
||||
@ -323,16 +339,34 @@ class ScalpingAnalyzer {
|
||||
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
|
||||
} else 0.5
|
||||
|
||||
// 신호 조건
|
||||
val maBull = currentClose > sma10Now && sma10Now > sma20Now
|
||||
|
||||
|
||||
val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high }
|
||||
val isBreakout = currentClose > nearHigh
|
||||
|
||||
// [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인)
|
||||
val bodySize = abs(current.close - current.open)
|
||||
val lowerShadow = minOf(current.close, current.open) - current.low
|
||||
val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우
|
||||
|
||||
// 신호 조건 고도화
|
||||
// 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수
|
||||
|
||||
// val maBull = currentClose > sma10Now && sma10Now > sma20Now
|
||||
val rsiBull = rsiNow > RSI_THRESHOLD
|
||||
val volSurge = volRatioNow > VOL_SURGE_THRESHOLD
|
||||
val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS
|
||||
val buySignal = maBull && rsiBull && volSurge && bbGood
|
||||
val maBull = currentClose > sma10Now && sma10Now > sma20Now
|
||||
val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout
|
||||
// val buySignal = maBull && rsiBull && volSurge && bbGood
|
||||
|
||||
// 종합 스코어 (가중: MA 30%, RSI 20%, Vol 30%, BB 20%)
|
||||
val score = (if (maBull) 30 else 0) + (if (rsiBull) 20 else 0) +
|
||||
(minOf((volRatioNow - 1.0) * 30, 30.0)).toInt() + (if (bbGood) 20 else 0)
|
||||
|
||||
val score = (if (maBull) 25 else 0) +
|
||||
(if (rsiBull) 15 else 0) +
|
||||
(if (isBreakout) 20 else 0) + // 돌파 에너지 가중치
|
||||
(minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() +
|
||||
(if (bbGood) 10 else 0) +
|
||||
(if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수
|
||||
|
||||
// 위험도 (ATR proxy)
|
||||
val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 }
|
||||
@ -351,6 +385,8 @@ class ScalpingAnalyzer {
|
||||
val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100)
|
||||
val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
|
||||
|
||||
|
||||
|
||||
return ScalpingSignalModel(
|
||||
currentPrice = currentClose,
|
||||
buySignal = buySignal,
|
||||
|
||||
160
src/main/kotlin/service/DynamicNewsScraper.kt
Normal file
160
src/main/kotlin/service/DynamicNewsScraper.kt
Normal file
@ -0,0 +1,160 @@
|
||||
package service
|
||||
|
||||
import com.microsoft.playwright.Playwright
|
||||
import com.microsoft.playwright.BrowserType
|
||||
import com.microsoft.playwright.Page
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import model.NewsItem
|
||||
import network.CorpInfo
|
||||
import kotlin.random.Random
|
||||
|
||||
object DynamicNewsScraper {
|
||||
private val playwright by lazy { Playwright.create() }
|
||||
private val browser by lazy {
|
||||
playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true))
|
||||
}
|
||||
|
||||
fun extractSmartContentWithLineFilter(page: Page): String {
|
||||
val script = """
|
||||
() => {
|
||||
// 1. 선제적 노이즈 제거: 분석에 방해되는 태그들을 DOM에서 아예 삭제
|
||||
const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV'];
|
||||
document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove());
|
||||
|
||||
const MIN_LINE_LENGTH = 10;
|
||||
const MIN_TOTAL_LENGTH = 100;
|
||||
const CONSECUTIVE_THRESHOLD = 2;
|
||||
|
||||
// 2. 라인별 정제 함수 (짧은 라인 연속 시 예외 처리)
|
||||
const getRefinedText = (el) => {
|
||||
// 실제 텍스트만 추출하여 라인별로 분리
|
||||
const lines = el.innerText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
let resultLines = [];
|
||||
let tempBuffer = [];
|
||||
let consecutiveShort = 0;
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.length <= MIN_LINE_LENGTH) {
|
||||
consecutiveShort++;
|
||||
tempBuffer.push(line);
|
||||
} else {
|
||||
// 짧은 줄이 연속되지 않았을 때만 버퍼를 결과에 합침
|
||||
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
|
||||
resultLines = resultLines.concat(tempBuffer);
|
||||
}
|
||||
resultLines.push(line);
|
||||
tempBuffer = [];
|
||||
consecutiveShort = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 마지막 남은 버퍼 처리 (본문 끝에 짧은 정보가 있을 경우 대비)
|
||||
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
|
||||
resultLines = resultLines.concat(tempBuffer);
|
||||
}
|
||||
return resultLines.join('\n');
|
||||
};
|
||||
|
||||
// 3. 후보 블록 탐색 및 텍스트 밀도 기반 분석
|
||||
const candidates = Array.from(document.querySelectorAll('div, section, article, p, main, td'))
|
||||
.map(el => ({
|
||||
el: el,
|
||||
refinedText: getRefinedText(el)
|
||||
}))
|
||||
.filter(item => {
|
||||
if (item.refinedText.length < MIN_TOTAL_LENGTH) return false;
|
||||
|
||||
// 링크 밀도 체크: 기사 본문은 보통 링크보다 텍스트 비중이 훨씬 높음
|
||||
const linkLength = Array.from(item.el.querySelectorAll('a'))
|
||||
.reduce((acc, a) => acc + (a.innerText || "").length, 0);
|
||||
return (linkLength / item.refinedText.length) < 0.3;
|
||||
});
|
||||
|
||||
// 4. 가장 최적의(가장 깊은 계층의) 본문 컨테이너 선정
|
||||
const best = candidates.find(parent =>
|
||||
!candidates.some(child =>
|
||||
parent.el !== child.el &&
|
||||
parent.el.contains(child.el) &&
|
||||
child.refinedText.length > parent.refinedText.length * 0.8
|
||||
)
|
||||
);
|
||||
|
||||
return best ? best.refinedText : (candidates.sort((a,b) => b.refinedText.length - a.refinedText.length)[0]?.refinedText || "");
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
return page.evaluate(script) as String
|
||||
}
|
||||
|
||||
suspend fun fetchFullContent(url: String): String {
|
||||
val context = browser.newContext()
|
||||
val page = context.newPage()
|
||||
delay(Random.nextInt(1000).toLong())
|
||||
return try {
|
||||
// 1. 페이지 이동 및 네트워크 유휴 상태까지 대기
|
||||
blockUnnecessaryResources(page)
|
||||
page.navigate(url)
|
||||
// println(url)
|
||||
page.waitForLoadState()
|
||||
|
||||
|
||||
var finded = cleanText(extractSmartContentWithLineFilter(page))
|
||||
println("finded : $finded")
|
||||
finded
|
||||
} catch (e: Exception) {
|
||||
println("❌ [Playwright] 스크래핑 실패: ${e.message}")
|
||||
""
|
||||
} finally {
|
||||
page.close()
|
||||
context.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun blockUnnecessaryResources(page: Page) {
|
||||
// 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단
|
||||
page.route("**/*.{png,jpg,jpeg,gif,webp,svg,css,woff,woff2}") { route ->
|
||||
route.abort()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanText(text: String): String {
|
||||
return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거
|
||||
.replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
object SafeScraper {
|
||||
// 동시 실행 브라우저 탭을 5개로 제한 (M3 Pro라면 10~20개도 여유롭습니다)
|
||||
private val semaphore = Semaphore(5)
|
||||
|
||||
suspend fun scrapeParallel(corpInfo: CorpInfo,urls: List<NewsItem>) = coroutineScope {
|
||||
var query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}"
|
||||
urls.map { item ->
|
||||
async {
|
||||
if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) {
|
||||
semaphore.withPermit {
|
||||
RagService.ingestWithChunking(
|
||||
text = DynamicNewsScraper.fetchFullContent(item.originallink),
|
||||
newsLink = item.originallink,
|
||||
pubDate = item.pubDate,
|
||||
stockCode = corpInfo.stockCode,
|
||||
corpName = corpInfo.cName,
|
||||
corpCode = corpInfo.cCode,
|
||||
stcokName = corpInfo.stockName
|
||||
)
|
||||
}
|
||||
println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링")
|
||||
} else {
|
||||
println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵")
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
println("$query 관련 뉴스 ${urls.size}개 학습 완료")
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,12 @@
|
||||
package network
|
||||
package service
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object LlamaServerManager {
|
||||
@ -24,7 +27,7 @@ object LlamaServerManager {
|
||||
binPath,
|
||||
"-m", modelPath,
|
||||
"--port", port.toString(),
|
||||
"-c", if (port == 8081) "512" else "4096", // 임베딩용은 컨텍스트가 짧아도 충분합니다.
|
||||
"-c", if (port == 8081) "512" else "8192", // 임베딩용은 컨텍스트가 짧아도 충분합니다.
|
||||
"-ngl", nGpuLayers.toString(),
|
||||
"-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장
|
||||
"--embedding" // 임베딩 기능을 활성화합니다.
|
||||
@ -45,15 +48,20 @@ object LlamaServerManager {
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
// 로그 출력 (디버깅용)
|
||||
println("[Server $port] $line")
|
||||
// println("[Server $port] $line")
|
||||
if (line?.contains("server is listening") == true) {
|
||||
println("🚀 AI 서버 준비 완료 (Port: $port)")
|
||||
if (processes.size > 1) {
|
||||
println("[Cache] ${processes.size}")
|
||||
RagService.active()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ AI 서버 실행 실패 (Port: $port): ${e.message}")
|
||||
processes.remove(port)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
55
src/main/kotlin/service/UrlCacheManager.kt
Normal file
55
src/main/kotlin/service/UrlCacheManager.kt
Normal file
@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
|
||||
import dev.langchain4j.model.embedding.EmbeddingModel
|
||||
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object UrlCacheManager {
|
||||
// 1. Thread-safe한 메모리 캐시 (M3 Pro의 멀티코어 환경 대응)
|
||||
private val processedUrls = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
/**
|
||||
* Lucene 저장소에서 모든 link 메타데이터를 읽어와 캐시를 초기화합니다.
|
||||
*/
|
||||
fun initialize(embeddingStore: LuceneEmbeddingStore, embeddingModel: EmbeddingModel) {
|
||||
try {
|
||||
// 1. 더미 텍스트로 기준 벡터 생성 (또는 0으로 채워진 리스트 생성)
|
||||
// 모델에게 아무 단어나 던져서 기준이 될 벡터 하나를 임시로 만듭니다.
|
||||
val dummyEmbedding = embeddingModel.embed("initial_load").content()
|
||||
|
||||
// 2. 검색 요청에 더미 벡터 포함
|
||||
val searchRequest = EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(dummyEmbedding) // [필수] 기준 벡터 주입으로 에러 해결
|
||||
.maxResults(2000)
|
||||
.build()
|
||||
|
||||
val result = embeddingStore.search(searchRequest)
|
||||
|
||||
result.matches().forEach { match ->
|
||||
val url = match.embedded().metadata().getString("link") as? String
|
||||
if (url != null) {
|
||||
processedUrls.add(url)
|
||||
}
|
||||
}
|
||||
println("✅ [Cache] Lucene으로부터 ${processedUrls.size}개의 URL 로드 완료")
|
||||
} catch (e: Exception) {
|
||||
println("⚠️ [Cache] 초기화 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun isInitialized(): Boolean {
|
||||
return processedUrls.isNotEmpty()
|
||||
}
|
||||
/**
|
||||
* 중복 여부 확인 (O(1) 속도)
|
||||
*/
|
||||
fun isAlreadyProcessed(url: String): Boolean = processedUrls.contains(url)
|
||||
|
||||
/**
|
||||
* 캐시 업데이트
|
||||
*/
|
||||
fun addToCache(url: String) {
|
||||
processedUrls.add(url)
|
||||
}
|
||||
}
|
||||
@ -26,9 +26,10 @@ import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.KisSession
|
||||
import service.AutoTradingManager
|
||||
import service.TradingDecisionCallback
|
||||
|
||||
@Composable
|
||||
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
|
||||
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) {
|
||||
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
||||
var code by remember(stockCode) {
|
||||
aiOpinion = ""
|
||||
@ -66,18 +67,10 @@ fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, tra
|
||||
scope.launch {
|
||||
isAnalyzing = true
|
||||
try {
|
||||
AutoTradingManager.addStock(stockCode) { msg,success ->
|
||||
aiOpinion = msg
|
||||
AutoTradingManager.addStock(stockName,stockCode) { decision,success ->
|
||||
aiOpinion = decision.toString()
|
||||
isAnalyzing = !success
|
||||
}
|
||||
// 실시간 데이터 수집부터 분석까지 한 번에 실행
|
||||
// StockAnalysisManager.analyzeStockWithMultiData(
|
||||
// stockCode = stockCode,
|
||||
// stockName = stockName,
|
||||
// result = {
|
||||
// aiOpinion = it
|
||||
// }
|
||||
// )
|
||||
} catch (e: Exception) {
|
||||
aiOpinion = "분석 중 오류 발생: ${e.message}"
|
||||
println(aiOpinion)
|
||||
|
||||
@ -152,10 +152,6 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
Text(
|
||||
"매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}",
|
||||
fontSize = 11.sp, color = Color.Gray
|
||||
)
|
||||
}
|
||||
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
}
|
||||
@ -174,6 +170,10 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
"매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}",
|
||||
fontSize = 11.sp, color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import TradingDecision
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@ -16,7 +17,6 @@ import model.KisSession
|
||||
import model.StockBasicInfo
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import util.MarketUtil
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen() {
|
||||
@ -30,6 +30,8 @@ fun DashboardScreen() {
|
||||
|
||||
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
|
||||
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
|
||||
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
|
||||
|
||||
|
||||
// 중앙 관리용 상태들
|
||||
var refreshTrigger by remember { mutableStateOf(0) }
|
||||
@ -141,7 +143,8 @@ fun DashboardScreen() {
|
||||
scope.launch {
|
||||
syncAndExecute(orderNo) // 매칭 시도
|
||||
}
|
||||
}
|
||||
},
|
||||
completeTradingDecision
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@ -156,7 +159,12 @@ fun DashboardScreen() {
|
||||
stockCode = selectedStockCode,
|
||||
stockName = selectedStockName,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
trades = wsManager.tradeLogs
|
||||
trades = wsManager.tradeLogs,
|
||||
tradingDecisionCallback = { decision,bool ->
|
||||
if (bool && decision != null && KisSession.config.isSimulation) {
|
||||
completeTradingDecision = decision
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
VerticalDivider()
|
||||
@ -196,8 +204,12 @@ fun DashboardScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun VerticalDivider() {
|
||||
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.background
|
||||
import TradingDecision
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@ -21,7 +21,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import util.MarketUtil
|
||||
|
||||
/**
|
||||
@ -40,7 +39,8 @@ fun IntegratedOrderSection(
|
||||
holdingQuantity: String,
|
||||
tradeService: KisTradeService,
|
||||
onOrderSaved: (String) -> Unit,
|
||||
onOrderResult: (String, Boolean) -> Unit
|
||||
onOrderResult: (String, Boolean) -> Unit,
|
||||
completeTradingDecision: TradingDecision?
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@ -60,6 +60,7 @@ fun IntegratedOrderSection(
|
||||
}
|
||||
|
||||
|
||||
|
||||
// UI 입력 상태
|
||||
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
|
||||
var orderQty by remember(holdingQuantity) {
|
||||
@ -80,6 +81,48 @@ fun IntegratedOrderSection(
|
||||
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
|
||||
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
|
||||
|
||||
fun excuteTrade(willEnableAutoSell: Boolean,orderQty: String) {
|
||||
scope.launch {
|
||||
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
|
||||
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
|
||||
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
||||
onOrderResult("주문 성공: $realOrderNo", true)
|
||||
if (willEnableAutoSell) {
|
||||
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
||||
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
||||
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0))
|
||||
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
|
||||
|
||||
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
||||
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
|
||||
code = stockCode,
|
||||
name = stockName,
|
||||
quantity = inputQty,
|
||||
profitRate = pRate,
|
||||
stopLossRate = sRate,
|
||||
targetPrice = calculatedTarget,
|
||||
stopLossPrice = calculatedStop,
|
||||
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
|
||||
isDomestic = isDomestic
|
||||
))
|
||||
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
||||
onOrderSaved(realOrderNo)
|
||||
onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true)
|
||||
}
|
||||
}
|
||||
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(completeTradingDecision) {
|
||||
if (completeTradingDecision != null &&
|
||||
completeTradingDecision.stockCode.equals(stockCode)) {
|
||||
when (completeTradingDecision?.decision) {
|
||||
"BUY" -> if (completeTradingDecision.confidence > 70) excuteTrade(true, "1")
|
||||
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
|
||||
else -> println("[$stockCode] 관망 유지: ${completeTradingDecision?.reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
|
||||
|
||||
@ -171,36 +214,7 @@ fun IntegratedOrderSection(
|
||||
// 매수 버튼
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
|
||||
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
|
||||
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
||||
onOrderResult("주문 성공: $realOrderNo", true)
|
||||
if (willEnableAutoSell) {
|
||||
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
||||
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
||||
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0))
|
||||
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
|
||||
|
||||
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
||||
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
|
||||
code = stockCode,
|
||||
name = stockName,
|
||||
quantity = inputQty,
|
||||
profitRate = pRate,
|
||||
stopLossRate = sRate,
|
||||
targetPrice = calculatedTarget,
|
||||
stopLossPrice = calculatedStop,
|
||||
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
|
||||
isDomestic = isDomestic
|
||||
))
|
||||
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
||||
onOrderSaved(realOrderNo)
|
||||
onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true)
|
||||
}
|
||||
}
|
||||
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
|
||||
}
|
||||
excuteTrade(willEnableAutoSell,orderQty)
|
||||
},
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
|
||||
@ -224,6 +238,8 @@ fun IntegratedOrderSection(
|
||||
) { Text("매도", color = Color.White) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -2,15 +2,10 @@ package ui
|
||||
|
||||
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import TradingDecision
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
@ -18,20 +13,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import model.BalanceSummary
|
||||
import model.CandleData
|
||||
import model.RankingStock
|
||||
import model.StockHolding
|
||||
import network.DartCodeManager
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import network.NewsService
|
||||
import service.TechnicalAnalyzer
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
@ -41,11 +31,12 @@ import kotlin.collections.isNotEmpty
|
||||
fun StockDetailSection(
|
||||
stockCode: String,
|
||||
stockName: String,
|
||||
holdingQuantity : String,
|
||||
holdingQuantity: String,
|
||||
isDomestic: Boolean,
|
||||
tradeService: KisTradeService,
|
||||
wsManager: KisWebSocketManager,
|
||||
onOrderSaved: (String) -> Unit
|
||||
onOrderSaved: (String) -> Unit,
|
||||
completeTradingDecision: TradingDecision?
|
||||
) {
|
||||
|
||||
var openPrice by remember { mutableStateOf("0") }
|
||||
@ -65,11 +56,7 @@ fun StockDetailSection(
|
||||
if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0"
|
||||
}
|
||||
|
||||
fun calculateAvg(data: List<CandleData>): String {
|
||||
if (data.isEmpty()) return "0"
|
||||
val avg = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average()
|
||||
return String.format("%,d", avg.toLong())
|
||||
}
|
||||
|
||||
|
||||
// 이전 종목 코드를 기억하기 위한 상태
|
||||
var previousCode by remember { mutableStateOf("") }
|
||||
@ -88,6 +75,7 @@ fun StockDetailSection(
|
||||
wsManager.subscribeStock(stockCode)
|
||||
previousCode = stockCode
|
||||
|
||||
|
||||
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
|
||||
|
||||
coroutineScope {
|
||||
@ -101,18 +89,33 @@ fun StockDetailSection(
|
||||
.onFailure { error ->
|
||||
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
|
||||
chartData = emptyList()
|
||||
}}
|
||||
}
|
||||
}
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
|
||||
daySummary = it.takeLast(7) }
|
||||
TechnicalAnalyzer.daily = daySummary
|
||||
daySummary = it.takeLast(7)
|
||||
TechnicalAnalyzer.daily = it
|
||||
println("daySummary ${daySummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
|
||||
}
|
||||
} // 최근 7일
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) }
|
||||
TechnicalAnalyzer.weekly = weekSummary} // 최근 4주
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess {
|
||||
weekSummary = it.takeLast(4)
|
||||
TechnicalAnalyzer.weekly = it
|
||||
println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
|
||||
}
|
||||
} // 최근 4주
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
|
||||
monthSummary = it.takeLast(6) // 최근 6개월
|
||||
yearSummary = it.takeLast(36) // 최근 3년
|
||||
TechnicalAnalyzer.monthly = yearSummary
|
||||
}}
|
||||
TechnicalAnalyzer.monthly = it
|
||||
println("monthSummary ${monthSummary.size} yearSummary ${yearSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
|
||||
}
|
||||
}
|
||||
launch {
|
||||
DartCodeManager.getCorpCode(stockCode)?.let {
|
||||
it.stockName = stockName
|
||||
NewsService.fetchAndIngestNews(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
@ -230,7 +233,8 @@ fun StockDetailSection(
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
}
|
||||
},
|
||||
completeTradingDecision
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user