..
This commit is contained in:
parent
4dff629861
commit
4bf055fa68
@ -5,7 +5,9 @@ import java.time.LocalDateTime
|
|||||||
const val feesAndTaxRate = 0.33
|
const val feesAndTaxRate = 0.33
|
||||||
const val minimumNetProfit = 0.35
|
const val minimumNetProfit = 0.35
|
||||||
const val buyWeight = 2.0
|
const val buyWeight = 2.0
|
||||||
|
val MAX_BUDGET = 40000.0
|
||||||
|
val MAX_PRICE = 20000
|
||||||
|
val MIN_PRICE = 1500
|
||||||
data class AppConfig(
|
data class AppConfig(
|
||||||
// [DB 저장 데이터]
|
// [DB 저장 데이터]
|
||||||
// 실전 3종
|
// 실전 3종
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
package network
|
|
||||||
|
|
||||||
import io.ktor.client.*
|
|
||||||
import io.ktor.client.call.*
|
|
||||||
import io.ktor.client.engine.cio.*
|
|
||||||
import io.ktor.client.plugins.HttpTimeout
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
|
||||||
import io.ktor.client.request.*
|
|
||||||
import io.ktor.http.*
|
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import model.RealTimeTrade
|
|
||||||
|
|
||||||
object AiService {
|
|
||||||
private val client = HttpClient(CIO) {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
coerceInputValues = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
install(HttpTimeout) {
|
|
||||||
requestTimeoutMillis = 60_000 // 전체 요청 대기 시간을 60초로 설정
|
|
||||||
connectTimeoutMillis = 10_000 // 서버 연결 대기 시간 10초
|
|
||||||
socketTimeoutMillis = 60_000 // 데이터 수신 대기 시간 60초
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// private const val LLM_URL = "http://localhost:8080/completion"
|
|
||||||
private const val LLM_URL = "http://127.0.0.1:8080/completion"
|
|
||||||
/**
|
|
||||||
* 종목명, 현재가, 실시간 체결내역을 바탕으로 AI 분석 결과를 가져옵니다.
|
|
||||||
*/
|
|
||||||
suspend fun fetchAnalysis(
|
|
||||||
stockName: String,
|
|
||||||
currentPrice: String,
|
|
||||||
trades: List<RealTimeTrade>
|
|
||||||
): String {
|
|
||||||
// 최근 체결 내역 10개를 텍스트로 요약
|
|
||||||
val tradeSummary = trades.take(10).joinToString("\n") { trade ->
|
|
||||||
"- ${trade.time}: ${trade.price}원 (${trade.volume}주 ${if (trade.type.name == "BUY") "매수" else "매도"})"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemma에게 전달할 프롬프트 구성
|
|
||||||
val prompt = """
|
|
||||||
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
|
||||||
당신은 20년 경력의 주식 트레이더입니다. 데이터를 분석하여 짧고 단호하게 조언합니다.<|eot_id|><|start_header_id|>user<|end_header_id|>
|
|
||||||
다음 데이터를 분석하여 '수급 상황'과 '단기 전망'을 3줄 이내로 요약하세요.
|
|
||||||
|
|
||||||
[종목] $stockName ($currentPrice)
|
|
||||||
[최근 체결]
|
|
||||||
$tradeSummary
|
|
||||||
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val response = client.post(LLM_URL) {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(LlamaRequest(prompt = prompt))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status == HttpStatusCode.OK) {
|
|
||||||
val result: LlamaResponse = response.body()
|
|
||||||
result.content.trim()
|
|
||||||
} else {
|
|
||||||
"AI 서버 응답 오류: ${response.status}"
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
var msg = "분석 실패: 로컬 AI 서버(llama.cpp)가 실행 중인지 확인하세요. (${e.message})"
|
|
||||||
println(msg)
|
|
||||||
msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
suspend fun getEmbedding(text: String): List<Double>? {
|
|
||||||
return try {
|
|
||||||
val response = client.post("http://127.0.0.1:8080/embedding") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(EmbeddingRequest(content = text))
|
|
||||||
}
|
|
||||||
if (response.status == HttpStatusCode.OK) {
|
|
||||||
val res: EmbeddingResponse = response.body()
|
|
||||||
res.embedding
|
|
||||||
} else null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class EmbeddingRequest(val content: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class EmbeddingResponse(val embedding: List<Double>)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* llama.cpp 서버 요청 데이터 구조
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class LlamaRequest(
|
|
||||||
val prompt: String,
|
|
||||||
val n_predict: Int = 256, // 답변 길이를 엄격히 제한
|
|
||||||
val temperature: Double = 0.4, // M3 Pro에서 더 일관된 답변을 위해 낮춤
|
|
||||||
val stop: List<String> = listOf(
|
|
||||||
"<|eot_id|>",
|
|
||||||
"<|end_of_text|>",
|
|
||||||
"<|start_header_id|>",
|
|
||||||
"user",
|
|
||||||
"model"
|
|
||||||
) // [중요] AI가 멈춰야 할 지점들을 명확히 지정
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* llama.cpp 서버 응답 데이터 구조
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class LlamaResponse(
|
|
||||||
val content: String
|
|
||||||
)
|
|
||||||
@ -40,14 +40,14 @@ object DartCodeManager {
|
|||||||
val url = "https://opendart.fss.or.kr/api/corpCode.xml?crtfc_key=$DART_API_KEY"
|
val url = "https://opendart.fss.or.kr/api/corpCode.xml?crtfc_key=$DART_API_KEY"
|
||||||
val response: HttpResponse = client.get(url)
|
val response: HttpResponse = client.get(url)
|
||||||
val zipBytes = response.readBytes()
|
val zipBytes = response.readBytes()
|
||||||
val zipFile = File("dart_corp_codes.zip")
|
// val zipFile = File("dart_corp_codes.zip")
|
||||||
zipFile.writeBytes(zipBytes)
|
// zipFile.writeBytes(zipBytes)
|
||||||
println("💾 [디버그] 원본 ZIP 저장 완료: ${zipFile.absolutePath} (${zipBytes.size} bytes)")
|
// println("💾 [디버그] 원본 ZIP 저장 완료: ${zipFile.absolutePath} (${zipBytes.size} bytes)")
|
||||||
ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis ->
|
ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis ->
|
||||||
var entry = zis.nextEntry
|
var entry = zis.nextEntry
|
||||||
while (entry != null) {
|
while (entry != null) {
|
||||||
if (entry.name == "CORPCODE.xml") {
|
if (entry.name == "CORPCODE.xml") {
|
||||||
saveXmlDebugFile(zipBytes)
|
// saveXmlDebugFile(zipBytes)
|
||||||
parseXml(zis.readAllBytes())
|
parseXml(zis.readAllBytes())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ class KisAuthService {
|
|||||||
*/
|
*/
|
||||||
suspend fun refreshAllTokens(): Boolean = coroutineScope {
|
suspend fun refreshAllTokens(): Boolean = coroutineScope {
|
||||||
val config = KisSession.config
|
val config = KisSession.config
|
||||||
|
println("refreshAllTokens")
|
||||||
// 1. 실전 시세용 토큰 발급 (Market Token)
|
// 1. 실전 시세용 토큰 발급 (Market Token)
|
||||||
val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) }
|
val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) }
|
||||||
|
|
||||||
@ -75,6 +75,7 @@ class KisAuthService {
|
|||||||
|
|
||||||
private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result<TokenResponse> {
|
private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result<TokenResponse> {
|
||||||
return try {
|
return try {
|
||||||
|
println("fetchAccessToken")
|
||||||
val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") {
|
val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(TokenRequest("client_credentials", appKey, secretKey))
|
setBody(TokenRequest("client_credentials", appKey, secretKey))
|
||||||
@ -82,6 +83,7 @@ class KisAuthService {
|
|||||||
if (response.status == HttpStatusCode.OK) Result.success(response.body())
|
if (response.status == HttpStatusCode.OK) Result.success(response.body())
|
||||||
else Result.failure(Exception("인증 실패: ${response.status}"))
|
else Result.failure(Exception("인증 실패: ${response.status}"))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
println("fetchAccessToken ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import AutoTradeItem
|
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
@ -11,7 +10,6 @@ import io.ktor.client.plugins.logging.Logger
|
|||||||
import io.ktor.client.plugins.logging.Logging
|
import io.ktor.client.plugins.logging.Logging
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.statement.bodyAsText
|
||||||
import io.ktor.client.statement.request
|
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@ -55,11 +53,9 @@ object KisTradeService {
|
|||||||
*/
|
*/
|
||||||
suspend fun fetchIntegratedBalance(): Result<UnifiedBalance> = coroutineScope {
|
suspend fun fetchIntegratedBalance(): Result<UnifiedBalance> = coroutineScope {
|
||||||
val config = KisSession.config
|
val config = KisSession.config
|
||||||
|
|
||||||
// 국내와 해외 잔고를 비동기로 동시 호출
|
// 국내와 해외 잔고를 비동기로 동시 호출
|
||||||
val domesticJob = async { fetchDomesticRawBalance() }
|
val domesticJob = async { fetchDomesticRawBalance() }
|
||||||
val overseasJob = async { fetchOverseasRawBalance() }
|
val overseasJob = async { fetchOverseasRawBalance() }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val domRes = domesticJob.await().getOrNull()
|
val domRes = domesticJob.await().getOrNull()
|
||||||
val ovsRes = overseasJob.await().getOrNull()
|
val ovsRes = overseasJob.await().getOrNull()
|
||||||
@ -87,14 +83,15 @@ object KisTradeService {
|
|||||||
val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) +
|
val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) +
|
||||||
(ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L)
|
(ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L)
|
||||||
val depositAmt = domRes?.output2?.firstOrNull()?.dnca_tot_amt?.toLongOrNull() ?: 0L
|
val depositAmt = domRes?.output2?.firstOrNull()?.dnca_tot_amt?.toLongOrNull() ?: 0L
|
||||||
|
println("fetchIntegratedBalance O")
|
||||||
Result.success(UnifiedBalance(
|
Result.success(UnifiedBalance(
|
||||||
totalAsset = String.format("%,d", totalAmt),
|
totalAsset = String.format("%,d", totalAmt),
|
||||||
totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0",
|
totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0",
|
||||||
deposit = String.format("%,d", depositAmt),
|
deposit = String.format("%,d", depositAmt),
|
||||||
holdings = combinedHoldings
|
holdings = combinedHoldings
|
||||||
))
|
))
|
||||||
} catch (e: Exception) { Result.failure(e) }
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -505,8 +502,8 @@ object KisTradeService {
|
|||||||
// --- 내부 Raw 호출용 (통합 잔고에서 사용) ---
|
// --- 내부 Raw 호출용 (통합 잔고에서 사용) ---
|
||||||
private suspend fun fetchDomesticRawBalance(): Result<StockBalanceResponse> {
|
private suspend fun fetchDomesticRawBalance(): Result<StockBalanceResponse> {
|
||||||
val config = KisSession.config
|
val config = KisSession.config
|
||||||
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
|
val baseUrl = prodUrl
|
||||||
val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R"
|
val trId = "TTTC8434R"
|
||||||
var pureAccount = config.accountNo.replace("-", "").trim()
|
var pureAccount = config.accountNo.replace("-", "").trim()
|
||||||
if (pureAccount.length == 8) pureAccount += "01"
|
if (pureAccount.length == 8) pureAccount += "01"
|
||||||
|
|
||||||
@ -522,7 +519,7 @@ object KisTradeService {
|
|||||||
parameter("ACNT_PRDT_CD", acntPrdtCd)
|
parameter("ACNT_PRDT_CD", acntPrdtCd)
|
||||||
parameter("AFHR_FLPR_YN", "N")
|
parameter("AFHR_FLPR_YN", "N")
|
||||||
parameter("OFL_YN", "N")
|
parameter("OFL_YN", "N")
|
||||||
parameter("INQR_DVSN", "02")
|
parameter("INQR_DVSN", "0")
|
||||||
parameter("UNPR_DVSN", "01")
|
parameter("UNPR_DVSN", "01")
|
||||||
parameter("FUND_STTL_ICLD_YN", "N")
|
parameter("FUND_STTL_ICLD_YN", "N")
|
||||||
parameter("FNCG_AMT_AUTO_RDPT_YN", "N")
|
parameter("FNCG_AMT_AUTO_RDPT_YN", "N")
|
||||||
@ -530,7 +527,8 @@ object KisTradeService {
|
|||||||
parameter("CTX_AREA_FK100", "")
|
parameter("CTX_AREA_FK100", "")
|
||||||
parameter("CTX_AREA_NK100", "")
|
parameter("CTX_AREA_NK100", "")
|
||||||
}
|
}
|
||||||
Result.success(response.body())
|
val body = response.body<StockBalanceResponse>()
|
||||||
|
Result.success(body)
|
||||||
} catch (e: Exception) { Result.failure(e) }
|
} catch (e: Exception) { Result.failure(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,12 +15,14 @@ import io.ktor.client.request.parameter
|
|||||||
import io.ktor.http.ContentType.Application.Json
|
import io.ktor.http.ContentType.Application.Json
|
||||||
import io.ktor.http.Url
|
import io.ktor.http.Url
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import model.DartFinancialResponse
|
import model.DartFinancialResponse
|
||||||
import model.NaverNewsResponse
|
import model.NaverNewsResponse
|
||||||
import service.DynamicNewsScraper
|
import service.DynamicNewsScraper
|
||||||
import service.SafeScraper
|
import service.SafeScraper
|
||||||
import service.UrlCacheManager
|
import service.UrlCacheManager
|
||||||
|
import kotlin.Double
|
||||||
|
|
||||||
object NewsService {
|
object NewsService {
|
||||||
private val client = HttpClient<CIOEngineConfig>(CIO) {
|
private val client = HttpClient<CIOEngineConfig>(CIO) {
|
||||||
@ -41,25 +43,25 @@ object NewsService {
|
|||||||
"${corpInfo.stockName} 주가",
|
"${corpInfo.stockName} 주가",
|
||||||
"${corpInfo.stockName} 실적",
|
"${corpInfo.stockName} 실적",
|
||||||
"${corpInfo.stockName} 공시",
|
"${corpInfo.stockName} 공시",
|
||||||
"${corpInfo.stockName} 이벤트"
|
// "${corpInfo.stockName} 이벤트"
|
||||||
)
|
)
|
||||||
|
|
||||||
val qlistCorpTrend = listOf(
|
val qlistCorpTrend = listOf(
|
||||||
"${corpInfo.cName} 최근 동향",
|
"${corpInfo.cName} 최근 동향",
|
||||||
"${corpInfo.cName} 이슈",
|
"${corpInfo.cName} 이슈",
|
||||||
"${corpInfo.cName} 투자",
|
// "${corpInfo.cName} 투자",
|
||||||
"${corpInfo.cName} 실적"
|
// "${corpInfo.cName} 실적"
|
||||||
)
|
)
|
||||||
(qlistNews + qlistCorpTrend).forEach { query ->
|
(qlistNews + qlistCorpTrend).forEach { query ->
|
||||||
try {
|
try {
|
||||||
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
|
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
|
||||||
parameter("query", query)
|
parameter("query", query)
|
||||||
parameter("display", 5) // 최근 10개 뉴스
|
parameter("display", 4) // 최근 10개 뉴스
|
||||||
parameter("sort", "date") // 유사도 순 (또는 date 발간순)
|
parameter("sort", "date") // 유사도 순 (또는 date 발간순)
|
||||||
header("X-Naver-Client-Id", clientId)
|
header("X-Naver-Client-Id", clientId)
|
||||||
header("X-Naver-Client-Secret", clientSecret)
|
header("X-Naver-Client-Secret", clientSecret)
|
||||||
}.body()
|
}.body()
|
||||||
SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(5) )
|
SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(2) )
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("❌ 뉴스 가져오기 실패: ${e.message}")
|
println("❌ 뉴스 가져오기 실패: ${e.message}")
|
||||||
}
|
}
|
||||||
@ -89,7 +91,7 @@ object NewsService {
|
|||||||
val response = client.get(url).body<DartFinancialResponse>()
|
val response = client.get(url).body<DartFinancialResponse>()
|
||||||
val accounts = response.list ?: return "재무 데이터 없음"
|
val accounts = response.list ?: return "재무 데이터 없음"
|
||||||
var buffer : StringBuffer = StringBuffer()
|
var buffer : StringBuffer = StringBuffer()
|
||||||
buffer.append("[재무 분석 데이터]")
|
buffer.append("[재무 분석 데이터]").append("\n")
|
||||||
response.list.forEach { it
|
response.list.forEach { it
|
||||||
buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n")
|
buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n")
|
||||||
}
|
}
|
||||||
@ -101,6 +103,75 @@ object NewsService {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object FinancialMapper {
|
||||||
|
/**
|
||||||
|
* 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환
|
||||||
|
*/
|
||||||
|
fun mapRawTextToStatement(rawText: String): FinancialStatement {
|
||||||
|
if (rawText.isBlank()) {
|
||||||
|
return FinancialStatement()
|
||||||
|
}
|
||||||
|
val currentValues = extractYearlyValues(rawText, "당기")
|
||||||
|
val previousValues = extractYearlyValues(rawText, "전기")
|
||||||
|
|
||||||
|
// 1. 영업이익 증가율: (당기 - 전기) / |전기| * 100
|
||||||
|
val opCurrent = currentValues["영업이익"] ?: 0.0
|
||||||
|
val opPrevious = previousValues["영업이익"] ?: 0.0
|
||||||
|
val opGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / Math.abs(opPrevious)) * 100 else 0.0
|
||||||
|
|
||||||
|
// 2. 당기순이익 증가율
|
||||||
|
val niCurrent = currentValues["당기순이익(손실)"] ?: 0.0
|
||||||
|
val niPrevious = previousValues["당기순이익(손실)"] ?: 0.0
|
||||||
|
val niGrowth = if (niPrevious != 0.0) ((niCurrent - niPrevious) / Math.abs(niPrevious)) * 100 else 0.0
|
||||||
|
|
||||||
|
// 3. ROE: 당기순이익 / 당기 자본총계 * 100
|
||||||
|
val equityCurrent = currentValues["자본총계"] ?: 1.0
|
||||||
|
val roe = (niCurrent / equityCurrent) * 100
|
||||||
|
|
||||||
|
// 4. 부채비율: 당기 부채총계 / 당기 자본총계 * 100
|
||||||
|
val debtCurrent = currentValues["부채총계"] ?: 0.0
|
||||||
|
val debtRatio = (debtCurrent / equityCurrent) * 100
|
||||||
|
|
||||||
|
// 5. 당좌비율(유동성): 당기 유동자산 / 당기 유동부채 * 100
|
||||||
|
val currentAssets = currentValues["유동자산"] ?: 0.0
|
||||||
|
val currentLiabilities = currentValues["유동부채"] ?: 1.0
|
||||||
|
val quickRatio = (currentAssets / currentLiabilities) * 100
|
||||||
|
|
||||||
|
return FinancialStatement(
|
||||||
|
operatingProfitGrowth = opGrowth,
|
||||||
|
netIncomeGrowth = niGrowth,
|
||||||
|
roe = roe,
|
||||||
|
debtRatio = debtRatio,
|
||||||
|
quickRatio = quickRatio,
|
||||||
|
isOperatingProfitPositive = opCurrent > 0,
|
||||||
|
isNetIncomePositive = niCurrent > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractYearlyValues(text: String, type: String): Map<String, Double> {
|
||||||
|
val result = mutableMapOf<String, Double>()
|
||||||
|
// 정규식 설명: 항목명 뒤의 (당기/전기) 괄호 안의 숫자와 콤마를 찾아 숫자로 변환
|
||||||
|
val regex = Regex("""([가-힣\s()]+)\s\(?$type\)?([-0-9,.]+)""")
|
||||||
|
regex.findAll(text).forEach { match ->
|
||||||
|
val key = match.groupValues[1].trim()
|
||||||
|
val value = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
@Serializable
|
||||||
|
data class FinancialStatement(
|
||||||
|
val revenueGrowth: Double = 0.0, // 매출액 증가율
|
||||||
|
val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율
|
||||||
|
val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율
|
||||||
|
val roe: Double = 0.0, // ROE
|
||||||
|
val debtRatio: Double = 0.0, // 부채비율
|
||||||
|
val quickRatio: Double = 0.0, // 당좌비율
|
||||||
|
val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부
|
||||||
|
val isNetIncomePositive: Boolean = false
|
||||||
|
)
|
||||||
@ -14,8 +14,12 @@ import kotlinx.serialization.Serializable
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import model.CandleData
|
import model.CandleData
|
||||||
import network.DartCodeManager
|
import network.DartCodeManager
|
||||||
|
import network.FinancialMapper
|
||||||
|
import network.FinancialStatement
|
||||||
import network.NewsService
|
import network.NewsService
|
||||||
import org.apache.lucene.store.MMapDirectory
|
import org.apache.lucene.store.MMapDirectory
|
||||||
|
import service.FinancialAnalyzer
|
||||||
|
import service.InvestmentScores
|
||||||
import service.TechnicalAnalyzer
|
import service.TechnicalAnalyzer
|
||||||
import service.TradingDecisionCallback
|
import service.TradingDecisionCallback
|
||||||
import service.UrlCacheManager
|
import service.UrlCacheManager
|
||||||
@ -131,39 +135,46 @@ object RagService {
|
|||||||
// 1. 10분간의 데이터 가져오기 (API 호출)
|
// 1. 10분간의 데이터 가져오기 (API 호출)
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
||||||
var tradingDecision: TradingDecision = TradingDecision()
|
var tradingDecision: TradingDecision = TradingDecision()
|
||||||
tradingDecision.stockCode = stockCode
|
tradingDecision.stockCode = stockCode
|
||||||
var corpInfo = DartCodeManager.getCorpCode(stockCode)
|
var corpInfo = DartCodeManager.getCorpCode(stockCode)
|
||||||
corpInfo?.stockName = stockName
|
corpInfo?.stockName = stockName
|
||||||
tradingDecision.stockName = stockName
|
tradingDecision.stockName = stockName
|
||||||
tradingDecision.corpName = corpInfo?.cName ?: ""
|
tradingDecision.corpName = corpInfo?.cName ?: ""
|
||||||
corpInfo?.let {
|
|
||||||
try {
|
|
||||||
NewsService.fetchAndIngestNews(it)
|
|
||||||
} catch (e: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
|
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
|
||||||
|
|
||||||
tradingDecision.financialData = financialDataDeferred.await()
|
tradingDecision.financialData = financialDataDeferred.await()
|
||||||
result(tradingDecision, false)
|
|
||||||
|
|
||||||
tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
|
val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "")
|
||||||
result(tradingDecision, false)
|
if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
|
||||||
|
corpInfo?.let {
|
||||||
|
try {
|
||||||
|
NewsService.fetchAndIngestNews(it)
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
|
val financialScore = FinancialAnalyzer.calculateScore(financialStmt)
|
||||||
val questionEmbedding = embeddingModel.embed(question).content()
|
val scores = technicalAnalyzer.calculateScores(financialScore)
|
||||||
val searchResult = embeddingStore.search(
|
|
||||||
EmbeddingSearchRequest.builder()
|
result(tradingDecision, false)
|
||||||
.queryEmbedding(questionEmbedding)
|
|
||||||
.maxResults(3)
|
tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
|
||||||
.build()
|
result(tradingDecision, false)
|
||||||
)
|
|
||||||
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
|
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
|
||||||
result(tradingDecision, false)
|
val questionEmbedding = embeddingModel.embed(question).content()
|
||||||
result(decideTrading(stockCode, tradingDecision), true)
|
val searchResult = embeddingStore.search(
|
||||||
|
EmbeddingSearchRequest.builder()
|
||||||
|
.queryEmbedding(questionEmbedding)
|
||||||
|
.maxResults(3)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
|
||||||
|
result(tradingDecision, false)
|
||||||
|
result(decideTrading(stockCode, scores,financialStmt,tradingDecision), true)
|
||||||
|
} else {
|
||||||
|
result(tradingDecision, false)
|
||||||
|
}
|
||||||
}catch (e: Exception) {
|
}catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
@ -237,45 +248,55 @@ object RagService {
|
|||||||
|
|
||||||
suspend fun decideTrading(
|
suspend fun decideTrading(
|
||||||
stockName: String,
|
stockName: String,
|
||||||
|
scores: InvestmentScores, // 직접 계산한 점수 객체
|
||||||
|
financialStmt: FinancialStatement, // 매핑된 재무 수치 객체
|
||||||
tempDecision: TradingDecision
|
tempDecision: TradingDecision
|
||||||
): TradingDecision? {
|
): TradingDecision? {
|
||||||
val prompt = """
|
val prompt = """
|
||||||
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
||||||
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다.
|
당신은 정량적 수치와 정성적 뉴스를 통합 분석하는 'AI 수석 애널리스트'입니다.
|
||||||
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
|
시스템이 계산한 지표 점수와 실제 재무제표 요약본을 바탕으로 최종 매매 전략을 수립하십시오.
|
||||||
아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
|
|
||||||
|
|
||||||
[데이터 요약]
|
|
||||||
- 종목: $stockName
|
|
||||||
- 분석: ${tempDecision.techSummary}
|
|
||||||
- 기업/재무: ${tempDecision.financialData}
|
|
||||||
- 시장 심리: ${tempDecision.newsContext}
|
|
||||||
|
|
||||||
[스코어 산출 가이드 (0-100)]
|
[종목 정보]
|
||||||
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상.
|
- 종목명: $stockName
|
||||||
2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상.
|
|
||||||
3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상.
|
[1. 시스템 산출 스코어 (0-100)]
|
||||||
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
|
- 초단기(Scalping): ${scores.ultraShort}
|
||||||
|
- 단기(Daily): ${scores.shortTerm}
|
||||||
|
- 중기(Weekly): ${scores.midTerm}
|
||||||
|
- 장기(Monthly): ${scores.longTerm}
|
||||||
|
|
||||||
|
[2. 핵심 재무제표 요약]
|
||||||
|
- 영업이익: ${if(financialStmt.isOperatingProfitPositive) "흑자" else "적자"} (성장률: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%)
|
||||||
|
- 당기순이익: ${if(financialStmt.isNetIncomePositive) "흑자" else "적자"} (성장률: ${"%.2f".format(financialStmt.netIncomeGrowth)}%)
|
||||||
|
- 수익성(ROE): ${"%.2f".format(financialStmt.roe)}%
|
||||||
|
- 안정성(부채비율): ${"%.2f".format(financialStmt.debtRatio)}%
|
||||||
|
- 유동성(당좌비율): ${"%.2f".format(financialStmt.quickRatio)}%
|
||||||
|
|
||||||
|
[3. 시장 심리 및 뉴스 컨텍스트]
|
||||||
|
${tempDecision.newsContext}
|
||||||
|
|
||||||
|
[분석 지침]
|
||||||
|
1. **재무-뉴스 정합성**: 재무제표상 영업이익이 적자임에도 뉴스가 장기적 장밋빛 전망만 내놓는다면 '신중(HOLD)' 의견을 제시하십시오.
|
||||||
|
2. **기술-심리 동기화**: 초단기 점수가 높고 뉴스에서 수급 급증 키워드가 포착되면 'BUY' 신뢰도를 높이십시오.
|
||||||
|
3. **종합 결정**: 모든 수치와 컨텍스트를 고려하여 최종 Decision을 내리고, 그 근거를 핵심만 기술하십시오.
|
||||||
|
|
||||||
|
[응답 지침]
|
||||||
|
- JSON 데이터만 출력하십시오. 설명이나 서론은 생략합니다.
|
||||||
|
- 반드시 아래 형식을 엄격히 준수하십시오.
|
||||||
|
|
||||||
[응답 지침 - 엄격 준수]
|
|
||||||
1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오.
|
|
||||||
2. 오직 JSON 데이터만 출력하십시오.
|
|
||||||
3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다.
|
|
||||||
4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다.
|
|
||||||
[응답 형식]
|
|
||||||
반드시 아래 JSON 형식으로만 답변하십시오:
|
|
||||||
{
|
{
|
||||||
"ultraShortScore": (숫자),
|
"ultraShortScore": ${scores.ultraShort},
|
||||||
"shortTermScore": (숫자),
|
"shortTermScore": ${scores.shortTerm},
|
||||||
"midTermScore": (숫자),
|
"midTermScore": ${scores.midTerm},
|
||||||
"longTermScore": (숫자),
|
"longTermScore": ${scores.longTerm},
|
||||||
"decision": "BUY" | "SELL" | "HOLD",
|
"decision": "BUY" | "SELL" | "HOLD",
|
||||||
"reason": "결정적 근거 한 줄",
|
"reason": "재무 수치와 뉴스 심리를 대조한 최종 결론 한 줄",
|
||||||
"confidence": 0~100
|
"confidence": 0~100
|
||||||
}
|
}
|
||||||
<|eot_id|>
|
<|eot_id|>
|
||||||
<|start_header_id|>user<|end_header_id|>
|
<|start_header_id|>user<|end_header_id|>
|
||||||
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
|
상기 데이터를 통합 분석하여 최종 리포트를 생성하십시오.
|
||||||
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
|||||||
@ -14,9 +14,13 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import model.CandleData
|
import model.CandleData
|
||||||
|
import model.MAX_PRICE
|
||||||
|
import model.MIN_PRICE
|
||||||
import model.RankingStock
|
import model.RankingStock
|
||||||
import model.RankingType
|
import model.RankingType
|
||||||
import network.DartCodeManager
|
import network.DartCodeManager
|
||||||
|
import network.FinancialMapper
|
||||||
|
import network.FinancialStatement
|
||||||
import network.KisTradeService
|
import network.KisTradeService
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@ -39,11 +43,13 @@ object AutoTradingManager {
|
|||||||
// 설정 상수
|
// 설정 상수
|
||||||
private const val MIN_RISE_RATE = 0.1
|
private const val MIN_RISE_RATE = 0.1
|
||||||
private const val MAX_RISE_RATE = 15.0
|
private const val MAX_RISE_RATE = 15.0
|
||||||
private const val CYCLE_TIMEOUT = 10 * 60 * 1000L // 한 사이클 최대 10분
|
private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분
|
||||||
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
|
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
|
||||||
private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
|
private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
|
||||||
|
|
||||||
fun isRunning(): Boolean = discoveryJob?.isActive == true
|
fun isRunning(): Boolean = discoveryJob?.isActive == true
|
||||||
|
private var remainingCandidates = mutableListOf<RankingStock>()
|
||||||
|
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자동 발굴 루프 시작 및 Watchdog 실행
|
* 자동 발굴 루프 시작 및 Watchdog 실행
|
||||||
@ -82,36 +88,47 @@ object AutoTradingManager {
|
|||||||
// [프로세스 1] 장 마감 및 잔고 체크
|
// [프로세스 1] 장 마감 및 잔고 체크
|
||||||
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
|
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
|
||||||
//&& now.isBefore(LocalTime.of(15, 30))
|
//&& now.isBefore(LocalTime.of(15, 30))
|
||||||
if (now.isAfter(LocalTime.of(15, 30)) ) {
|
// if (now.isAfter(LocalTime.of(15, 30)) ) {
|
||||||
executeClosingLiquidation(tradeService)
|
// executeClosingLiquidation(tradeService)
|
||||||
return@withTimeout
|
// return@withTimeout
|
||||||
}
|
// }
|
||||||
|
|
||||||
val balance = tradeService.fetchIntegratedBalance().getOrNull()
|
val balance = tradeService.fetchIntegratedBalance().getOrNull()
|
||||||
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
|
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
|
||||||
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
|
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
|
||||||
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
|
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
|
||||||
// [프로세스 2] 후보군 수집
|
// [프로세스 2] 후보군 수집
|
||||||
val candidates = fetchCandidates(tradeService).apply {
|
if (remainingCandidates.isEmpty()) {
|
||||||
println("후보군 총 개수 : $size")
|
val candidates = fetchCandidates(tradeService).apply {
|
||||||
|
println("후보군 총 개수 : $size")
|
||||||
|
}
|
||||||
|
.filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE }
|
||||||
|
.filter { it.code !in myHoldings && it.code !in pendingStocks }
|
||||||
|
.distinctBy { it.code }
|
||||||
|
.sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) }
|
||||||
|
.apply {
|
||||||
|
println("후보군 조건 충족 총 개수 : $size")
|
||||||
|
}
|
||||||
|
remainingCandidates.addAll(candidates)
|
||||||
|
} else {
|
||||||
|
println("미확인 데이터 ${remainingCandidates.size}")
|
||||||
}
|
}
|
||||||
.filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE }
|
|
||||||
.filter { it.code !in myHoldings && it.code !in pendingStocks }
|
|
||||||
.distinctBy { it.code }
|
|
||||||
.apply {
|
|
||||||
println("후보군 조건 충족 총 개수 : $size")
|
|
||||||
}
|
|
||||||
|
|
||||||
// [프로세스 3] 종목별 순회 분석
|
// [프로세스 3] 종목별 순회 분석
|
||||||
candidates.forEach { stock ->
|
val iterator = remainingCandidates.iterator()
|
||||||
try {
|
while (iterator.hasNext()) {
|
||||||
lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고
|
val stock = iterator.next()
|
||||||
processSingleStock(stock, myCash, tradeService, callback)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
|
|
||||||
}finally {
|
try {
|
||||||
delay(300)
|
processSingleStock(stock, myCash, tradeService, callback)
|
||||||
|
// 성공적으로 처리(또는 분석 완료) 후 리스트에서 제거
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}")
|
||||||
|
// 오류 시 리스트에 남겨둘지, 제거할지 결정
|
||||||
|
// (심각한 에러면 remove하고 다음 루프에서 다시 받는게 안전)
|
||||||
|
} finally {
|
||||||
|
iterator.remove()
|
||||||
}
|
}
|
||||||
|
delay(300)
|
||||||
}
|
}
|
||||||
|
|
||||||
println("⏱️ [Cycle End] ${LocalTime.now()}")
|
println("⏱️ [Cycle End] ${LocalTime.now()}")
|
||||||
@ -142,7 +159,7 @@ object AutoTradingManager {
|
|||||||
val today = dailyData.lastOrNull() ?: return@withTimeout
|
val today = dailyData.lastOrNull() ?: return@withTimeout
|
||||||
val currentPrice = today.stck_prpr.toDouble()
|
val currentPrice = today.stck_prpr.toDouble()
|
||||||
|
|
||||||
if (currentPrice > myCash || currentPrice > 15000 || currentPrice < 900) return@withTimeout
|
if (currentPrice > myCash || currentPrice > MAX_PRICE || currentPrice < MIN_PRICE) return@withTimeout
|
||||||
|
|
||||||
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
|
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
|
||||||
callback(TradingDecision().apply {
|
callback(TradingDecision().apply {
|
||||||
@ -163,6 +180,7 @@ object AutoTradingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
RagService.processStock(analyzer, stock.name, stock.code) { decision, isSuccess ->
|
RagService.processStock(analyzer, stock.name, stock.code) { decision, isSuccess ->
|
||||||
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
|
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
|
||||||
}
|
}
|
||||||
@ -186,7 +204,7 @@ object AutoTradingManager {
|
|||||||
// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) },
|
// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) },
|
||||||
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
|
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
|
||||||
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
|
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
|
||||||
async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) },
|
// async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) },
|
||||||
async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) },
|
async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) },
|
||||||
async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) },
|
async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) },
|
||||||
async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) },
|
async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) },
|
||||||
@ -252,12 +270,89 @@ object AutoTradingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object FinancialAnalyzer {
|
||||||
|
|
||||||
|
fun isSafetyBeltMet(fs: FinancialStatement): Boolean {
|
||||||
|
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
|
||||||
|
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
|
||||||
|
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
|
||||||
|
|
||||||
|
return isDebtSafe && isLiquiditySafe && isNotDeficit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [매수 고려] 우량 기업 요건 확인
|
||||||
|
* 모든 조건 충족 시 적극적인 분석(AI/차트) 단계로 진입합니다.
|
||||||
|
*/
|
||||||
|
fun isBuyConsiderationMet(fs: FinancialStatement): Boolean {
|
||||||
|
val highProfitability = fs.roe >= 10.0 // ROE 10% 이상
|
||||||
|
val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
|
||||||
|
val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
|
||||||
|
val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
|
||||||
|
val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자
|
||||||
|
|
||||||
|
return highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 종합 상태 반환 (UI 또는 로그용)
|
||||||
|
*/
|
||||||
|
fun getInvestmentStatus(fs: FinancialStatement): String {
|
||||||
|
return when {
|
||||||
|
isBuyConsiderationMet(fs) -> "🚀 [매수 검토 권장] 재무 건전성 및 성장성 우수"
|
||||||
|
isSafetyBeltMet(fs) -> "⚖️ [관망/보류] 생존 요건은 충족하나 성장성 부족"
|
||||||
|
else -> "🚨 [위험/제외] 재무 안정성 미달 또는 적자 기업"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateScore(fs: FinancialStatement): Int {
|
||||||
|
var score = 50.0 // 기본 점수
|
||||||
|
|
||||||
|
// 성장성 (영업이익 증가율)
|
||||||
|
score += when {
|
||||||
|
fs.operatingProfitGrowth > 20 -> 20
|
||||||
|
fs.operatingProfitGrowth > 0 -> 10
|
||||||
|
else -> -10 // 역성장 시 감점
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수익성 (ROE)
|
||||||
|
score += when {
|
||||||
|
fs.roe > 15 -> 15
|
||||||
|
fs.roe > 5 -> 5
|
||||||
|
fs.roe < 0 -> -15 // 적자 시 큰 감점
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 안정성 (부채비율)
|
||||||
|
score += when {
|
||||||
|
fs.debtRatio < 100 -> 15
|
||||||
|
fs.debtRatio < 200 -> 5
|
||||||
|
else -> -10
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유동성 (당좌비율)
|
||||||
|
if (fs.quickRatio < 100) score -= 10 // 단기 채무 지급 능력 부족 시 감점
|
||||||
|
|
||||||
|
return score.coerceIn(0.0, 100.0).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class InvestmentScores(
|
data class InvestmentScores(
|
||||||
val ultraShort: Int, // 초단기 (분봉/에너지)
|
val ultraShort: Int, // 초단기 (분봉/에너지)
|
||||||
val shortTerm: Int, // 단기 (일봉/뉴스)
|
val shortTerm: Int, // 단기 (일봉/뉴스)
|
||||||
val midTerm: Int, // 중기 (주봉/재무)
|
val midTerm: Int, // 중기 (주봉/재무)
|
||||||
val longTerm: Int // 장기 (월봉/펀더멘털)
|
val longTerm: Int // 장기 (월봉/펀더멘털)
|
||||||
)
|
) {
|
||||||
|
override fun toString(): String {
|
||||||
|
return """
|
||||||
|
ultraShort $ultraShort
|
||||||
|
shortTerm $shortTerm
|
||||||
|
midTerm $midTerm
|
||||||
|
longTerm $longTerm
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
class TechnicalAnalyzer {
|
class TechnicalAnalyzer {
|
||||||
var monthly: List<CandleData> = emptyList()
|
var monthly: List<CandleData> = emptyList()
|
||||||
var weekly: List<CandleData> = emptyList()
|
var weekly: List<CandleData> = emptyList()
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
//package service
|
|
||||||
//
|
|
||||||
//import kotlinx.coroutines.async
|
|
||||||
//import kotlinx.coroutines.coroutineScope
|
|
||||||
//import model.CandleData
|
|
||||||
//import model.RealTimeTrade
|
|
||||||
//import network.NewsService
|
|
||||||
//
|
|
||||||
//object StockAnalysisManager {
|
|
||||||
// var days : List<CandleData> = emptyList()
|
|
||||||
// var weeks : List<CandleData> = emptyList()
|
|
||||||
// var monthly : List<CandleData> = emptyList()
|
|
||||||
// var mins : List<CandleData> = emptyList()
|
|
||||||
//
|
|
||||||
// suspend fun analyzeStockWithMultiData(stockCode : String, stockName: String, result : (String)-> Unit) {
|
|
||||||
// coroutineScope {
|
|
||||||
// println("🔍 [1/3] '${stockName}' 실시간 뉴스 수집 및 학습 시작...")
|
|
||||||
//
|
|
||||||
// val corpInfoDeferred = async { NewsService.fetchCorpInfo(stockCode) }
|
|
||||||
// val financialDataDeferred = async { NewsService.fetchFinancialGrowth(stockCode) }
|
|
||||||
//
|
|
||||||
// val corpInfo = corpInfoDeferred.await()
|
|
||||||
// val financialData = financialDataDeferred.await()
|
|
||||||
//
|
|
||||||
// NewsService.fetchAndIngestNews("$stockName 주가 전망")
|
|
||||||
//
|
|
||||||
// println("🧠 [2/3] 관련 컨텍스트 추출 중...")
|
|
||||||
//
|
|
||||||
// // 2. 방금 저장된 뉴스를 포함하여 DB에서 관련성 높은 정보 추출
|
|
||||||
// val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘."
|
|
||||||
// val context = RagService.askWithContext(question,corpInfo,financialData,days,weeks,monthly)
|
|
||||||
//
|
|
||||||
// println("🤖 [3/3] AI 분석 생성 중 (Chat 서버 8080)...")
|
|
||||||
//
|
|
||||||
// // 3. 최종 분석 결과 반환
|
|
||||||
// result.invoke(context)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
//package ui
|
|
||||||
//
|
|
||||||
//import AutoTradeItem
|
|
||||||
//import androidx.compose.foundation.background
|
|
||||||
//import androidx.compose.foundation.clickable
|
|
||||||
//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
|
|
||||||
//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.delay
|
|
||||||
//import kotlinx.coroutines.launch
|
|
||||||
//import model.AppConfig
|
|
||||||
//import model.BalanceSummary
|
|
||||||
//import model.CandleData
|
|
||||||
//import model.RankingStock
|
|
||||||
//import model.StockHolding
|
|
||||||
//import network.KisTradeService
|
|
||||||
//import network.KisWebSocketManager
|
|
||||||
//import kotlin.collections.isNotEmpty
|
|
||||||
//
|
|
||||||
//
|
|
||||||
//@Composable
|
|
||||||
//fun AutoTradeSettingCard(
|
|
||||||
// stockCode: String,
|
|
||||||
// stockName: String, // 종목명 추가
|
|
||||||
// currentPrice: String,
|
|
||||||
// isDomestic: Boolean = true
|
|
||||||
//) {
|
|
||||||
// var profitRate by remember { mutableStateOf("5.0") }
|
|
||||||
// var stopLossRate by remember { mutableStateOf("-3.0") }
|
|
||||||
//
|
|
||||||
// // DB에서 현재 감시 중인지 확인
|
|
||||||
// var isEnabled by remember(stockCode) {
|
|
||||||
// mutableStateOf(DatabaseFactory.findConfigByCode(stockCode) != null)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Card(
|
|
||||||
// elevation = 4.dp,
|
|
||||||
// shape = RoundedCornerShape(8.dp),
|
|
||||||
// backgroundColor = Color(0xFFF8F9FA)
|
|
||||||
// ) {
|
|
||||||
// Column(modifier = Modifier.padding(12.dp)) {
|
|
||||||
// Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2)
|
|
||||||
// Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
//
|
|
||||||
// Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
// OutlinedTextField(
|
|
||||||
// value = profitRate,
|
|
||||||
// onValueChange = { profitRate = it },
|
|
||||||
// label = { Text("익절 %") },
|
|
||||||
// modifier = Modifier.weight(1f).padding(end = 4.dp),
|
|
||||||
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
|
|
||||||
// )
|
|
||||||
// OutlinedTextField(
|
|
||||||
// value = stopLossRate,
|
|
||||||
// onValueChange = { stopLossRate = it },
|
|
||||||
// label = { Text("손절 %") },
|
|
||||||
// modifier = Modifier.weight(1f),
|
|
||||||
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
//
|
|
||||||
// Button(
|
|
||||||
// onClick = {
|
|
||||||
// if (!isEnabled) {
|
|
||||||
// // 자동 매매 시작: DB 저장
|
|
||||||
// val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
|
|
||||||
// val target = curPriceNum * (1 + profitRate.toDouble() / 100.0)
|
|
||||||
// val stopLoss = curPriceNum * (1 + stopLossRate.toDouble() / 100.0)
|
|
||||||
//
|
|
||||||
// DatabaseFactory.saveAutoTrade(
|
|
||||||
// AutoTradeItem(
|
|
||||||
// code = stockCode,
|
|
||||||
// name = stockName,
|
|
||||||
// targetPrice = target,
|
|
||||||
// stopLossPrice = stopLoss,
|
|
||||||
// status = "MONITORING",
|
|
||||||
// isDomestic = isDomestic
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// // [중요] 웹소켓 실시간 감시 등록 로직이 이곳에 호출되어야 함
|
|
||||||
// // KisWebSocketManager.subscribe(stockCode)
|
|
||||||
// isEnabled = true
|
|
||||||
// } else {
|
|
||||||
// // 자동 매매 중단: DB 삭제
|
|
||||||
// DatabaseFactory.deleteAutoTrade(stockCode)
|
|
||||||
// // KisWebSocketManager.unsubscribe(stockCode)
|
|
||||||
// isEnabled = false
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// modifier = Modifier.fillMaxWidth(),
|
|
||||||
// colors = ButtonDefaults.buttonColors(
|
|
||||||
// backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
|
||||||
// )
|
|
||||||
// ) {
|
|
||||||
// Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@ -20,6 +20,7 @@ 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
|
||||||
|
import model.MAX_BUDGET
|
||||||
import model.buyWeight
|
import model.buyWeight
|
||||||
import model.feesAndTaxRate
|
import model.feesAndTaxRate
|
||||||
import model.minimumNetProfit
|
import model.minimumNetProfit
|
||||||
@ -31,42 +32,48 @@ enum class InvestmentGrade(
|
|||||||
val description: String,
|
val description: String,
|
||||||
val shortWeight: Double = 0.0,
|
val shortWeight: Double = 0.0,
|
||||||
val midWeight: Double = 0.0,
|
val midWeight: Double = 0.0,
|
||||||
val longWeight: Double = 0.0
|
val longWeight: Double = 0.0,
|
||||||
|
val profitGuide: Double = 0.0,
|
||||||
) {
|
) {
|
||||||
LEVEL_5_STRONG_RECOMMEND(
|
LEVEL_5_STRONG_RECOMMEND(
|
||||||
displayName = "최상급 추천",
|
displayName = "최상급 추천",
|
||||||
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
|
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
|
||||||
shortWeight = 1.0,
|
shortWeight = 1.0,
|
||||||
midWeight = 1.0,
|
midWeight = 1.0,
|
||||||
longWeight = 1.0
|
longWeight = 1.0,
|
||||||
|
profitGuide = 1.8,
|
||||||
),
|
),
|
||||||
LEVEL_4_BALANCED_RECOMMEND(
|
LEVEL_4_BALANCED_RECOMMEND(
|
||||||
displayName = "균형 추천",
|
displayName = "균형 추천",
|
||||||
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
|
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
|
||||||
shortWeight = 0.8,
|
shortWeight = 0.8,
|
||||||
midWeight = 1.0,
|
midWeight = 1.0,
|
||||||
longWeight = 1.0
|
longWeight = 1.0,
|
||||||
|
profitGuide = 1.4,
|
||||||
),
|
),
|
||||||
LEVEL_3_CAUTIOUS_RECOMMEND(
|
LEVEL_3_CAUTIOUS_RECOMMEND(
|
||||||
displayName = "보수적 추천",
|
displayName = "보수적 추천",
|
||||||
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
|
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
|
||||||
shortWeight = 0.6,
|
shortWeight = 0.6,
|
||||||
midWeight = 1.0,
|
midWeight = 1.0,
|
||||||
longWeight = 1.0
|
longWeight = 1.0,
|
||||||
|
profitGuide = 1.0,
|
||||||
),
|
),
|
||||||
LEVEL_2_HIGH_RISK(
|
LEVEL_2_HIGH_RISK(
|
||||||
displayName = "고위험 추천",
|
displayName = "고위험 추천",
|
||||||
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
|
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
|
||||||
shortWeight = 1.0,
|
shortWeight = 1.0,
|
||||||
midWeight = 0.4,
|
midWeight = 0.4,
|
||||||
longWeight = 0.4
|
longWeight = 0.4,
|
||||||
|
profitGuide = 0.8,
|
||||||
),
|
),
|
||||||
LEVEL_1_SPECULATIVE(
|
LEVEL_1_SPECULATIVE(
|
||||||
displayName = "순수 공격적 선택",
|
displayName = "순수 공격적 선택",
|
||||||
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
|
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
|
||||||
shortWeight = 1.0,
|
shortWeight = 1.0,
|
||||||
midWeight = 0.2,
|
midWeight = 0.2,
|
||||||
longWeight = 0.2
|
longWeight = 0.2,
|
||||||
|
profitGuide = 0.6,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,18 +275,13 @@ fun IntegratedOrderSection(
|
|||||||
totalScore : ${totalScore}
|
totalScore : ${totalScore}
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
|
if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
|
||||||
|
var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
|
||||||
// 4. 점수에 따른 가변 마진 적용
|
// 4. 점수에 따른 가변 마진 적용
|
||||||
// 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용
|
// 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용
|
||||||
val finalMargin = if (totalScore >= HIGH_QUALITY_SCORE) {
|
val finalMargin = minimumNetProfit * investmentGrade.profitGuide
|
||||||
println("💎 [우량주 포착] 토탈 스코어($totalScore)가 매우 높아 목표 마진을 3.0%로 상향합니다.")
|
|
||||||
minimumNetProfit * 1.5
|
|
||||||
} else {
|
|
||||||
minimumNetProfit
|
|
||||||
}
|
|
||||||
|
|
||||||
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
|
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
|
||||||
val MAX_BUDGET = 35000.0
|
|
||||||
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
|
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
|
||||||
val calculatedQty = if (basePrice > 0) {
|
val calculatedQty = if (basePrice > 0) {
|
||||||
(MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)
|
(MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)
|
||||||
@ -291,7 +293,7 @@ fun IntegratedOrderSection(
|
|||||||
willEnableAutoSell = true,
|
willEnableAutoSell = true,
|
||||||
orderQty = calculatedQty.toString(),
|
orderQty = calculatedQty.toString(),
|
||||||
profitRate1 = finalMargin,
|
profitRate1 = finalMargin,
|
||||||
investmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence),
|
investmentGrade = investmentGrade,
|
||||||
)
|
)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
<configuration>
|
|
||||||
<logger name="Exposed" level="OFF" />
|
|
||||||
|
|
||||||
<root level="INFO">
|
|
||||||
<appender-ref ref="STDOUT" />
|
|
||||||
</root>
|
|
||||||
</configuration>
|
|
||||||
Loading…
x
Reference in New Issue
Block a user