d16456cb92
# 사용자별 격리 - 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>
494 lines
24 KiB
Python
494 lines
24 KiB
Python
"""
|
|
Streamlit 의존 없는 핵심 비즈니스 로직 — 사용자별 격리 버전.
|
|
모든 알림 / 진입 추적 / 텔레그램 / 일일 리포트는 user_id 단위로 동작.
|
|
|
|
알림 루프는 사용자 순회 — alert_enabled=1 인 모든 사용자의 settings 를
|
|
각자 적용해서 신호 계산 / 알림 / DB 기록.
|
|
"""
|
|
import os
|
|
import time
|
|
import threading
|
|
import requests
|
|
import pandas as pd
|
|
import numpy as np
|
|
from datetime import datetime, timezone, timedelta
|
|
import ta
|
|
import urllib3
|
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
|
|
import alert_state
|
|
import settings_db
|
|
import trades_db
|
|
|
|
|
|
BASE = "https://fapi.binance.com"
|
|
KST = timedelta(hours=9)
|
|
STOP_LOSS_PCT = 0.0075
|
|
|
|
LONG_SIGNALS = {"strong_long_signal", "long_signal", "vol_long_signal", "reversal_long_signal"}
|
|
SHORT_SIGNALS = {"strong_short_signal", "short_signal", "vol_short_signal", "reversal_short_signal"}
|
|
TF_LABEL_MAP = {
|
|
"1m": "1분봉", "3m": "3분봉", "5m": "5분봉",
|
|
"15m": "15분봉", "30m": "30분봉",
|
|
"1h": "1시간봉", "4h": "4시간봉", "12h": "12시간봉",
|
|
"1d": "1일봉", "3d": "3일봉", "1M": "1개월봉",
|
|
}
|
|
|
|
SIG_DEFS = [
|
|
("strong_long_signal", "strong_long", "🟢 강한 롱", "long"),
|
|
("strong_short_signal", "strong_short", "🔴 강한 숏", "short"),
|
|
("long_signal", "long", "🔼 일반 롱", "long"),
|
|
("short_signal", "short", "🔽 일반 숏", "short"),
|
|
("vol_long_signal", "vol_long", "🔼 볼륨 롱", "long"),
|
|
("vol_short_signal", "vol_short", "🔽 볼륨 숏", "short"),
|
|
("reversal_long_signal", "rev_long", "🔄 롱 추세 꺾임 감지", "long"),
|
|
("reversal_short_signal","rev_short", "🔄 숏 추세 꺾임 감지", "short"),
|
|
("short_caution_signal", "short_caution","⚠️ 숏 주의", "caution"),
|
|
]
|
|
|
|
|
|
# ── 사용자별 설정 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(user_id: int, message: str):
|
|
token = TELEGRAM_TOKEN(user_id)
|
|
chat_id = TELEGRAM_CHAT_ID(user_id)
|
|
if not token or not chat_id:
|
|
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"[텔레그램 오류 user={user_id}] {e}")
|
|
|
|
|
|
# ── 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)
|
|
df = pd.DataFrame(r.json(), columns=[
|
|
"open_time","open","high","low","close","volume",
|
|
"close_time","quote_vol","trades","taker_buy_vol","taker_sell_vol","ignore"
|
|
])
|
|
for c in ["open","high","low","close","volume","taker_buy_vol","taker_sell_vol"]:
|
|
df[c] = df[c].astype(float)
|
|
df["taker_sell_vol"] = df["volume"] - df["taker_buy_vol"]
|
|
df["open_time"] = pd.to_datetime(df["open_time"], unit="ms") + KST
|
|
return df
|
|
|
|
|
|
def get_funding_rate(symbol="BTCUSDT", limit=100):
|
|
url = f"{BASE}/fapi/v1/fundingRate"
|
|
r = requests.get(url, params={"symbol": symbol, "limit": limit}, timeout=10, verify=False)
|
|
df = pd.DataFrame(r.json())
|
|
if df.empty:
|
|
return df
|
|
df["fundingRate"] = df["fundingRate"].astype(float) * 100
|
|
df["fundingTime"] = pd.to_datetime(df["fundingTime"], unit="ms") + KST
|
|
return df
|
|
|
|
|
|
def get_open_interest_history(symbol="BTCUSDT", period="5m", limit=100):
|
|
url = f"{BASE}/futures/data/openInterestHist"
|
|
r = requests.get(url, params={"symbol": symbol, "period": period, "limit": limit}, timeout=10, verify=False)
|
|
df = pd.DataFrame(r.json())
|
|
if df.empty:
|
|
return df
|
|
df["sumOpenInterest"] = df["sumOpenInterest"].astype(float)
|
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") + KST
|
|
return df
|
|
|
|
|
|
def get_long_short_ratio(symbol="BTCUSDT", period="5m", limit=500):
|
|
url = f"{BASE}/futures/data/topLongShortPositionRatio"
|
|
r = requests.get(url, params={"symbol": symbol, "period": period, "limit": limit}, timeout=10, verify=False)
|
|
df = pd.DataFrame(r.json())
|
|
if df.empty:
|
|
return df
|
|
df["longShortRatio"] = df["longShortRatio"].astype(float)
|
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") + KST
|
|
return df
|
|
|
|
|
|
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", user_id=None):
|
|
c = df["close"]
|
|
df["MA7"] = c.rolling(7).mean()
|
|
df["MA25"] = c.rolling(25).mean()
|
|
df["MA99"] = c.rolling(99).mean()
|
|
df["MA200"] = c.rolling(200).mean()
|
|
df["BB_mid"] = c.rolling(20).mean()
|
|
df["BB_std"] = c.rolling(20).std()
|
|
df["BB_upper"] = df["BB_mid"] + 2 * df["BB_std"]
|
|
df["BB_lower"] = df["BB_mid"] - 2 * df["BB_std"]
|
|
df["RSI"] = ta.momentum.RSIIndicator(c, window=14).rsi()
|
|
macd = ta.trend.MACD(c, window_slow=26, window_fast=12, window_sign=9)
|
|
df["MACD"] = macd.macd()
|
|
df["MACD_signal"] = macd.macd_signal()
|
|
df["MACD_hist"] = macd.macd_diff()
|
|
stoch = ta.momentum.StochRSIIndicator(c, window=14, smooth1=3, smooth2=3)
|
|
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, user_id=user_id)
|
|
return df
|
|
|
|
|
|
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"])
|
|
df["bull_ma"] = (df["close"] > df["MA7"]) & (df["MA7"] > df["MA25"])
|
|
df["bear_ma"] = (df["close"] < df["MA7"]) & (df["MA7"] < df["MA25"])
|
|
bb_range = (df["BB_upper"] - df["BB_lower"]).replace(0, float("nan"))
|
|
df["bb_pos"] = (df["close"] - df["BB_lower"]) / bb_range
|
|
body_pct = (df["close"] - df["open"]) / df["open"].replace(0, float("nan"))
|
|
df["long_signal"] = df["bull_ma_2"] & (df["RSI"] < LONG_RSI_MAX) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & (df["close"] > df["BB_mid"]) & (body_pct >= BODY_PCT_MIN)
|
|
df["short_signal"] = df["bear_ma_2"] & (df["RSI"] > SHORT_RSI_MIN) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & (df["close"] < df["BB_mid"]) & (body_pct <= -BODY_PCT_MIN)
|
|
df["long_signal"] = df["long_signal"] & (df["long_signal"].rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0)
|
|
df["short_signal"] = df["short_signal"] & (df["short_signal"].rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0)
|
|
|
|
if "sumOpenInterest" in df.columns and df["sumOpenInterest"].notna().sum() > 5:
|
|
oi_series = df["sumOpenInterest"].ffill()
|
|
else:
|
|
oi_series = df["close"] * df["volume"]
|
|
df["oi_up"] = oi_series > oi_series.shift(1)
|
|
df["oi_down"] = oi_series < oi_series.shift(1)
|
|
df["oi_up_2"] = df["oi_up"] & df["oi_up"].shift(1).fillna(False)
|
|
df["oi_down_2"] = df["oi_down"] & df["oi_down"].shift(1).fillna(False)
|
|
df["oi_active"] = oi_series.pct_change().abs() > OI_ACTIVE_PCT
|
|
|
|
df["taker_buy_dom"] = df["taker_buy_vol"] > df["taker_sell_vol"]
|
|
df["taker_sell_dom"] = df["taker_sell_vol"] > df["taker_buy_vol"]
|
|
df["taker_buy_2"] = df["taker_buy_dom"] & df["taker_buy_dom"].shift(1).fillna(False)
|
|
df["taker_sell_2"] = df["taker_sell_dom"] & df["taker_sell_dom"].shift(1).fillna(False)
|
|
|
|
df["fr_long_favor"] = df["taker_buy_vol"].rolling(3).mean() > df["taker_sell_vol"].rolling(3).mean()
|
|
df["fr_short_favor"] = df["taker_sell_vol"].rolling(3).mean() > df["taker_buy_vol"].rolling(3).mean()
|
|
|
|
df["strong_long_signal"] = df["bull_ma_2"] & (df["RSI"] < SLONG_RSI_MAX) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & df["oi_up_2"] & df["taker_buy_2"] & df["fr_long_favor"] & (df["close"] > df["open"])
|
|
df["strong_short_signal"] = df["bear_ma_2"] & (df["RSI"] > SSHORT_RSI_MIN) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & df["oi_down_2"] & df["taker_sell_2"] & df["fr_short_favor"] & (df["close"] < df["open"])
|
|
df["strong_long_signal"] = df["strong_long_signal"] & (df["strong_long_signal"].rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0)
|
|
df["strong_short_signal"] = df["strong_short_signal"] & (df["strong_short_signal"].rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0)
|
|
|
|
vol_avg = df["volume"].rolling(10).mean()
|
|
spike = df["volume"] > vol_avg * VOL_EXH_MULT
|
|
buy_spike = spike & (df["taker_buy_vol"] > df["taker_sell_vol"])
|
|
sell_spike = spike & (df["taker_sell_vol"] > df["taker_buy_vol"])
|
|
df["exhaustion_short"] = buy_spike.shift(1).fillna(False)
|
|
df["exhaustion_long"] = sell_spike.shift(1).fillna(False)
|
|
|
|
_vol_min_map = {"1m": 33, "3m": 100, "5m": 100, "15m": 300, "30m": 600, "1h": 1200, "2h": 2400, "4h": 4800, "12h": 14400, "1d": 28800, "3d": 86400, "1M": 864000}
|
|
_vol_min = _vol_min_map.get(interval, 100)
|
|
|
|
df["sell_net"] = df["taker_sell_vol"] - df["taker_buy_vol"]
|
|
sell_net_avg = df["sell_net"].rolling(10).mean()
|
|
sell_spike_strong = (
|
|
(df["sell_net"] > sell_net_avg * VOL_NET_MULT) &
|
|
(df["sell_net"] > 0) &
|
|
(df["taker_sell_vol"] > _vol_min) &
|
|
df["oi_active"]
|
|
)
|
|
cooldown_vol_short = sell_spike_strong.rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0
|
|
df["vol_short_signal"] = sell_spike_strong & cooldown_vol_short
|
|
|
|
df["buy_net"] = df["taker_buy_vol"] - df["taker_sell_vol"]
|
|
buy_net_avg = df["buy_net"].rolling(10).mean()
|
|
buy_spike_strong = (
|
|
(df["buy_net"] > buy_net_avg * VOL_NET_MULT) &
|
|
(df["buy_net"] > 0) &
|
|
(df["taker_buy_vol"] > _vol_min) &
|
|
df["oi_active"]
|
|
)
|
|
cooldown_vol_long = buy_spike_strong.rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0
|
|
df["vol_long_signal"] = buy_spike_strong & cooldown_vol_long
|
|
|
|
if "fundingRate" in df.columns and "sumOpenInterest" in df.columns:
|
|
fr_extreme = df["fundingRate"] <= FR_SHORT_EXTREME
|
|
raw_signal = df["oi_down_2"] & fr_extreme
|
|
cooldown_mask = raw_signal.rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0
|
|
df["short_caution_signal"] = raw_signal & cooldown_mask
|
|
else:
|
|
df["short_caution_signal"] = False
|
|
|
|
prior_close = df["close"].shift(1)
|
|
prior_close_3 = df["close"].shift(3)
|
|
was_up = prior_close > prior_close_3
|
|
was_down = prior_close < prior_close_3
|
|
candle_body_pct = (df["close"] - df["open"]) / df["open"].replace(0, float("nan"))
|
|
vol_avg3 = df["volume"].rolling(3).mean().shift(1)
|
|
vol_strong = df["volume"] > vol_avg3 * REV_VOL_MULT
|
|
rev_short_raw = was_up & (candle_body_pct < -REV_BODY_PCT) & vol_strong
|
|
rev_long_raw = was_down & (candle_body_pct > REV_BODY_PCT) & vol_strong
|
|
df["reversal_short_signal"] = rev_short_raw & (rev_short_raw.rolling(3, min_periods=1).sum().shift(1).fillna(0) == 0)
|
|
df["reversal_long_signal"] = rev_long_raw & (rev_long_raw.rolling(3, min_periods=1).sum().shift(1).fillna(0) == 0)
|
|
|
|
return df
|
|
|
|
|
|
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:
|
|
oi = get_open_interest_history(symbol, oi_period, min(klines_limit, 500))
|
|
if not oi.empty:
|
|
oi_m = oi[["timestamp","sumOpenInterest"]].rename(columns={"timestamp":"open_time"})
|
|
df["open_time_r"] = df["open_time"].dt.floor(_to_floor_freq(oi_period))
|
|
oi_m["open_time"] = oi_m["open_time"].dt.floor(_to_floor_freq(oi_period))
|
|
df = df.merge(oi_m, left_on="open_time_r", right_on="open_time", how="left", suffixes=("","_oi"))
|
|
df = df.drop(columns=["open_time_r","open_time_oi"], errors="ignore")
|
|
df["sumOpenInterest"] = df["sumOpenInterest"].ffill()
|
|
except Exception: pass
|
|
try:
|
|
fr = get_funding_rate(symbol, 200)
|
|
if not fr.empty:
|
|
fr_m = fr[["fundingTime","fundingRate"]].rename(columns={"fundingTime":"open_time"})
|
|
fr_m["open_time"] = fr_m["open_time"].dt.floor(_to_floor_freq("1h"))
|
|
df["open_time_r2"] = df["open_time"].dt.floor(_to_floor_freq("1h"))
|
|
df = df.merge(fr_m, left_on="open_time_r2", right_on="open_time", how="left", suffixes=("","_fr"))
|
|
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, user_id=user_id)
|
|
return df
|
|
|
|
|
|
# ── 알림 코어 (사용자별) ──
|
|
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"]
|
|
|
|
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[(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.get("user_id") != user_id or p["interval"] != interval:
|
|
new_pending.append(p)
|
|
continue
|
|
ct = p["candle_time"]
|
|
row_match = df[df["open_time"] == ct]
|
|
if row_match.empty:
|
|
continue
|
|
row = row_match.iloc[0]
|
|
any_still_true = any(bool(row.get(s, False)) for s in p["sig_cols"])
|
|
if any_still_true:
|
|
if ct == forming_ct:
|
|
new_pending.append(p)
|
|
else:
|
|
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(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(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)
|
|
eligible = []
|
|
for sig, key, sub_label, direction in SIG_DEFS:
|
|
if sig not in recent.columns:
|
|
continue
|
|
triggered = recent[recent[sig].fillna(False)]
|
|
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 = (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(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, user_id=user_id)
|
|
if candle_time == forming_ct and count < stable_min:
|
|
continue
|
|
eligible.append({
|
|
"sig": sig, "key": key, "sub_label": sub_label,
|
|
"direction": direction, "candle_time": candle_time, "row": triggered.iloc[-1],
|
|
})
|
|
|
|
groups = {"long": [], "short": [], "caution": []}
|
|
for e in eligible:
|
|
groups[e["direction"]].append(e)
|
|
|
|
tf_label = TF_LABEL_MAP.get(interval, interval)
|
|
|
|
def _send_group(group):
|
|
if not group:
|
|
return
|
|
candle_time = group[0]["candle_time"]
|
|
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(user_id, symbol, interval, group)
|
|
if direction == "caution":
|
|
msg = f"{sub_labels} 신호\n{symbol} {tf_label}\n시간: {candle_time_str}"
|
|
send_telegram(user_id, msg)
|
|
else:
|
|
entry_price = float(group[0]["row"]["open"])
|
|
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"
|
|
f"시간: {candle_time_str}\n진입가: {entry_price:,.2f}\n손절가: {stop_price:,.2f}"
|
|
)
|
|
entry_record = {"price": entry_price, "stop": stop_price, "open_time": candle_time, "entry_msg": msg}
|
|
if interval in ("30m", "1h"):
|
|
# 반대 방향 진입 청산 권고
|
|
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(user_id,
|
|
f"[반대 신호 감지 - {opposite_label} 청산 권장]\n"
|
|
f"--- 기존 진입 ---\n{opp_rec['entry_msg']}\n"
|
|
f"--- 반대 신호 ---\n{msg}"
|
|
)
|
|
trades_db.record_exit(user_id, symbol, opp_interval, opp_key,
|
|
opp_rec.get("open_time"), entry_price, "reversal")
|
|
opp_dict[(u2, opp_interval)] = None
|
|
if direction == "long":
|
|
alert_state.long_entry[(user_id, interval)] = entry_record
|
|
else:
|
|
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(user_id, msg)
|
|
for e in group:
|
|
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({
|
|
"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", []))
|
|
_send_group(groups.get("short", []))
|
|
_send_group(groups.get("caution", []))
|
|
|
|
current_price = float(df.iloc[-1]["close"])
|
|
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(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(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
|
|
|
|
|
|
# ── 알림 루프 — 모든 활성 사용자 순회 ──
|
|
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:
|
|
# 가장 짧은 폴링 주기 사용 (사용자가 다 달라도 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:
|
|
now_kst = (datetime.now(timezone.utc) + KST).replace(tzinfo=None)
|
|
today_str = now_kst.strftime("%Y-%m-%d")
|
|
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}")
|
|
time.sleep(60)
|
|
|
|
|
|
def start_background_threads():
|
|
if not alert_state.alert_started:
|
|
t = threading.Thread(target=alert_loop, daemon=True, name="alert_loop")
|
|
t.start()
|
|
alert_state.alert_started = True
|
|
if not alert_state.daily_report_started:
|
|
dr = threading.Thread(target=daily_report_loop, daemon=True, name="daily_report_loop")
|
|
dr.start()
|
|
alert_state.daily_report_started = True
|