알림 상태를 별도 모듈로 분리해 Streamlit rerun 영향 차단

## 배경
이전 커밋 fedbf1c 에서 globals() 검사 가드로 rerun 시 상태 초기화를 막으려
했으나 동작하지 않음. streamlit.log 의 "[일일리포트] 스레드 기동" 메시지가
restart 후에도 12 회 이상 누적된 것이 증거.

## 원인
Streamlit 은 rerun 시 메인 스크립트를 새 namespace 에서 exec() 한다.
이 namespace 는 매번 새로 만들어지므로 globals() 안에는 이전 run 의
변수가 존재하지 않음 -> 가드가 항상 True 분기를 타고 mutable state 가
매번 초기화됨. 그 결과:
- 알림 dedup 상태 (last_fired_candle) 가 None 으로 리셋
- 진입 추적 (long_entry / short_entry) 가 None 으로 리셋
- 스레드 기동 가드 (alert_started) 가 False 로 리셋 -> 새 스레드 spawn
- threading.Lock() 도 매번 새로 생성되어 동기화 깨짐

이로 인해 같은 캔들 (예: 30분봉 20:30 일반 숏) 알림이 텔레그램으로 30초
간격 반복 발사되는 증상 발생.

## 수정
mutable state 를 별도 alert_state.py 모듈로 분리. 메인 스크립트는
"import alert_state" 만 실행하는데, 이 import 는 sys.modules 캐싱
덕분에 첫 실행 후 노옵 -> alert_state 모듈은 process lifetime 동안
같은 객체이며 그 attribute 들은 보존된다. 메인 스크립트 namespace 가
매번 새로 만들어져도 alert_state 의 state 는 영향받지 않음.

상태 항목:
- last_alert        (signal type 별 마지막 발사 시각, 쿨다운용)
- last_fired_candle (signal type 별 마지막 발사 캔들 open_time, dedup)
- long_entry / short_entry (진입 추적)
- alert_lock, alert_symbol, alert_interval (스레드 동기화 + UI -> 스레드)
- alert_started, daily_report_started (스레드 1회 기동 가드)
- last_report_date (자정 통과 감지용)

