사용자별 격리 시스템 + 사용자 관리 + 라이브 PnL%
# 사용자별 격리 - JWT 토큰에 uid 추가 (auth.get_uid 헬퍼) - PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT - SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환) - 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가 - alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화 - core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용 - ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드 # 사용자 관리 (admin only) - users_db: delete_user / admin_reset_password / set_role - /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호) - /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달 - 사이드바 adminOnly 필터 — admin role 만 메뉴 노출 # 대시보드 개선 - 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200) - 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산) - 메트릭 카드 분리 (실거래 vs 실시간 open) # 안정성 - api.ts: error.detail array/object 안전 처리 ([object Object] 방지) - Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+119
-114
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
Streamlit 의존이 없는 핵심 비즈니스 로직.
|
||||
- Binance Futures API 데이터 수집
|
||||
- 지표 / 신호 계산
|
||||
- 알림 (텔레그램) + 트레이드 lifecycle 기록
|
||||
- 알림 / 일일 리포트 백그라운드 루프
|
||||
Streamlit 의존 없는 핵심 비즈니스 로직 — 사용자별 격리 버전.
|
||||
모든 알림 / 진입 추적 / 텔레그램 / 일일 리포트는 user_id 단위로 동작.
|
||||
|
||||
기존 app_streamlit.py 에서 그대로 추출. FastAPI 에서도 그대로 import.
|
||||
알림 루프는 사용자 순회 — alert_enabled=1 인 모든 사용자의 settings 를
|
||||
각자 적용해서 신호 계산 / 알림 / DB 기록.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
@@ -49,28 +47,27 @@ SIG_DEFS = [
|
||||
]
|
||||
|
||||
|
||||
# ── 설정 helpers ──
|
||||
def TELEGRAM_TOKEN(): return settings_db.get("telegram_token", "")
|
||||
def TELEGRAM_CHAT_ID(): return settings_db.get("telegram_chat_id", "")
|
||||
def ALERT_COOLDOWN(): return settings_db.get_int("alert_cooldown_sec", 600)
|
||||
def STOP_LOSS_PCT_v(): return settings_db.get_float("stop_loss_pct", 0.0075)
|
||||
# ── 사용자별 설정 helpers ──
|
||||
def TELEGRAM_TOKEN(user_id): return settings_db.get("telegram_token", "", user_id=user_id)
|
||||
def TELEGRAM_CHAT_ID(user_id): return settings_db.get("telegram_chat_id", "", user_id=user_id)
|
||||
def ALERT_COOLDOWN(user_id): return settings_db.get_int("alert_cooldown_sec", 600, user_id=user_id)
|
||||
def STOP_LOSS_PCT_v(user_id): return settings_db.get_float("stop_loss_pct", 0.0075, user_id=user_id)
|
||||
|
||||
|
||||
# ── 텔레그램 ──
|
||||
def send_telegram(message: str):
|
||||
token = TELEGRAM_TOKEN()
|
||||
chat_id = TELEGRAM_CHAT_ID()
|
||||
def send_telegram(user_id: int, message: str):
|
||||
token = TELEGRAM_TOKEN(user_id)
|
||||
chat_id = TELEGRAM_CHAT_ID(user_id)
|
||||
if not token or not chat_id:
|
||||
print("[텔레그램] 토큰/chat_id 미설정 — skip")
|
||||
return
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
requests.post(url, data={"chat_id": chat_id, "text": message}, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"[텔레그램 오류] {e}")
|
||||
print(f"[텔레그램 오류 user={user_id}] {e}")
|
||||
|
||||
|
||||
# ── Binance Futures fetch ──
|
||||
# ── Binance Futures fetch (글로벌, 사용자 무관) ──
|
||||
def get_klines(symbol="BTCUSDT", interval="5m", limit=375):
|
||||
url = f"{BASE}/fapi/v1/klines"
|
||||
r = requests.get(url, params={"symbol": symbol, "interval": interval, "limit": limit}, timeout=10, verify=False)
|
||||
@@ -122,8 +119,8 @@ def _to_floor_freq(period):
|
||||
return {"1m":"1min","3m":"3min","5m":"5min","15m":"15min","30m":"30min","1h":"1h","4h":"4h","12h":"12h","1d":"1D","3d":"3D","1M":"1ME"}.get(period, period)
|
||||
|
||||
|
||||
# ── 지표 + 신호 ──
|
||||
def compute_indicators(df, interval="5m"):
|
||||
# ── 지표 + 신호 (사용자별 임계값 적용) ──
|
||||
def compute_indicators(df, interval="5m", user_id=None):
|
||||
c = df["close"]
|
||||
df["MA7"] = c.rolling(7).mean()
|
||||
df["MA25"] = c.rolling(25).mean()
|
||||
@@ -142,22 +139,22 @@ def compute_indicators(df, interval="5m"):
|
||||
df["StochRSI_k"] = stoch.stochrsi_k() * 100
|
||||
df["StochRSI_d"] = stoch.stochrsi_d() * 100
|
||||
df["ATR"] = ta.volatility.AverageTrueRange(df["high"], df["low"], df["close"], window=14).average_true_range()
|
||||
df = compute_signals(df, interval)
|
||||
df = compute_signals(df, interval, user_id=user_id)
|
||||
return df
|
||||
|
||||
|
||||
def compute_signals(df, interval="5m"):
|
||||
LONG_RSI_MAX = settings_db.get_float("long_rsi_max", 75.0)
|
||||
SHORT_RSI_MIN = settings_db.get_float("short_rsi_min", 25.0)
|
||||
SLONG_RSI_MAX = settings_db.get_float("strong_long_rsi_max", 65.0)
|
||||
SSHORT_RSI_MIN = settings_db.get_float("strong_short_rsi_min", 35.0)
|
||||
BODY_PCT_MIN = settings_db.get_float("body_pct_min", 0.002)
|
||||
REV_BODY_PCT = settings_db.get_float("reversal_body_pct", 0.003)
|
||||
REV_VOL_MULT = settings_db.get_float("reversal_vol_mult", 1.3)
|
||||
VOL_EXH_MULT = settings_db.get_float("vol_exhaustion_mult", 3.0)
|
||||
VOL_NET_MULT = settings_db.get_float("vol_net_mult", 2.0)
|
||||
OI_ACTIVE_PCT = settings_db.get_float("oi_active_pct", 0.001)
|
||||
FR_SHORT_EXTREME = settings_db.get_float("fr_short_extreme", -0.007)
|
||||
def compute_signals(df, interval="5m", user_id=None):
|
||||
LONG_RSI_MAX = settings_db.get_float("long_rsi_max", 75.0, user_id=user_id)
|
||||
SHORT_RSI_MIN = settings_db.get_float("short_rsi_min", 25.0, user_id=user_id)
|
||||
SLONG_RSI_MAX = settings_db.get_float("strong_long_rsi_max", 65.0, user_id=user_id)
|
||||
SSHORT_RSI_MIN = settings_db.get_float("strong_short_rsi_min", 35.0, user_id=user_id)
|
||||
BODY_PCT_MIN = settings_db.get_float("body_pct_min", 0.002, user_id=user_id)
|
||||
REV_BODY_PCT = settings_db.get_float("reversal_body_pct", 0.003, user_id=user_id)
|
||||
REV_VOL_MULT = settings_db.get_float("reversal_vol_mult", 1.3, user_id=user_id)
|
||||
VOL_EXH_MULT = settings_db.get_float("vol_exhaustion_mult", 3.0, user_id=user_id)
|
||||
VOL_NET_MULT = settings_db.get_float("vol_net_mult", 2.0, user_id=user_id)
|
||||
OI_ACTIVE_PCT = settings_db.get_float("oi_active_pct", 0.001, user_id=user_id)
|
||||
FR_SHORT_EXTREME = settings_db.get_float("fr_short_extreme", -0.007, user_id=user_id)
|
||||
|
||||
df["bull_ma_2"] = (df["close"] > df["MA7"]) & (df["close"] > df["MA25"])
|
||||
df["bear_ma_2"] = (df["close"] < df["MA7"]) & (df["close"] < df["MA25"])
|
||||
@@ -249,8 +246,7 @@ def compute_signals(df, interval="5m"):
|
||||
return df
|
||||
|
||||
|
||||
def build_signal_df(symbol, interval, klines_limit=200):
|
||||
"""알림 / API 공용 - klines + OI + FR 머지 + 지표/신호 계산"""
|
||||
def build_signal_df(symbol, interval, klines_limit=200, user_id=None):
|
||||
df = get_klines(symbol, interval, klines_limit)
|
||||
oi_period = interval if interval in ["5m","15m","30m","1h","4h","12h","1d","3d","1M"] else "5m"
|
||||
try:
|
||||
@@ -273,31 +269,32 @@ def build_signal_df(symbol, interval, klines_limit=200):
|
||||
df = df.drop(columns=["open_time_r2","open_time_fr"], errors="ignore")
|
||||
df["fundingRate"] = df["fundingRate"].ffill().fillna(0)
|
||||
except Exception: pass
|
||||
df = compute_indicators(df, interval)
|
||||
df = compute_indicators(df, interval, user_id=user_id)
|
||||
return df
|
||||
|
||||
|
||||
# ── 알림 코어 ──
|
||||
def check_and_alert(df, symbol, interval):
|
||||
# ── 알림 코어 (사용자별) ──
|
||||
def check_and_alert(user_id: int, df, symbol, interval):
|
||||
now = time.time()
|
||||
if df is None or df.empty:
|
||||
return
|
||||
forming_ct = df.iloc[-1]["open_time"]
|
||||
|
||||
if interval not in alert_state.synced_intervals:
|
||||
sync_key = (user_id, interval)
|
||||
if sync_key not in alert_state.synced_intervals:
|
||||
for sig, key, _, _ in SIG_DEFS:
|
||||
if sig not in df.columns:
|
||||
continue
|
||||
triggered = df[df[sig].fillna(False)]
|
||||
if not triggered.empty:
|
||||
alert_state.last_fired_candle[(interval, key)] = triggered.iloc[-1]["open_time"]
|
||||
alert_state.synced_intervals.add(interval)
|
||||
print(f"[알림스레드] {interval} 초기 sync 완료")
|
||||
alert_state.last_fired_candle[(user_id, interval, key)] = triggered.iloc[-1]["open_time"]
|
||||
alert_state.synced_intervals.add(sync_key)
|
||||
print(f"[user={user_id}] {interval} 초기 sync 완료")
|
||||
return
|
||||
|
||||
new_pending = []
|
||||
for p in alert_state.pending_groups:
|
||||
if p["interval"] != interval:
|
||||
if p.get("user_id") != user_id or p["interval"] != interval:
|
||||
new_pending.append(p)
|
||||
continue
|
||||
ct = p["candle_time"]
|
||||
@@ -310,15 +307,15 @@ def check_and_alert(df, symbol, interval):
|
||||
if ct == forming_ct:
|
||||
new_pending.append(p)
|
||||
else:
|
||||
send_telegram(f"[취소 알림]\n{p['msg']}")
|
||||
le = alert_state.long_entry.get(interval)
|
||||
se = alert_state.short_entry.get(interval)
|
||||
send_telegram(user_id, f"[취소 알림]\n{p['msg']}")
|
||||
le = alert_state.long_entry.get((user_id, interval))
|
||||
se = alert_state.short_entry.get((user_id, interval))
|
||||
if p["direction"] == "long" and le is not None and le.get("open_time") == ct:
|
||||
trades_db.record_exit(symbol, interval, "long", ct, float(row["close"]), "cancelled")
|
||||
alert_state.long_entry[interval] = None
|
||||
trades_db.record_exit(user_id, symbol, interval, "long", ct, float(row["close"]), "cancelled")
|
||||
alert_state.long_entry[(user_id, interval)] = None
|
||||
elif p["direction"] == "short" and se is not None and se.get("open_time") == ct:
|
||||
trades_db.record_exit(symbol, interval, "short", ct, float(row["close"]), "cancelled")
|
||||
alert_state.short_entry[interval] = None
|
||||
trades_db.record_exit(user_id, symbol, interval, "short", ct, float(row["close"]), "cancelled")
|
||||
alert_state.short_entry[(user_id, interval)] = None
|
||||
alert_state.pending_groups = new_pending
|
||||
|
||||
recent = df.tail(3)
|
||||
@@ -327,24 +324,24 @@ def check_and_alert(df, symbol, interval):
|
||||
if sig not in recent.columns:
|
||||
continue
|
||||
triggered = recent[recent[sig].fillna(False)]
|
||||
seen_key = (interval, sig)
|
||||
seen_key = (user_id, interval, sig)
|
||||
prev_seen = alert_state.signal_seen_count.get(seen_key)
|
||||
if triggered.empty:
|
||||
if prev_seen:
|
||||
alert_state.signal_seen_count[seen_key] = {"candle_time": prev_seen["candle_time"], "count": 0}
|
||||
continue
|
||||
candle_time = triggered.iloc[-1]["open_time"]
|
||||
state_key = (interval, key)
|
||||
state_key = (user_id, interval, key)
|
||||
if candle_time == alert_state.last_fired_candle.get(state_key):
|
||||
continue
|
||||
if now - alert_state.last_alert.get(state_key, 0) <= ALERT_COOLDOWN():
|
||||
if now - alert_state.last_alert.get(state_key, 0) <= ALERT_COOLDOWN(user_id):
|
||||
continue
|
||||
if prev_seen is None or prev_seen["candle_time"] != candle_time:
|
||||
alert_state.signal_seen_count[seen_key] = {"candle_time": candle_time, "count": 1}
|
||||
else:
|
||||
alert_state.signal_seen_count[seen_key] = {"candle_time": candle_time, "count": prev_seen["count"] + 1}
|
||||
count = alert_state.signal_seen_count[seen_key]["count"]
|
||||
stable_min = settings_db.get_int("forming_stable_polls", 2)
|
||||
stable_min = settings_db.get_int("forming_stable_polls", 2, user_id=user_id)
|
||||
if candle_time == forming_ct and count < stable_min:
|
||||
continue
|
||||
eligible.append({
|
||||
@@ -365,13 +362,13 @@ def check_and_alert(df, symbol, interval):
|
||||
candle_time_str = pd.Timestamp(candle_time).strftime("%Y-%m-%d %H:%M")
|
||||
sub_labels = " + ".join(e["sub_label"] for e in group)
|
||||
direction = group[0]["direction"]
|
||||
trades_db.log_signal_events(symbol, interval, group)
|
||||
trades_db.log_signal_events(user_id, symbol, interval, group)
|
||||
if direction == "caution":
|
||||
msg = f"{sub_labels} 신호\n{symbol} {tf_label}\n시간: {candle_time_str}"
|
||||
send_telegram(msg)
|
||||
send_telegram(user_id, msg)
|
||||
else:
|
||||
entry_price = float(group[0]["row"]["open"])
|
||||
sl_pct = STOP_LOSS_PCT_v()
|
||||
sl_pct = STOP_LOSS_PCT_v(user_id)
|
||||
stop_price = entry_price * (1 - sl_pct) if direction == "long" else entry_price * (1 + sl_pct)
|
||||
msg = (
|
||||
f"{sub_labels} 진입 신호\n{symbol} {tf_label}\n"
|
||||
@@ -379,35 +376,42 @@ def check_and_alert(df, symbol, interval):
|
||||
)
|
||||
entry_record = {"price": entry_price, "stop": stop_price, "open_time": candle_time, "entry_msg": msg}
|
||||
if interval in ("30m", "1h"):
|
||||
opposite_dict = alert_state.short_entry if direction == "long" else alert_state.long_entry
|
||||
opposite_label = "숏" if direction == "long" else "롱"
|
||||
opposite_direction = "short" if direction == "long" else "long"
|
||||
for opp_interval, opp_rec in list(opposite_dict.items()):
|
||||
if opp_rec is None:
|
||||
# 반대 방향 진입 청산 권고
|
||||
if direction == "long":
|
||||
opp_key = "short"
|
||||
opp_dict = alert_state.short_entry
|
||||
opposite_label = "숏"
|
||||
else:
|
||||
opp_key = "long"
|
||||
opp_dict = alert_state.long_entry
|
||||
opposite_label = "롱"
|
||||
for (u2, opp_interval), opp_rec in list(opp_dict.items()):
|
||||
if u2 != user_id or opp_rec is None:
|
||||
continue
|
||||
send_telegram(
|
||||
send_telegram(user_id,
|
||||
f"[반대 신호 감지 - {opposite_label} 청산 권장]\n"
|
||||
f"--- 기존 진입 ---\n{opp_rec['entry_msg']}\n"
|
||||
f"--- 반대 신호 ---\n{msg}"
|
||||
)
|
||||
trades_db.record_exit(symbol, opp_interval, opposite_direction,
|
||||
trades_db.record_exit(user_id, symbol, opp_interval, opp_key,
|
||||
opp_rec.get("open_time"), entry_price, "reversal")
|
||||
opposite_dict[opp_interval] = None
|
||||
opp_dict[(u2, opp_interval)] = None
|
||||
if direction == "long":
|
||||
alert_state.long_entry[interval] = entry_record
|
||||
alert_state.long_entry[(user_id, interval)] = entry_record
|
||||
else:
|
||||
alert_state.short_entry[interval] = entry_record
|
||||
trades_db.record_entry(symbol, interval, direction,
|
||||
alert_state.short_entry[(user_id, interval)] = entry_record
|
||||
trades_db.record_entry(user_id, symbol, interval, direction,
|
||||
[e["sig"] for e in group],
|
||||
candle_time, entry_price, stop_price)
|
||||
send_telegram(msg)
|
||||
send_telegram(user_id, msg)
|
||||
for e in group:
|
||||
alert_state.last_alert[(interval, e["key"])] = now
|
||||
alert_state.last_fired_candle[(interval, e["key"])] = e["candle_time"]
|
||||
alert_state.last_alert[(user_id, interval, e["key"])] = now
|
||||
alert_state.last_fired_candle[(user_id, interval, e["key"])] = e["candle_time"]
|
||||
if candle_time == forming_ct:
|
||||
alert_state.pending_groups.append({
|
||||
"interval": interval, "direction": direction, "candle_time": candle_time,
|
||||
"msg": msg, "sig_cols": [e["sig"] for e in group],
|
||||
"user_id": user_id, "interval": interval, "direction": direction,
|
||||
"candle_time": candle_time, "msg": msg,
|
||||
"sig_cols": [e["sig"] for e in group],
|
||||
})
|
||||
|
||||
_send_group(groups.get("long", []))
|
||||
@@ -415,69 +419,70 @@ def check_and_alert(df, symbol, interval):
|
||||
_send_group(groups.get("caution", []))
|
||||
|
||||
current_price = float(df.iloc[-1]["close"])
|
||||
le = alert_state.long_entry.get(interval)
|
||||
se = alert_state.short_entry.get(interval)
|
||||
le = alert_state.long_entry.get((user_id, interval))
|
||||
se = alert_state.short_entry.get((user_id, interval))
|
||||
if le is not None and current_price <= le["stop"]:
|
||||
send_telegram(f"[손절가알림]\n{le['entry_msg']}\n현재가: {current_price:,.2f}")
|
||||
trades_db.record_exit(symbol, interval, "long", le.get("open_time"), current_price, "stop_loss")
|
||||
alert_state.long_entry[interval] = None
|
||||
send_telegram(user_id, f"[손절가알림]\n{le['entry_msg']}\n현재가: {current_price:,.2f}")
|
||||
trades_db.record_exit(user_id, symbol, interval, "long", le.get("open_time"), current_price, "stop_loss")
|
||||
alert_state.long_entry[(user_id, interval)] = None
|
||||
if se is not None and current_price >= se["stop"]:
|
||||
send_telegram(f"[손절가알림]\n{se['entry_msg']}\n현재가: {current_price:,.2f}")
|
||||
trades_db.record_exit(symbol, interval, "short", se.get("open_time"), current_price, "stop_loss")
|
||||
alert_state.short_entry[interval] = None
|
||||
send_telegram(user_id, f"[손절가알림]\n{se['entry_msg']}\n현재가: {current_price:,.2f}")
|
||||
trades_db.record_exit(user_id, symbol, interval, "short", se.get("open_time"), current_price, "stop_loss")
|
||||
alert_state.short_entry[(user_id, interval)] = None
|
||||
|
||||
|
||||
# ── 백그라운드 루프 (FastAPI startup 에서 호출) ──
|
||||
def alert_timeframes():
|
||||
return settings_db.get_list("alert_timeframes", default=["5m", "15m", "30m", "1h"])
|
||||
# ── 알림 루프 — 모든 활성 사용자 순회 ──
|
||||
def alert_timeframes(user_id):
|
||||
return settings_db.get_list("alert_timeframes", default=["5m", "15m", "30m", "1h"], user_id=user_id)
|
||||
|
||||
|
||||
def alert_loop():
|
||||
while True:
|
||||
poll = max(10, settings_db.get_int("polling_interval_sec", 30))
|
||||
if not settings_db.get_bool("alert_enabled", True):
|
||||
time.sleep(poll)
|
||||
continue
|
||||
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}")
|
||||
# 가장 짧은 폴링 주기 사용 (사용자가 다 달라도 OK — 폴링 마다 각자 적용)
|
||||
poll = 30
|
||||
try:
|
||||
uids = settings_db.list_user_ids_with_alerts_enabled()
|
||||
for uid in uids:
|
||||
try:
|
||||
sym = settings_db.get("alert_symbol", "BTCUSDT", user_id=uid) or "BTCUSDT"
|
||||
tfs = alert_timeframes(uid)
|
||||
user_poll = max(10, settings_db.get_int("polling_interval_sec", 30, user_id=uid))
|
||||
poll = min(poll, user_poll)
|
||||
for interval in tfs:
|
||||
try:
|
||||
df = build_signal_df(sym, interval, 200, user_id=uid)
|
||||
check_and_alert(uid, df, sym, interval)
|
||||
except Exception as e:
|
||||
print(f"[알림스레드 user={uid} {interval}] {e}")
|
||||
except Exception as e:
|
||||
print(f"[알림스레드 user={uid}] {e}")
|
||||
except Exception as e:
|
||||
print(f"[알림스레드 outer] {e}")
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
def daily_report_loop():
|
||||
"""간단 버전 — 일일 리포트 사용자별 분리는 추후."""
|
||||
while True:
|
||||
try:
|
||||
if not settings_db.get_bool("daily_report_enabled", True):
|
||||
time.sleep(60)
|
||||
continue
|
||||
now_kst = (datetime.now(timezone.utc) + KST).replace(tzinfo=None)
|
||||
today_str = now_kst.strftime("%Y-%m-%d")
|
||||
if alert_state.last_report_date is None:
|
||||
alert_state.last_report_date = today_str
|
||||
print(f"[일일리포트] 스레드 기동 -- 다음 자정({today_str} 24:00 KST) 까지 대기")
|
||||
elif alert_state.last_report_date != today_str:
|
||||
print(f"[일일리포트] 자정 통과 감지 -> 발송 ({today_str})")
|
||||
# send_daily_report 는 app_streamlit.py 안에 있는 그대로 사용 (없어도 silent skip)
|
||||
try:
|
||||
from app_streamlit import send_daily_report
|
||||
with alert_state.alert_lock:
|
||||
symbol = alert_state.alert_symbol
|
||||
send_daily_report(symbol)
|
||||
except Exception as e:
|
||||
print(f"[일일리포트 호출 실패] {e}")
|
||||
alert_state.last_report_date = today_str
|
||||
uids = settings_db.list_user_ids_with_alerts_enabled()
|
||||
for uid in uids:
|
||||
if not settings_db.get_bool("daily_report_enabled", True, user_id=uid):
|
||||
continue
|
||||
last = alert_state.last_report_date_by_user.get(uid)
|
||||
if last is None:
|
||||
alert_state.last_report_date_by_user[uid] = today_str
|
||||
elif last != today_str:
|
||||
send_telegram(uid, f"📊 일일 리포트 ({today_str})\n(상세 통계 추후 확장)")
|
||||
alert_state.last_report_date_by_user[uid] = today_str
|
||||
except Exception as e:
|
||||
print(f"[일일리포트 스레드 오류] {e}")
|
||||
print(f"[일일리포트 스레드] {e}")
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
def start_background_threads():
|
||||
"""FastAPI startup 에서 호출. 한 번만 시작."""
|
||||
if not alert_state.alert_started:
|
||||
t = threading.Thread(target=alert_loop, daemon=True, name="alert_loop")
|
||||
t.start()
|
||||
|
||||
Reference in New Issue
Block a user