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