## 검증
import 동작 확인 (별도 process 에서):
- 같은 모듈 객체 (s1 is s2)
- 같은 dict 객체 (s1.last_fired_candle is s2.last_fired_candle)
- attribute set/get 양쪽에 반영

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ILSEON-RYU
2026-05-02 21:54:01 +09:00
parent fedbf1c81e
commit 7dcd9591dc
2 changed files with 67 additions and 53 deletions
+34
View File
@@ -0,0 +1,34 @@
"""
Streamlit 의 매 rerun 마다 메인 스크립트는 새 namespace 에서 재실행되어
모듈 최상단의 mutable state 가 모두 초기화된다 (`globals()` 가드도 우회됨).
이 파일은 별도 모듈로 단 한 번만 import 되므로 (Python 의 sys.modules 캐싱)
state 가 process lifetime 동안 보존된다. 알림 dedup, 진입 추적, 스레드
기동 가드 등 다중 rerun 환경에서 살아남아야 하는 모든 mutable 상태는
여기에 둔다.
"""
import threading
last_alert = {
"strong_long": 0, "strong_short": 0,
"long": 0, "short": 0,
"vol_long": 0, "vol_short": 0,
"short_caution": 0,
}
last_fired_candle = {
"strong_long": None, "strong_short": None,
"long": None, "short": None,
"vol_long": None, "vol_short": None,
"short_caution": None,
}
long_entry = None
short_entry = None
alert_symbol = "BTCUSDT"
alert_interval = "5m"
alert_lock = threading.Lock()
alert_started = False
daily_report_started = False
last_report_date = None
+33 -53
View File
@@ -64,15 +64,10 @@ TF_LABEL_MAP = {
"1d": "1일봉", "3d": "3일봉", "1M": "1개월봉",
}
# Streamlit 은 매 페이지 rerun 마다 모듈 최상단 코드를 재실행한다. 알림용 mutable
# 상태 (dedup, 진입 추적, 스레드 기동 플래그 등)는 한 번만 초기화되어야 하므로
# globals() 검사로 일회성 초기화를 보장한다.
if "_alert_state_initialized" not in globals():
_last_alert = {"strong_long": 0, "strong_short": 0, "long": 0, "short": 0, "vol_long": 0, "vol_short": 0, "short_caution": 0}
_last_fired_candle = {"strong_long": None, "strong_short": None, "long": None, "short": None, "vol_long": None, "vol_short": None, "short_caution": None}
_long_entry = None
_short_entry = None
_alert_state_initialized = True
# Streamlit 은 매 rerun 마다 메인 스크립트를 새 namespace 에서 재실행해
# globals() 가드도 우회된다. 알림 mutable 상태는 별도 모듈에 두어 sys.modules
# 캐싱으로 process lifetime 보존되도록 한다 (alert_state.py 참조).
import alert_state
# ──────────────────────────────────────────────
# 텔레그램
@@ -95,7 +90,6 @@ SIG_DEFS = [
]
def check_and_alert(df, symbol, interval):
global _long_entry, _short_entry
now = time.time()
recent = df.tail(3)
@@ -107,9 +101,9 @@ def check_and_alert(df, symbol, interval):
if triggered.empty:
continue
candle_time = triggered.iloc[-1]["open_time"]
if candle_time == _last_fired_candle.get(key):
if candle_time == alert_state.last_fired_candle.get(key):
continue
if now - _last_alert[key] <= ALERT_COOLDOWN:
if now - alert_state.last_alert[key] <= ALERT_COOLDOWN:
continue
eligible.append({
"sig": sig, "key": key, "sub_label": sub_label,
@@ -152,33 +146,31 @@ 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":
global _long_entry
_long_entry = entry_record
alert_state.long_entry = entry_record
else:
global _short_entry
_short_entry = entry_record
alert_state.short_entry = entry_record
send_telegram(msg)
for e in group:
_last_alert[e["key"]] = now
_last_fired_candle[e["key"]] = e["candle_time"]
alert_state.last_alert[e["key"]] = now
alert_state.last_fired_candle[e["key"]] = e["candle_time"]
_send_group(groups.get("long", []))
_send_group(groups.get("short", []))
_send_group(groups.get("caution", []))
current_price = float(df.iloc[-1]["close"])
if _long_entry is not None and current_price <= _long_entry["stop"]:
if alert_state.long_entry is not None and current_price <= alert_state.long_entry["stop"]:
send_telegram(
f"[손절가알림]\n{_long_entry['entry_msg']}\n"
f"[손절가알림]\n{alert_state.long_entry['entry_msg']}\n"
f"현재가: {current_price:,.2f}"
)
_long_entry = None
if _short_entry is not None and current_price >= _short_entry["stop"]:
alert_state.long_entry = None
if alert_state.short_entry is not None and current_price >= alert_state.short_entry["stop"]:
send_telegram(
f"[손절가알림]\n{_short_entry['entry_msg']}\n"
f"[손절가알림]\n{alert_state.short_entry['entry_msg']}\n"
f"현재가: {current_price:,.2f}"
)
_short_entry = None
alert_state.short_entry = None
# ──────────────────────────────────────────────
# 데이터 수집
@@ -631,13 +623,7 @@ def build_chart(symbol, interval, candle_limit=200):
# ──────────────────────────────────────────────
# 알림 스레드
# ──────────────────────────────────────────────
if "_alert_thread_state_initialized" not in globals():
_alert_symbol = "BTCUSDT"
_alert_interval = "5m"
_alert_lock = threading.Lock()
_alert_started = False
_daily_report_started = False
_alert_thread_state_initialized = True
# 알림 스레드용 mutable state 는 alert_state 모듈에 보관 (위 import 참조).
def _build_signal_df(symbol, interval, klines_limit=200):
df = get_klines(symbol, interval, klines_limit)
@@ -668,9 +654,9 @@ def _build_signal_df(symbol, interval, klines_limit=200):
def _alert_loop():
while True:
try:
with _alert_lock:
symbol = _alert_symbol
interval = _alert_interval
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:
@@ -757,24 +743,20 @@ def send_daily_report(symbol="BTCUSDT"):
msg_2x = _build_daily_report_lines(dfs, cutoff_kst, now_kst, symbol, offset=2, header_suffix="2배 시간 (2번째 봉 검증)")
send_telegram(msg_2x)
if "_last_report_date" not in globals():
_last_report_date = None
def _daily_report_loop():
global _last_report_date
while True:
try:
now_kst = (datetime.now(timezone.utc) + KST).replace(tzinfo=None)
today_str = now_kst.strftime("%Y-%m-%d")
if _last_report_date is None:
_last_report_date = today_str
if alert_state.last_report_date is None:
alert_state.last_report_date = today_str
print(f"[일일리포트] 스레드 기동 -- 다음 자정({today_str} 24:00 KST) 까지 대기")
elif _last_report_date != today_str:
elif alert_state.last_report_date != today_str:
print(f"[일일리포트] 자정 통과 감지 -> 발송 ({today_str})")
with _alert_lock:
symbol = _alert_symbol
with alert_state.alert_lock:
symbol = alert_state.alert_symbol
send_daily_report(symbol)
_last_report_date = today_str
alert_state.last_report_date = today_str
except Exception as e:
print(f"[일일리포트 스레드 오류] {e}")
time.sleep(60)
@@ -783,17 +765,15 @@ def _daily_report_loop():
# 메인 UI
# ──────────────────────────────────────────────
def main():
global _alert_started, _alert_symbol, _alert_interval, _daily_report_started
if not _alert_started:
if not alert_state.alert_started:
t = threading.Thread(target=_alert_loop, daemon=True)
t.start()
_alert_started = True
alert_state.alert_started = True
if not _daily_report_started:
if not alert_state.daily_report_started:
dr = threading.Thread(target=_daily_report_loop, daemon=True)
dr.start()
_daily_report_started = True
alert_state.daily_report_started = True
col1, col2, col3, col4, col5 = st.columns([2, 1, 1, 1, 2])
with col1:
@@ -819,9 +799,9 @@ def main():
candle_limit = 53 if mobile_mode else 200
with _alert_lock:
_alert_symbol = symbol
_alert_interval = interval
with alert_state.alert_lock:
alert_state.alert_symbol = symbol
alert_state.alert_interval = interval
try:
with st.spinner("데이터 로딩 중..."):