알림 상태를 별도 모듈로 분리해 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:
@@ -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
@@ -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("데이터 로딩 중..."):
|
||||
|
||||
Reference in New Issue
Block a user