atrade/src/main/kotlin/network/NewsService.kt

186 lines
7.6 KiB
Kotlin
Raw Normal View History

2026-01-21 18:59:55 +09:00
package network
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.engine.cio.CIOEngineConfig
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.http.ContentType.Application.Json
2026-02-09 15:32:31 +09:00
import io.ktor.http.Url
2026-01-21 18:59:55 +09:00
import io.ktor.serialization.kotlinx.json.json
2026-02-10 15:08:52 +09:00
import kotlinx.serialization.Serializable
2026-01-21 18:59:55 +09:00
import kotlinx.serialization.json.Json
2026-01-22 16:21:18 +09:00
import model.DartFinancialResponse
2026-03-13 11:06:20 +09:00
import model.KisSession
2026-01-21 18:59:55 +09:00
import model.NaverNewsResponse
2026-01-23 17:05:09 +09:00
import service.DynamicNewsScraper
2026-03-26 13:48:26 +09:00
import service.FinancialAnalyzer
2026-01-23 17:05:09 +09:00
import service.SafeScraper
import service.UrlCacheManager
2026-02-10 15:08:52 +09:00
import kotlin.Double
2026-01-21 18:59:55 +09:00
object NewsService {
private val client = HttpClient<CIOEngineConfig>(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true })
2026-01-23 17:05:09 +09:00
}
install(Logging) {
logger = Logger.DEFAULT
2026-02-03 18:07:18 +09:00
level = LogLevel.NONE
2026-01-21 18:59:55 +09:00
}
}
2026-01-23 17:05:09 +09:00
suspend fun fetchAndIngestNews(corpInfo: CorpInfo) {
2026-03-13 11:06:20 +09:00
val clientId = KisSession.config.nAppKey // 설정에서 가져오도록 수정 필요
val clientSecret = KisSession.config.nSecretKey
2026-02-09 15:32:31 +09:00
val qlistNews = listOf(
"${corpInfo.stockName} 주가",
"${corpInfo.stockName} 실적",
"${corpInfo.stockName} 공시",
2026-02-10 15:08:52 +09:00
// "${corpInfo.stockName} 이벤트"
2026-02-09 15:32:31 +09:00
)
val qlistCorpTrend = listOf(
"${corpInfo.cName} 최근 동향",
"${corpInfo.cName} 이슈",
2026-02-10 15:08:52 +09:00
// "${corpInfo.cName} 투자",
// "${corpInfo.cName} 실적"
2026-02-09 15:32:31 +09:00
)
(qlistNews + qlistCorpTrend).forEach { query ->
2026-01-23 17:05:09 +09:00
try {
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
parameter("query", query)
2026-02-10 15:08:52 +09:00
parameter("display", 4) // 최근 10개 뉴스
2026-02-09 15:32:31 +09:00
parameter("sort", "date") // 유사도 순 (또는 date 발간순)
2026-01-23 17:05:09 +09:00
header("X-Naver-Client-Id", clientId)
header("X-Naver-Client-Secret", clientSecret)
}.body()
2026-02-10 15:08:52 +09:00
SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(2) )
2026-01-23 17:05:09 +09:00
} catch (e: Exception) {
println("❌ 뉴스 가져오기 실패: ${e.message}")
2026-01-21 18:59:55 +09:00
}
}
}
2026-01-22 16:21:18 +09:00
2026-01-23 17:05:09 +09:00
// 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) {
// "기업 정보 로드 실패"
// }
// }
2026-01-22 16:21:18 +09:00
suspend fun fetchFinancialGrowth(corpCode: String?): String {
if (corpCode != null) {
2026-03-13 11:06:20 +09:00
val apiKey = KisSession.config.dAppKey
2026-01-22 16:21:18 +09:00
// 단일회사 주요계정 API (재무상태표, 손익계산서 주요 항목)
val url = "https://opendart.fss.or.kr/api/fnlttSinglAcnt.json?crtfc_key=$apiKey&corp_code=$corpCode&bsns_year=2024&reprt_code=11011"
return try {
val response = client.get(url).body<DartFinancialResponse>()
val accounts = response.list ?: return "재무 데이터 없음"
2026-01-23 17:05:09 +09:00
var buffer : StringBuffer = StringBuffer()
2026-02-10 15:08:52 +09:00
buffer.append("[재무 분석 데이터]").append("\n")
2026-01-23 17:05:09 +09:00
response.list.forEach { it
2026-03-26 13:48:26 +09:00
buffer.append("${it.account_nm} (당기)${it.thstrm_amount} (전기)${it.frmtrm_amount}").append("\n")
2026-01-23 17:05:09 +09:00
}
return buffer.toString()
2026-01-22 16:21:18 +09:00
} catch (e: Exception) {
"재무 API 연동 실패: ${e.message}"
}
} else {
return ""
}
}
2026-02-10 15:08:52 +09:00
}
object FinancialMapper {
/**
* 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환
*/
fun mapRawTextToStatement(rawText: String): FinancialStatement {
if (rawText.isBlank()) {
return FinancialStatement()
}
2026-03-26 13:48:26 +09:00
// println(rawText)
2026-02-10 15:08:52 +09:00
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
2026-03-26 13:48:26 +09:00
).apply {
println("당기순이익: ${niCurrent} , isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(this)}")
}
2026-02-10 15:08:52 +09:00
}
private fun extractYearlyValues(text: String, type: String): Map<String, Double> {
val result = mutableMapOf<String, Double>()
2026-03-26 13:48:26 +09:00
// 핵심 수정: 항목명 뒤에 (당기) 또는 (전기)가 오고, 그 직후의 숫자(마이너스, 쉼표 포함)를 캡처
// 쉼표나 공백으로 끝나는 지점까지 찾습니다.
val regex = Regex("""([가-힣\s()]+)\s\($type\)([-0-9,.]+)""")
2026-02-10 15:08:52 +09:00
regex.findAll(text).forEach { match ->
val key = match.groupValues[1].trim()
2026-03-26 13:48:26 +09:00
// 숫자 내 쉼표 제거 후 Double 변환
val rawValue = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0
result[key] = rawValue
2026-02-10 15:08:52 +09:00
}
return result
}
}
2026-01-22 16:21:18 +09:00
2026-02-10 15:08:52 +09:00
@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
)