검증게시판 단계 추가

This commit is contained in:
Macbook
2026-05-28 16:24:14 +09:00
parent f64dc1e983
commit 7e3644cb62
375 changed files with 4539 additions and 251294 deletions
@@ -0,0 +1,122 @@
package com.analysis.goldenanalysis.util
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
data class StockStrategyPair(
val koreanName: String,
val strategyName: String,
val symbol: String? = null,
)
data class ResolvedStockRow(
val koreanName: String,
val strategyName: String,
val displaySymbol: String,
val navSymbol: String?,
)
object AlertMessageParser {
private val marketCodeRe = Regex("^(KRW|USDT|BTC)-[A-Z0-9]+$", RegexOption.IGNORE_CASE)
fun extractStrategyNames(message: String): List<String>? {
if (!message.contains("알림 조건 충족") || !message.contains("개)")) return null
val lines = message.split("\n").map { it.trim() }.filter { it.isNotEmpty() }
val names = mutableListOf<String>()
for (i in 1 until lines.size) {
val sep = listOf(" × ", " X ").firstOrNull { lines[i].contains(it) } ?: continue
val idx = lines[i].indexOf(sep)
if (idx >= 0) {
val name = lines[i].substring(idx + sep.length).trim()
if (name.isNotEmpty()) names.add(name)
}
}
return if (names.isEmpty()) null else names.distinct()
}
fun extractStockStrategyPairs(message: String): List<StockStrategyPair>? {
if (!message.contains("알림 조건 충족") || !message.contains("개)")) return null
val lines = message.split("\n").map { it.trim() }.filter { it.isNotEmpty() }
val pairs = mutableListOf<StockStrategyPair>()
for (i in 1 until lines.size) {
val sep = listOf(" × ", " X ").firstOrNull { lines[i].contains(it) } ?: continue
val idx = lines[i].indexOf(sep)
if (idx >= 0) {
val kn = lines[i].substring(0, idx).trim()
val sn = lines[i].substring(idx + sep.length).trim()
if (kn.isNotEmpty()) pairs.add(StockStrategyPair(kn, sn))
}
}
return pairs.ifEmpty { null }
}
fun resolveSymbols(koreanNames: List<String>, cryptoMap: Map<String, String>): List<Pair<String, String>> {
val entries = cryptoMap.entries.toList()
fun norm(s: String) = s.replace(Regex("\\s+"), " ").trim()
val out = mutableListOf<Pair<String, String>>()
for (kn in koreanNames) {
val n = norm(kn)
var matched = entries.find { norm(it.key) == n || it.key == kn }
if (matched == null) {
matched = entries.find {
norm(it.key).contains(n) || n.contains(norm(it.key))
}
}
if (matched != null) out.add(matched.key to matched.value)
}
return out
}
private fun koreanNameForMarketSymbol(symbol: String, cryptoMap: Map<String, String>, fallback: String): String {
val s = symbol.trim().uppercase()
cryptoMap.entries.forEach { (k, v) ->
if (v.uppercase() == s) return k
}
return fallback
}
fun resolveStockStrategyPairRow(pair: StockStrategyPair, cryptoMap: Map<String, String>): ResolvedStockRow {
if (!pair.symbol.isNullOrBlank()) {
val nav = pair.symbol.trim().uppercase()
val left = pair.koreanName.trim()
val kn = if (left.isNotEmpty() && !marketCodeRe.matches(left)) pair.koreanName
else koreanNameForMarketSymbol(nav, cryptoMap, left.ifEmpty { nav })
return ResolvedStockRow(kn, pair.strategyName, nav, nav)
}
val raw = pair.koreanName.trim()
if (marketCodeRe.matches(raw)) {
val nav = raw.uppercase()
val kn = koreanNameForMarketSymbol(nav, cryptoMap, raw)
return ResolvedStockRow(kn, pair.strategyName, nav, nav)
}
val resolved = resolveSymbols(listOf(pair.koreanName), cryptoMap).firstOrNull()
return ResolvedStockRow(
pair.koreanName,
pair.strategyName,
resolved?.second ?: pair.strategyName,
resolved?.second,
)
}
fun titleAndPairs(notification: AlertNotificationDto): Pair<String, List<StockStrategyPair>> {
val msg = notification.message.orEmpty()
if (msg.contains("알림 조건 충족") && msg.contains("개)")) {
val strategyNames = extractStrategyNames(msg).orEmpty()
val title = when {
strategyNames.isEmpty() -> "🎯 통합 알림"
strategyNames.size <= 3 -> strategyNames.joinToString(", ")
else -> "${strategyNames.take(2).joinToString(", ")}${strategyNames.size - 2}개 전략"
}
val pairs = extractStockStrategyPairs(msg).orEmpty()
return title to pairs
}
if (!notification.koreanName.isNullOrBlank()) {
val firstLine = msg.split("\n").firstOrNull()?.trim().orEmpty()
val strategyName = firstLine.replace(Regex("^[\\s🧪🎯✅]*"), "").trim().ifEmpty { "알림" }
return strategyName to listOf(
StockStrategyPair(notification.koreanName!!, strategyName, notification.symbol)
)
}
return (notification.symbol ?: "알림") to emptyList()
}
}
@@ -0,0 +1,44 @@
package com.analysis.goldenanalysis.util
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
object AlertSignalInference {
fun inferFromStrategyName(strategyName: String): String? {
val s = strategyName.trim()
if (s.isEmpty()) return null
val hint = s.replace(Regex("\\s"), "")
if (Regex("매도$").containsMatchIn(hint) && !Regex("과매도$").containsMatchIn(hint)) return "SELL"
if (Regex("매수$").containsMatchIn(hint) && !Regex("과매수$").containsMatchIn(hint)) return "BUY"
if (s.contains("데드크로스")) return "SELL"
if (s.contains("골든크로스")) return "BUY"
return null
}
fun inferFromNotification(n: AlertNotificationDto, koreanNameForRow: String?): String? {
val row = koreanNameForRow?.trim()
if (!row.isNullOrEmpty()) {
val pairs = AlertMessageParser.extractStockStrategyPairs(n.message.orEmpty()).orEmpty()
val pair = pairs.find { it.koreanName.trim() == row }
pair?.strategyName?.let { inferFromStrategyName(it) }?.let { return it }
}
val raw = "${n.message.orEmpty()}\n${n.conditionDescription.orEmpty()}"
val pairsAll = AlertMessageParser.extractStockStrategyPairs(n.message.orEmpty()).orEmpty()
if (pairsAll.size == 1) {
inferFromStrategyName(pairsAll[0].strategyName)?.let { return it }
}
val hint = raw.replace(Regex("\\s"), "")
if (Regex("매도$").containsMatchIn(hint) && !Regex("과매도$").containsMatchIn(hint)) return "SELL"
if (Regex("매수$").containsMatchIn(hint) && !Regex("과매수$").containsMatchIn(hint)) return "BUY"
if (raw.contains("데드크로스")) return "SELL"
if (raw.contains("골든크로스")) return "BUY"
val masked = raw.replace("과매도", "").replace("과매수", "")
if (Regex("매도조건|매도신호").containsMatchIn(masked) &&
!Regex("매수조건|매수신호").containsMatchIn(masked)
) return "SELL"
if (Regex("매수조건|매수신호").containsMatchIn(masked) &&
!Regex("매도조건|매도신호").containsMatchIn(masked)
) return "BUY"
return null
}
}
@@ -0,0 +1,35 @@
package com.analysis.goldenanalysis.util
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
object ApiPrefs {
private const val PREFS = "app_prefs"
private const val KEY_API_BASE = "api_base_url"
private const val KEY_FRONTEND = "frontend_url"
fun prefs(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* 백엔드 REST 베이스 (…/api/ 형태, Retrofit용).
* [api_base_url]이 있으면 사용, 없으면 [frontend_url]의 origin + /api/
*/
fun getApiBaseUrl(context: Context): String {
val p = prefs(context)
val explicit = p.getString(KEY_API_BASE, null)?.trim().orEmpty()
if (explicit.isNotEmpty()) {
var b = explicit.trimEnd('/')
if (!b.endsWith("/api")) b = "$b/api"
return "$b/"
}
val front = p.getString(KEY_FRONTEND, "https://exdev.co.kr/mobile/chart")
?: "https://exdev.co.kr/mobile/chart"
val uri = Uri.parse(front)
val scheme = uri.scheme ?: "https"
val host = uri.host ?: "exdev.co.kr"
val port = if (uri.port == -1 || uri.port == 80 || uri.port == 443) "" else ":${uri.port}"
return "$scheme://$host$port/api/"
}
}
@@ -0,0 +1,62 @@
package com.analysis.goldenanalysis.util
import android.content.Context
import androidx.work.*
import com.analysis.goldenanalysis.data.model.Schedule
import com.analysis.goldenanalysis.worker.ScheduleWorker
import java.util.concurrent.TimeUnit
object ScheduleManager {
fun scheduleWork(context: Context, schedule: Schedule) {
val workManager = WorkManager.getInstance(context)
if (!schedule.isEnabled) {
cancelWork(context, schedule.id)
return
}
val intervalMinutes = getIntervalInMinutes(schedule.interval)
val inputData = Data.Builder()
.putLong("schedule_id", schedule.id)
.build()
val workRequest = PeriodicWorkRequestBuilder<ScheduleWorker>(
intervalMinutes,
TimeUnit.MINUTES
)
.setInputData(inputData)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.addTag("schedule_${schedule.id}")
.build()
workManager.enqueueUniquePeriodicWork(
"schedule_${schedule.id}",
ExistingPeriodicWorkPolicy.REPLACE,
workRequest
)
}
fun cancelWork(context: Context, scheduleId: Long) {
val workManager = WorkManager.getInstance(context)
workManager.cancelUniqueWork("schedule_$scheduleId")
}
private fun getIntervalInMinutes(interval: String): Long {
return when (interval) {
"1분" -> 15 // Minimum 15 minutes for WorkManager
"5분" -> 15
"15분" -> 15
"30분" -> 30
"1시간" -> 60
"4시간" -> 240
"1일" -> 1440
else -> 60
}
}
}
@@ -0,0 +1,33 @@
package com.analysis.goldenanalysis.util
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object UpbitWebUrls {
fun mapAlertTimeIntervalToResolution(timeInterval: String?): String? {
if (timeInterval.isNullOrBlank()) return null
val raw = timeInterval.trim()
if (Regex("^\\d+M$").matches(raw)) {
return mapOf("1M" to "1M", "3M" to "3M", "6M" to "6M", "12M" to "12M")[raw]
}
val key = raw.lowercase()
val map = mapOf(
"1m" to "1", "3m" to "3", "5m" to "5", "10m" to "10",
"15m" to "15", "30m" to "30", "60m" to "60", "1h" to "60",
"4h" to "240", "240m" to "240", "1d" to "1D", "1w" to "1W",
)
return map[key]
}
fun getUpbitWebExchangeUrl(market: String?, timeInterval: String?): String? {
if (market.isNullOrBlank()) return null
var m = market.trim().uppercase()
if (m.startsWith("CRIX.UPBIT.")) m = m.removePrefix("CRIX.UPBIT.")
if (!Regex("^(KRW|USDT|BTC)-[A-Z0-9]+$", RegexOption.IGNORE_CASE).matches(m)) return null
val code = "CRIX.UPBIT.$m"
val base = "https://www.upbit.com/exchange?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8.name())
val res = mapAlertTimeIntervalToResolution(timeInterval)
return if (res != null) "$base&resolution=" + URLEncoder.encode(res, StandardCharsets.UTF_8.name()) else base
}
}