알림 스레드 multi-TF 모니터링 + per-(interval,key) state 분리

## 동작 변경
이전: 알림 스레드가 사용자 dropdown 선택 1개 시간봉만 polling.
이후: [1m, 3m, 5m, 15m, 30m, 1h] 6개 시간봉을 매 cycle 마다 순회 polling.
같은 신호가 여러 시간봉에서 발화하면 각 시간봉별로 독립 알림 (dedup 도
interval 별로 분리).

## 상태 구조 변경 (alert_state.py)
- last_alert : dict[(interval, key)] -> timestamp
- last_fired_candle : dict[(interval, key)] -> candle_time
- long_entry / short_entry : dict[interval] -> entry_record
  (TF 별로 진입 추적, 손절가 검증도 TF 별)

## 신호 정의는 변경 없음
OI 필터(oi_up / oi_up_2 / oi_down_2) 모두 원복 — 신호 정의는 의도된
대로 유지. "OI 하락 + 가격 하락 = 롱 청산" 케이스는 시스템 설계상
vol_short/strong_short 가 안 잡는 것이 정상 (별도 신호 추가 시 더
정밀한 분리 가능, 이번 변경에는 불포함).

## API 호출 부담
6 TF × ~4 endpoint per cycle = 24 calls / 30s = 48 calls/min. Binance
futures 1200/min limit 대비 4% 사용 — 안전.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ILSEON-RYU
2026-05-04 19:40:39 +09:00
parent b27e2fcf51
commit b572758682
2 changed files with 44 additions and 45 deletions
+12 -15
View File
@@ -6,25 +6,22 @@ Streamlit 의 매 rerun 마다 메인 스크립트는 새 namespace 에서 재
state 가 process lifetime 동안 보존된다. 알림 dedup, 진입 추적, 스레드 state 가 process lifetime 동안 보존된다. 알림 dedup, 진입 추적, 스레드
기동 가드 등 다중 rerun 환경에서 살아남아야 하는 모든 mutable 상태는 기동 가드 등 다중 rerun 환경에서 살아남아야 하는 모든 mutable 상태는
여기에 둔다. 여기에 둔다.
알림 스레드는 multi-TF (1m/3m/5m/15m/30m/1h) 동시 모니터링 모드라
dedup 과 진입 추적은 모두 (interval, key) 또는 interval 별로 분리된다.
""" """
import threading import threading
last_alert = { # (interval, key) 별 마지막 알림 시각 (cooldown 용). default 0.
"strong_long": 0, "strong_short": 0, # 키가 없으면 dict.get 으로 0 fallback.
"long": 0, "short": 0, last_alert = {}
"vol_long": 0, "vol_short": 0,
"short_caution": 0,
}
last_fired_candle = { # (interval, key) 별 마지막으로 알림 보낸 candle open_time (per-candle dedup).
"strong_long": None, "strong_short": None, last_fired_candle = {}
"long": None, "short": None,
"vol_long": None, "vol_short": None,
"short_caution": None,
}
long_entry = None # interval 별 진입 추적. value = entry_record dict 또는 None.
short_entry = None long_entry = {}
short_entry = {}
# forming candle 에서 발사된 알림은 캔들 마감 후 신호 재검증을 받는다. # forming candle 에서 발사된 알림은 캔들 마감 후 신호 재검증을 받는다.
# 마감 시점에 신호가 사라졌으면 [취소 알림] 을 보낸다. # 마감 시점에 신호가 사라졌으면 [취소 알림] 을 보낸다.
@@ -32,7 +29,7 @@ short_entry = None
pending_groups = [] pending_groups = []
alert_symbol = "BTCUSDT" alert_symbol = "BTCUSDT"
alert_interval = "5m" alert_interval = "5m" # UI 표시용; 알림 스레드는 multi-TF 모니터링이라 무시
alert_lock = threading.Lock() alert_lock = threading.Lock()
alert_started = False alert_started = False
daily_report_started = False daily_report_started = False
+32 -30
View File
@@ -96,9 +96,6 @@ def check_and_alert(df, symbol, interval):
forming_ct = df.iloc[-1]["open_time"] forming_ct = df.iloc[-1]["open_time"]
# Phase 1 — pending_groups 검증. # Phase 1 — pending_groups 검증.
# 이전 polling 에서 forming candle 기준으로 발사한 알림들은 그 캔들이 닫히면
# 신호가 살아남았는지 다시 확인한다. 모든 신호가 False 로 바뀌었으면 [취소 알림]
# 을 발송하고, 진입 추적(_long_entry/_short_entry) 도 클리어.
new_pending = [] new_pending = []
for p in alert_state.pending_groups: for p in alert_state.pending_groups:
if p["interval"] != interval: if p["interval"] != interval:
@@ -110,16 +107,17 @@ def check_and_alert(df, symbol, interval):
continue continue
row_match = df[df["open_time"] == ct] row_match = df[df["open_time"] == ct]
if row_match.empty: if row_match.empty:
continue # 캔들이 df 윈도우 밖으로 빠짐 — 검증 포기, drop continue
row = row_match.iloc[0] row = row_match.iloc[0]
any_still_true = any(bool(row.get(s, False)) for s in p["sig_cols"]) any_still_true = any(bool(row.get(s, False)) for s in p["sig_cols"])
if not any_still_true: if not any_still_true:
send_telegram(f"[취소 알림]\n{p['msg']}") send_telegram(f"[취소 알림]\n{p['msg']}")
if p["direction"] == "long" and alert_state.long_entry is not None and alert_state.long_entry.get("open_time") == ct: le = alert_state.long_entry.get(interval)
alert_state.long_entry = None se = alert_state.short_entry.get(interval)
elif p["direction"] == "short" and alert_state.short_entry is not None and alert_state.short_entry.get("open_time") == ct: if p["direction"] == "long" and le is not None and le.get("open_time") == ct:
alert_state.short_entry = None alert_state.long_entry[interval] = None
# 검증 완료 (확정 또는 취소) — pending 에서 제거 (new_pending 에 추가 X) elif p["direction"] == "short" and se is not None and se.get("open_time") == ct:
alert_state.short_entry[interval] = None
alert_state.pending_groups = new_pending alert_state.pending_groups = new_pending
# Phase 2 — 신호 검사 + 알림 발사 (forming candle 포함) # Phase 2 — 신호 검사 + 알림 발사 (forming candle 포함)
@@ -133,9 +131,10 @@ def check_and_alert(df, symbol, interval):
if triggered.empty: if triggered.empty:
continue continue
candle_time = triggered.iloc[-1]["open_time"] candle_time = triggered.iloc[-1]["open_time"]
if candle_time == alert_state.last_fired_candle.get(key): state_key = (interval, key)
if candle_time == alert_state.last_fired_candle.get(state_key):
continue continue
if now - alert_state.last_alert[key] <= ALERT_COOLDOWN: if now - alert_state.last_alert.get(state_key, 0) <= ALERT_COOLDOWN:
continue continue
eligible.append({ eligible.append({
"sig": sig, "key": key, "sub_label": sub_label, "sig": sig, "key": key, "sub_label": sub_label,
@@ -178,14 +177,13 @@ def check_and_alert(df, symbol, interval):
) )
entry_record = {"price": entry_price, "stop": stop_price, "open_time": candle_time, "entry_msg": msg} entry_record = {"price": entry_price, "stop": stop_price, "open_time": candle_time, "entry_msg": msg}
if direction == "long": if direction == "long":
alert_state.long_entry = entry_record alert_state.long_entry[interval] = entry_record
else: else:
alert_state.short_entry = entry_record alert_state.short_entry[interval] = entry_record
send_telegram(msg) send_telegram(msg)
for e in group: for e in group:
alert_state.last_alert[e["key"]] = now alert_state.last_alert[(interval, e["key"])] = now
alert_state.last_fired_candle[e["key"]] = e["candle_time"] alert_state.last_fired_candle[(interval, e["key"])] = e["candle_time"]
# forming candle 기반 알림이면 마감 후 재검증을 위해 pending_groups 에 등록.
if candle_time == forming_ct: if candle_time == forming_ct:
alert_state.pending_groups.append({ alert_state.pending_groups.append({
"interval": interval, "interval": interval,
@@ -200,18 +198,20 @@ def check_and_alert(df, symbol, interval):
_send_group(groups.get("caution", [])) _send_group(groups.get("caution", []))
current_price = float(df.iloc[-1]["close"]) current_price = float(df.iloc[-1]["close"])
if alert_state.long_entry is not None and current_price <= alert_state.long_entry["stop"]: le = alert_state.long_entry.get(interval)
se = alert_state.short_entry.get(interval)
if le is not None and current_price <= le["stop"]:
send_telegram( send_telegram(
f"[손절가알림]\n{alert_state.long_entry['entry_msg']}\n" f"[손절가알림]\n{le['entry_msg']}\n"
f"현재가: {current_price:,.2f}" f"현재가: {current_price:,.2f}"
) )
alert_state.long_entry = None alert_state.long_entry[interval] = None
if alert_state.short_entry is not None and current_price >= alert_state.short_entry["stop"]: if se is not None and current_price >= se["stop"]:
send_telegram( send_telegram(
f"[손절가알림]\n{alert_state.short_entry['entry_msg']}\n" f"[손절가알림]\n{se['entry_msg']}\n"
f"현재가: {current_price:,.2f}" f"현재가: {current_price:,.2f}"
) )
alert_state.short_entry = None alert_state.short_entry[interval] = None
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 데이터 수집 # 데이터 수집
@@ -692,16 +692,18 @@ def _build_signal_df(symbol, interval, klines_limit=200):
df = compute_indicators(df, interval) df = compute_indicators(df, interval)
return df return df
ALERT_TIMEFRAMES = ["1m", "3m", "5m", "15m", "30m", "1h"]
def _alert_loop(): def _alert_loop():
while True: while True:
try: with alert_state.alert_lock:
with alert_state.alert_lock: symbol = alert_state.alert_symbol
symbol = alert_state.alert_symbol for interval in ALERT_TIMEFRAMES:
interval = alert_state.alert_interval try:
df = _build_signal_df(symbol, interval, 200) df = _build_signal_df(symbol, interval, 200)
check_and_alert(df, symbol, interval) check_and_alert(df, symbol, interval)
except Exception as e: except Exception as e:
print(f"[알림스레드 오류] {e}") print(f"[알림스레드 오류] {interval}: {e}")
time.sleep(30) time.sleep(30)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────