검증게시판 단계 추가
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user