..
This commit is contained in:
parent
5587635624
commit
ac50737ea8
@ -4,6 +4,7 @@ import io.ktor.client.*
|
|||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.File
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
import javax.xml.parsers.DocumentBuilderFactory
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
|
|
||||||
@ -11,6 +12,17 @@ object DartCodeManager {
|
|||||||
private val corpCodeMap = mutableMapOf<String, String>()
|
private val corpCodeMap = mutableMapOf<String, String>()
|
||||||
private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력
|
private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력
|
||||||
|
|
||||||
|
private fun saveXmlDebugFile(xmlBytes: ByteArray) {
|
||||||
|
try {
|
||||||
|
val debugFile = java.io.File("debug_CORPCODE.xml")
|
||||||
|
debugFile.writeBytes(xmlBytes)
|
||||||
|
println("💾 [디버그] XML 파일 저장 완료: ${debugFile.absolutePath}")
|
||||||
|
// M3 Pro 환경에서는 파일 쓰기가 거의 즉시 완료됩니다.
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("⚠️ [디버그] 파일 저장 실패: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앱 실행 시 호출하여 매핑 테이블 업데이트
|
* 앱 실행 시 호출하여 매핑 테이블 업데이트
|
||||||
*/
|
*/
|
||||||
@ -21,11 +33,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")
|
||||||
|
zipFile.writeBytes(zipBytes)
|
||||||
|
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)
|
||||||
parseXml(zis.readAllBytes())
|
parseXml(zis.readAllBytes())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -48,7 +63,7 @@ object DartCodeManager {
|
|||||||
val element = nodeList.item(i) as org.w3c.dom.Element
|
val element = nodeList.item(i) as org.w3c.dom.Element
|
||||||
val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: ""
|
val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: ""
|
||||||
val corpCode = element.getElementsByTagName("corp_code").item(0)?.textContent ?: ""
|
val corpCode = element.getElementsByTagName("corp_code").item(0)?.textContent ?: ""
|
||||||
|
println("stockCode: $stockCode , corpCode: $corpCode")
|
||||||
// 종목코드(stock_code)가 있는 상장사만 매핑에 추가
|
// 종목코드(stock_code)가 있는 상장사만 매핑에 추가
|
||||||
if (stockCode.isNotEmpty()) {
|
if (stockCode.isNotEmpty()) {
|
||||||
corpCodeMap[stockCode] = corpCode
|
corpCodeMap[stockCode] = corpCode
|
||||||
@ -60,6 +75,18 @@ object DartCodeManager {
|
|||||||
* 6자리 종목코드로 8자리 법인코드 반환
|
* 6자리 종목코드로 8자리 법인코드 반환
|
||||||
*/
|
*/
|
||||||
fun getCorpCode(stockCode: String): String? {
|
fun getCorpCode(stockCode: String): String? {
|
||||||
return corpCodeMap[stockCode]
|
// 1. 직접 매칭 시도
|
||||||
|
corpCodeMap[stockCode]?.let { return it }
|
||||||
|
|
||||||
|
// 2. 우선주 규칙 적용 (마지막 자리가 5, 7, 9인 경우 0으로 변경)
|
||||||
|
if (stockCode.length == 6 && stockCode.last() in listOf('5', '7', '9')) {
|
||||||
|
val commonStockCode = stockCode.substring(0, 5) + "0"
|
||||||
|
corpCodeMap[commonStockCode]?.let {
|
||||||
|
println("ℹ️ [DART] 우선주($stockCode)를 보통주($commonStockCode) 코드로 매핑 성공")
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -452,7 +452,7 @@ object KisTradeService {
|
|||||||
else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice"
|
else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice"
|
||||||
val now = LocalTime.now().minusMinutes(30)
|
val now = LocalTime.now().minusMinutes(30)
|
||||||
val searchTime = if (now.isAfter(LocalTime.of(15, 30))) {
|
val searchTime = if (now.isAfter(LocalTime.of(15, 30))) {
|
||||||
"153000"
|
"150000"
|
||||||
} else {
|
} else {
|
||||||
now.format(DateTimeFormatter.ofPattern("HHmmss"))
|
now.format(DateTimeFormatter.ofPattern("HHmmss"))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,8 @@ object RagService {
|
|||||||
// 1. 10분간의 데이터 가져오기 (API 호출)
|
// 1. 10분간의 데이터 가져오기 (API 호출)
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
var tradingDecision : TradingDecision = TradingDecision()
|
var tradingDecision : TradingDecision = TradingDecision()
|
||||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)) }
|
val corpCode = DartCodeManager.getCorpCode(stockCode)
|
||||||
|
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpCode) }
|
||||||
|
|
||||||
tradingDecision.financialData = financialDataDeferred.await()
|
tradingDecision.financialData = financialDataDeferred.await()
|
||||||
result(tradingDecision.toString(),false)
|
result(tradingDecision.toString(),false)
|
||||||
@ -148,19 +149,38 @@ object RagService {
|
|||||||
financialData: String
|
financialData: String
|
||||||
): TradingDecision? {
|
): TradingDecision? {
|
||||||
val prompt = """
|
val prompt = """
|
||||||
당신은 단기 데이트레이딩 전문가입니다. 아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
|
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
||||||
|
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 단기 데이트레이딩 전문가이자 전문 애널리스트입니다.
|
||||||
|
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
|
||||||
|
아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
|
||||||
|
|
||||||
[종목]: $stockName
|
[데이터 요약]
|
||||||
|
- 종목: $stockName
|
||||||
$techSummary
|
$techSummary
|
||||||
[관련 뉴스]: $newsContext
|
- 기업/재무: $financialData
|
||||||
[재무 기초]: $financialData
|
- 시장 심리: $newsContext
|
||||||
|
|
||||||
반드시 아래 JSON 형식으로만 답변하세요:
|
[스코어 산출 가이드 (0-100)]
|
||||||
|
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상.
|
||||||
|
2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상.
|
||||||
|
3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상.
|
||||||
|
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
|
||||||
|
|
||||||
|
[응답 형식]
|
||||||
|
반드시 아래 JSON 형식으로만 답변하십시오:
|
||||||
{
|
{
|
||||||
|
"ultraShortScore": (숫자),
|
||||||
|
"shortTermScore": (숫자),
|
||||||
|
"midTermScore": (숫자),
|
||||||
|
"longTermScore": (숫자),
|
||||||
"decision": "BUY" | "SELL" | "HOLD",
|
"decision": "BUY" | "SELL" | "HOLD",
|
||||||
"reason": "결정적 근거 한 줄",
|
"reason": "결정적 근거 한 줄",
|
||||||
"confidence": 0~100
|
"confidence": 0~100
|
||||||
}
|
}
|
||||||
|
<|eot_id|>
|
||||||
|
<|start_header_id|>user<|end_header_id|>
|
||||||
|
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
|
||||||
|
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val response = chatModel.chat(UserMessage.from(prompt))
|
val response = chatModel.chat(UserMessage.from(prompt))
|
||||||
@ -183,6 +203,10 @@ object RagService {
|
|||||||
}
|
}
|
||||||
@Serializable
|
@Serializable
|
||||||
class TradingDecision {
|
class TradingDecision {
|
||||||
|
val ultraShortScore: Int = 0 // 초단기 (분봉/에너지)
|
||||||
|
val shortTermScore: Int = 0 // 단기 (일봉/뉴스)
|
||||||
|
val midTermScore: Int = 0 // 중기 (주봉/재무)
|
||||||
|
val longTermScore: Int = 0
|
||||||
var decision: String? = null
|
var decision: String? = null
|
||||||
var reason: String? = null
|
var reason: String? = null
|
||||||
var confidence: Int = 0
|
var confidence: Int = 0
|
||||||
@ -191,6 +215,10 @@ class TradingDecision {
|
|||||||
var financialData : String? = null
|
var financialData : String? = null
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return """
|
return """
|
||||||
|
ultraShortScore :$ultraShortScore
|
||||||
|
shortTermScore :$shortTermScore
|
||||||
|
midTermScore :$midTermScore
|
||||||
|
longTermScore :$longTermScore
|
||||||
decision: $decision
|
decision: $decision
|
||||||
reason: $reason
|
reason: $reason
|
||||||
confidence: $confidence
|
confidence: $confidence
|
||||||
|
|||||||
@ -10,9 +10,13 @@ import kotlinx.coroutines.launch
|
|||||||
import model.CandleData
|
import model.CandleData
|
||||||
import network.KisTradeService
|
import network.KisTradeService
|
||||||
import network.NewsService
|
import network.NewsService
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import kotlin.collections.List
|
import kotlin.collections.List
|
||||||
|
|
||||||
|
import kotlin.math.*
|
||||||
// service/AutoTradingManager.kt
|
// service/AutoTradingManager.kt
|
||||||
object AutoTradingManager {
|
object AutoTradingManager {
|
||||||
private val scope = CoroutineScope(Dispatchers.Default)
|
private val scope = CoroutineScope(Dispatchers.Default)
|
||||||
@ -56,7 +60,43 @@ object TechnicalAnalyzer {
|
|||||||
var daily: List<CandleData> = emptyList()
|
var daily: List<CandleData> = emptyList()
|
||||||
var min30: List<CandleData> = emptyList()
|
var min30: List<CandleData> = emptyList()
|
||||||
|
|
||||||
|
data class InvestmentScores(
|
||||||
|
val ultraShort: Int, // 초단기 (분봉/에너지)
|
||||||
|
val shortTerm: Int, // 단기 (일봉/뉴스)
|
||||||
|
val midTerm: Int, // 중기 (주봉/재무)
|
||||||
|
val longTerm: Int // 장기 (월봉/펀더멘털)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun calculateScores(
|
||||||
|
financialScore: Int // 재무제표 점수 (성장률 등 기반)
|
||||||
|
): InvestmentScores {
|
||||||
|
|
||||||
|
// 1. 초단기 (분봉 + 에너지 지표 위주)
|
||||||
|
val ultra = (TechnicalAnalyzer.calculateMFI(min30, 14) * 0.4 +
|
||||||
|
TechnicalAnalyzer.calculateStochastic(min30) * 0.3 +
|
||||||
|
(if(TechnicalAnalyzer.calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt()
|
||||||
|
|
||||||
|
// 2. 단기 (일봉 추세 + OBV 에너지)
|
||||||
|
val short = (TechnicalAnalyzer.calculateRSI(daily) * 0.3 +
|
||||||
|
(if(TechnicalAnalyzer.calculateOBV(daily) > 0) 40 else 10) +
|
||||||
|
(if(TechnicalAnalyzer.calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt()
|
||||||
|
|
||||||
|
// 3. 중기 (주봉 + 재무 점수 혼합)
|
||||||
|
val mid = (if(TechnicalAnalyzer.calculateChange(weekly) > 0) 40 else 10) +
|
||||||
|
(financialScore * 0.6).toInt()
|
||||||
|
|
||||||
|
// 4. 장기 (월봉 + 섹터/기업 펀더멘털)
|
||||||
|
val long = (if(TechnicalAnalyzer.calculateChange(monthly) > 0) 50 else 0) +
|
||||||
|
(financialScore * 0.5).toInt()
|
||||||
|
|
||||||
|
return InvestmentScores(
|
||||||
|
ultraShort = ultra.coerceIn(0, 100),
|
||||||
|
shortTerm = short.coerceIn(0, 100),
|
||||||
|
midTerm = mid.coerceIn(0, 100),
|
||||||
|
longTerm = long.coerceIn(0, 100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun generateComprehensiveReport(): String {
|
fun generateComprehensiveReport(): String {
|
||||||
// [1] 단기 에너지 지표 계산 (최근 30분봉 기준)
|
// [1] 단기 에너지 지표 계산 (최근 30분봉 기준)
|
||||||
val obv = calculateOBV(min30)
|
val obv = calculateOBV(min30)
|
||||||
@ -72,35 +112,71 @@ object TechnicalAnalyzer {
|
|||||||
// [3] 이평선 및 가격 위치
|
// [3] 이평선 및 가격 위치
|
||||||
val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||||
|
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList())
|
||||||
// [4] 거래량 강도
|
// [4] 거래량 강도
|
||||||
val avgVol30 = min30.map { it.cntg_vol.toLong() }.average()
|
val avgVol30 = min30.map { it.cntg_vol.toLong() }.average()
|
||||||
val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average()
|
val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average()
|
||||||
val volStrength = if (avgVol30 > 0) recentVol5 / avgVol30 else 1.0
|
val volStrength = if (avgVol30 > 0) recentVol5 / avgVol30 else 1.0
|
||||||
|
val atr = calculateATR(min30)
|
||||||
|
val stochK = calculateStochastic(min30)
|
||||||
|
val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() }
|
||||||
return """
|
return """
|
||||||
[종합 시계열 및 에너지 분석 보고서]
|
[초단기 기술적 스켈핑 분석]
|
||||||
|
- 종합 스코어: ${signal.compositeScore} / 100
|
||||||
1. 가격 및 추세 현황
|
- 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
|
||||||
- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"}
|
- 성공 확률 예측: ${signal.successProbPct}%
|
||||||
- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동
|
- 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
|
||||||
- 30분 대비: ${ "%.2f".format(change30) }% 변동
|
- RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배
|
||||||
- 10분 대비: ${ "%.2f".format(change10) }% 변동
|
- 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원)
|
||||||
- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"}
|
- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"}
|
||||||
|
- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동
|
||||||
2. 자금 흐름 및 에너지 지표
|
- 30분 대비: ${ "%.2f".format(change30) }% 변동
|
||||||
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } (${if(obv > 0) "누적 매수 우위" else "누적 매도 우위"})
|
- 10분 대비: ${ "%.2f".format(change10) }% 변동
|
||||||
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } (과매수 기준: 80 / 과매도 기준: 20)
|
- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"}
|
||||||
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } (종가 형성 위치와 거래량 결합 수치)
|
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } (${if(obv > 0) "누적 매수 우위" else "누적 매도 우위"})
|
||||||
- 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준
|
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } (과매수 기준: 80 / 과매도 기준: 20)
|
||||||
|
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } (종가 형성 위치와 거래량 결합 수치)
|
||||||
3. 가격 변동 범위
|
- 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준
|
||||||
- 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }}
|
- ATR (평균 변동폭): ${"%.0f".format(atr)}원 (최근 캔들 하나가 평균적으로 움직이는 크기)
|
||||||
- 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }}
|
- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원 (최고가-최저가 차이)
|
||||||
- RSI(14): ${ "%.1f".format(calculateRSI(min30)) }
|
- 스토캐스틱(%K): ${"%.1f".format(stochK)} (100에 가까울수록 최근 파동의 고점, 0에 가까울수록 저점)
|
||||||
|
- 변동성 강도: 현재 진폭이 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ATR (Average True Range): 최근 변동 폭의 평균. 그래프의 '출렁임' 크기를 측정
|
||||||
|
*/
|
||||||
|
fun calculateATR(candles: List<CandleData>, period: Int = 14): Double {
|
||||||
|
val sub = candles.takeLast(period + 1)
|
||||||
|
val trList = mutableListOf<Double>()
|
||||||
|
for (i in 1 until sub.size) {
|
||||||
|
val high = sub[i].stck_hgpr.toDouble()
|
||||||
|
val low = sub[i].stck_lwpr.toDouble()
|
||||||
|
val prevClose = sub[i - 1].stck_prpr.toDouble()
|
||||||
|
|
||||||
|
val tr = maxOf(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose))
|
||||||
|
trList.add(tr)
|
||||||
|
}
|
||||||
|
return trList.average()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stochastic (%K): 최근 가격 범위 내에서 현재가의 위치 (0~100)
|
||||||
|
* 반복되는 파동(Ups and Downs)에서 현재가 고점인지 저점인지 판단
|
||||||
|
*/
|
||||||
|
fun calculateStochastic(candles: List<CandleData>, period: Int = 14): Double {
|
||||||
|
val sub = candles.takeLast(period)
|
||||||
|
val highest = sub.maxOf { it.stck_hgpr.toDouble() }
|
||||||
|
val lowest = sub.minOf { it.stck_lwpr.toDouble() }
|
||||||
|
val current = sub.last().stck_prpr.toDouble()
|
||||||
|
|
||||||
|
return if (highest != lowest) (current - lowest) / (highest - lowest) * 100 else 50.0
|
||||||
|
}
|
||||||
|
|
||||||
private fun calculateChange(list: List<CandleData>): Double {
|
private fun calculateChange(list: List<CandleData>): Double {
|
||||||
val start = list.first().stck_oprc.toDouble()
|
val start = list.first().stck_oprc.toDouble()
|
||||||
val end = list.last().stck_prpr.toDouble()
|
val end = list.last().stck_prpr.toDouble()
|
||||||
@ -170,4 +246,185 @@ object TechnicalAnalyzer {
|
|||||||
min30 = emptyList()
|
min30 = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScalpingAnalyzer {
|
||||||
|
companion object {
|
||||||
|
private const val SMA_SHORT = 10
|
||||||
|
private const val SMA_LONG = 20
|
||||||
|
private const val RSI_WINDOW = 14
|
||||||
|
private const val VOL_WINDOW = 20
|
||||||
|
private const val VOL_SURGE_THRESHOLD = 1.5
|
||||||
|
private const val RSI_THRESHOLD = 50.0
|
||||||
|
private const val BB_LOWER_POS = 0.2
|
||||||
|
private const val BB_UPPER_POS = 0.8
|
||||||
|
private const val ATR_WINDOW = 14
|
||||||
|
private const val DEFAULT_SL_PCT = -0.5
|
||||||
|
private const val DEFAULT_TP_PCT = 1.0
|
||||||
|
private const val HIGH_SCORE_THRESHOLD = 80
|
||||||
|
}
|
||||||
|
|
||||||
|
fun computeRSI(closes: List<Double>, window: Int = RSI_WINDOW): List<Double> {
|
||||||
|
val rsi = mutableListOf<Double>()
|
||||||
|
if (closes.size < window + 1) return rsi
|
||||||
|
for (i in window until closes.size) {
|
||||||
|
val gains = mutableListOf<Double>()
|
||||||
|
val losses = mutableListOf<Double>()
|
||||||
|
for (j in (i - window + 1) until i + 1) {
|
||||||
|
val delta = closes[j] - closes[j - 1]
|
||||||
|
if (delta > 0) gains.add(delta) else losses.add(abs(delta))
|
||||||
|
}
|
||||||
|
val avgGain = gains.average()
|
||||||
|
val avgLoss = losses.average()
|
||||||
|
val rs = if (avgLoss > 0) avgGain / avgLoss else Double.POSITIVE_INFINITY
|
||||||
|
rsi.add(100.0 - (100.0 / (1.0 + rs)))
|
||||||
|
}
|
||||||
|
return rsi
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bollingerBands(closes: List<Double>, window: Int = SMA_LONG): Triple<List<Double>, List<Double>, List<Double>> {
|
||||||
|
val sma = mutableListOf<Double>()
|
||||||
|
val upper = mutableListOf<Double>()
|
||||||
|
val lower = mutableListOf<Double>()
|
||||||
|
for (i in window - 1 until closes.size) {
|
||||||
|
val slice = closes.subList(i - window + 1, i + 1)
|
||||||
|
val mean = slice.average()
|
||||||
|
val std = sqrt(slice.map { (it - mean).pow(2.0) }.average()) * 2.0
|
||||||
|
sma.add(mean)
|
||||||
|
upper.add(mean + std)
|
||||||
|
lower.add(mean - std)
|
||||||
|
}
|
||||||
|
return Triple(upper, sma, lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun analyze(candles: List<Candle>): ScalpingSignalModel {
|
||||||
|
if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요")
|
||||||
|
|
||||||
|
val closes = candles.map { it.close }
|
||||||
|
val volumes = candles.map { it.volume }
|
||||||
|
|
||||||
|
// 지표 계산
|
||||||
|
val sma10 = simpleMovingAverage(closes, SMA_SHORT)
|
||||||
|
val sma20 = simpleMovingAverage(closes, SMA_LONG)
|
||||||
|
val rsiList = computeRSI(closes)
|
||||||
|
val volAvg = simpleMovingAverage(volumes, VOL_WINDOW)
|
||||||
|
val volRatioList = volumes.mapIndexed { i, v -> if (i >= VOL_WINDOW) v / volAvg[i - VOL_WINDOW] else 0.0 }
|
||||||
|
val (bbUpper, bbMiddle, bbLower) = bollingerBands(closes)
|
||||||
|
|
||||||
|
val current = candles.last()
|
||||||
|
val idx = candles.size - 1
|
||||||
|
val currentClose = current.close
|
||||||
|
val sma10Now = if (sma10.size > 0) sma10.last() else 0.0
|
||||||
|
val sma20Now = if (sma20.size > 0) sma20.last() else 0.0
|
||||||
|
val rsiNow = if (rsiList.isNotEmpty()) rsiList.last() else 0.0
|
||||||
|
val volRatioNow = volRatioList.last()
|
||||||
|
val bbPos = if (bbUpper.isNotEmpty() && bbLower.isNotEmpty()) {
|
||||||
|
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
|
||||||
|
} else 0.5
|
||||||
|
|
||||||
|
// 신호 조건
|
||||||
|
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
|
||||||
|
|
||||||
|
// 종합 스코어 (가중: 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)
|
||||||
|
|
||||||
|
// 위험도 (ATR proxy)
|
||||||
|
val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 }
|
||||||
|
val atrProxy = if (returns.size >= ATR_WINDOW) {
|
||||||
|
returns.subList(returns.size - ATR_WINDOW, returns.size).average()
|
||||||
|
} else 1.0
|
||||||
|
val riskLevel = when {
|
||||||
|
abs(atrProxy) < 1 -> "Low"
|
||||||
|
abs(atrProxy) < 2 -> "Medium"
|
||||||
|
else -> "High"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 확률 & SL/TP
|
||||||
|
val successProb = if (buySignal) 75.0 else 35.0 + (score / 100.0 * 20)
|
||||||
|
val slPrice = currentClose * (1 + DEFAULT_SL_PCT / 100)
|
||||||
|
val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100)
|
||||||
|
val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
|
||||||
|
|
||||||
|
return ScalpingSignalModel(
|
||||||
|
currentPrice = currentClose,
|
||||||
|
buySignal = buySignal,
|
||||||
|
compositeScore = minOf(score.toInt(), 100),
|
||||||
|
successProbPct = successProb,
|
||||||
|
riskLevel = riskLevel,
|
||||||
|
rsi = rsiNow,
|
||||||
|
volRatio = volRatioNow,
|
||||||
|
suggestedSlPrice = slPrice,
|
||||||
|
suggestedTpPrice = tpPrice,
|
||||||
|
riskRewardRatio = rrRatio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun simpleMovingAverage(values: List<Double>, window: Int): List<Double> {
|
||||||
|
val sma = mutableListOf<Double>()
|
||||||
|
for (i in window - 1 until values.size) {
|
||||||
|
val slice = values.subList(i - window + 1, i + 1)
|
||||||
|
sma.add(slice.average())
|
||||||
|
}
|
||||||
|
return sma
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Candle(
|
||||||
|
val timestamp: Long,
|
||||||
|
val open: Double,
|
||||||
|
val high: Double,
|
||||||
|
val low: Double,
|
||||||
|
val close: Double,
|
||||||
|
val volume: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScalpingSignalModel(
|
||||||
|
val currentPrice: Double,
|
||||||
|
val buySignal: Boolean,
|
||||||
|
val compositeScore: Int, // 0-100: 종합 매수 추천도 (80+ 강매수)
|
||||||
|
val successProbPct: Double, // 성공 확률 추정 %
|
||||||
|
val riskLevel: String, // "Low", "Medium", "High"
|
||||||
|
val rsi: Double,
|
||||||
|
val volRatio: Double,
|
||||||
|
val suggestedSlPrice: Double, // 손절 가격
|
||||||
|
val suggestedTpPrice: Double, // 익절 가격
|
||||||
|
val riskRewardRatio: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
fun CandleData.toScalpingCandle(): Candle {
|
||||||
|
// 1. 날짜(YYYYMMDD)와 시간(HHMMSS) 문자열 결합
|
||||||
|
val dateTimeStr = "${this.stck_bsop_date}${this.stck_cntg_hour}"
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
|
||||||
|
|
||||||
|
// 2. 타임스탬프(Epoch Milliseconds) 계산
|
||||||
|
val timestamp = try {
|
||||||
|
val ldt = LocalDateTime.parse(dateTimeStr, formatter)
|
||||||
|
ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 시간 파싱 실패 시 현재 시스템 시간 사용
|
||||||
|
System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. String 필드들을 Double로 변환하여 Candle 객체 생성
|
||||||
|
return Candle(
|
||||||
|
timestamp = timestamp,
|
||||||
|
open = this.stck_oprc.toDoubleOrNull() ?: 0.0,
|
||||||
|
high = this.stck_hgpr.toDoubleOrNull() ?: 0.0,
|
||||||
|
low = this.stck_lwpr.toDoubleOrNull() ?: 0.0,
|
||||||
|
close = this.stck_prpr.toDoubleOrNull() ?: 0.0, // stck_prpr가 종가 역할
|
||||||
|
volume = this.cntg_vol.toDoubleOrNull() ?: 0.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스트 전체를 변환하는 유틸리티
|
||||||
|
*/
|
||||||
|
fun List<CandleData>.toScalpingList(): List<Candle> {
|
||||||
|
return this.map { it.toScalpingCandle() }
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import service.AutoTradingManager
|
|||||||
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
|
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
|
||||||
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
||||||
var code by remember(stockCode) {
|
var code by remember(stockCode) {
|
||||||
|
aiOpinion = ""
|
||||||
mutableStateOf(stockCode.isNotEmpty())
|
mutableStateOf(stockCode.isNotEmpty())
|
||||||
}
|
}
|
||||||
var isAnalyzing by remember { mutableStateOf(false) }
|
var isAnalyzing by remember { mutableStateOf(false) }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user