diff --git a/app_streamlit.py b/app_streamlit.py index 26086c0..810aaf6 100644 --- a/app_streamlit.py +++ b/app_streamlit.py @@ -86,8 +86,8 @@ SIG_DEFS = [ ("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"), + ("reversal_long_signal", "rev_long", "๐Ÿ”„ ๋กฑ ์ถ”์„ธ ๊บพ์ž„ ๊ฐ์ง€", "long"), + ("reversal_short_signal","rev_short", "๐Ÿ”„ ์ˆ ์ถ”์„ธ ๊บพ์ž„ ๊ฐ์ง€", "short"), ("short_caution_signal", "short_caution","โš ๏ธ ์ˆ ์ฃผ์˜", "caution"), ] @@ -126,14 +126,11 @@ def check_and_alert(df, symbol, interval): alert_state.short_entry[interval] = None alert_state.pending_groups = new_pending - # Phase 2 โ€” ์‹ ํ˜ธ ๊ฒ€์‚ฌ + ์•Œ๋ฆผ ๋ฐœ์‚ฌ. - # 5๋ถ„๋ด‰: forming candle ํฌํ•จ (๋ฐ˜์‘์„ฑ ์šฐ์„ , ๊นœ๋นก์ž„์€ a9ad52f ์˜ ์ฆ‰์‹œ ์ทจ์†Œ๋กœ ๋Œ€์‘) - # 15m / 30m / 1h: forming candle ์ œ์™ธ (forming ๋™์•ˆ ์‹ ํ˜ธ ๊นœ๋นก์œผ๋กœ ์ธํ•œ ๊ฐ€์งœ - # ์•Œ๋ฆผ ์ž์ฒด๋ฅผ ์ฐจ๋‹จ. ์•Œ๋ฆผ์€ ์บ”๋“ค ๋งˆ๊ฐ ํ›„ ~30์ดˆ ์ด๋‚ด ๋ฐœ์‚ฌ โ€” ์‹ ๋ขฐ์„ฑ ์šฐ์„ ) - if interval in ("15m", "30m", "1h"): - recent = df.iloc[:-1].tail(3) - else: - recent = df.tail(3) + # Phase 2 โ€” ์‹ ํ˜ธ ๊ฒ€์‚ฌ + ์•Œ๋ฆผ ๋ฐœ์‚ฌ (๋ชจ๋“  TF forming candle ํฌํ•จ). + # 30์ดˆ polling ์œผ๋กœ ๋งค ์‚ฌ์ดํด๋งˆ๋‹ค forming candle ์˜ ์‹ ํ˜ธ ์ƒํƒœ ์žฌ๊ฒ€์ฆ โ†’ + # ์‹ ํ˜ธ ์‚ฌ๋ผ์ง€๋ฉด ์ฆ‰์‹œ [์ทจ์†Œ ์•Œ๋ฆผ] ๋ฐœ์‚ฌ (Phase 1 ๋กœ์ง). 5m=2.5m, 15m=7.5m, + # 30m=15m, 1h=30m ์˜ ์ ˆ๋ฐ˜ ์‹œ๊ฐ„๋ณด๋‹ค ํ›จ์”ฌ ๋น ๋ฅธ ๊ฒ€์ฆ ์ฃผ๊ธฐ. + recent = df.tail(3) eligible = [] for sig, key, sub_label, direction in SIG_DEFS: @@ -188,6 +185,18 @@ def check_and_alert(df, symbol, interval): f"์†์ ˆ๊ฐ€: {stop_price:,.2f}" ) entry_record = {"price": entry_price, "stop": stop_price, "open_time": candle_time, "entry_msg": msg} + # ๋ฐ˜๋Œ€ ๋ฐฉํ–ฅ ํ™œ์„ฑ ์ง„์ž…์ด ์žˆ์œผ๋ฉด ์ฒญ์‚ฐ ๊ถŒ๊ณ  + ์ถ”์  ํ•ด์ œ (๋ชจ๋“  TF ๋Œ€์ƒ) + opposite_dict = alert_state.short_entry if direction == "long" else alert_state.long_entry + opposite_label = "์ˆ" if direction == "long" else "๋กฑ" + for opp_interval, opp_rec in list(opposite_dict.items()): + if opp_rec is None: + continue + send_telegram( + f"[๋ฐ˜๋Œ€ ์‹ ํ˜ธ ๊ฐ์ง€ - {opposite_label} ์ฒญ์‚ฐ ๊ถŒ์žฅ]\n" + f"--- ๊ธฐ์กด ์ง„์ž… ---\n{opp_rec['entry_msg']}\n" + f"--- ๋ฐ˜๋Œ€ ์‹ ํ˜ธ ---\n{msg}" + ) + opposite_dict[opp_interval] = None if direction == "long": alert_state.long_entry[interval] = entry_record else: @@ -387,22 +396,22 @@ def compute_signals(df, interval="5m"): else: df["short_caution_signal"] = False - # ๋ฐฉํ–ฅ ์ „ํ™˜ ๊ฐ์ง€: ์ง์ „ 5๋ด‰์˜ ์ถ”์„ธ ๋ฐฉํ–ฅ๊ณผ ํ˜„์žฌ ์บ”๋“ค ๋ฐฉํ–ฅ์ด ๋ฐ˜๋Œ€ + ๊ฐ•ํ•œ ํญ + ๊ฑฐ๋ž˜๋Ÿ‰ ๋™๋ฐ˜. - # - ์ถ”์„ธ ํŒ๋‹จ: close[t-1] vs close[t-5] (ํ˜„์žฌ ์บ”๋“ค ์ œ์™ธ, ์ง์ „๊นŒ์ง€์˜ ํ๋ฆ„) + # ์ถ”์„ธ ๊บพ์ž„ ๊ฐ์ง€: ์ง์ „ 3๋ด‰์˜ ์ถ”์„ธ ๋ฐฉํ–ฅ๊ณผ ํ˜„์žฌ ์บ”๋“ค ๋ฐฉํ–ฅ์ด ๋ฐ˜๋Œ€ + ๊ฐ•ํ•œ ํญ + ๊ฑฐ๋ž˜๋Ÿ‰ ๋™๋ฐ˜. + # - ์ถ”์„ธ ํŒ๋‹จ: close[t-1] vs close[t-3] (ํ˜„์žฌ ์บ”๋“ค ์ œ์™ธ, ์ง์ „๊นŒ์ง€์˜ ํ๋ฆ„) # - ํ˜„์žฌ ์บ”๋“ค ๊ฐ•๋„: |close-open|/open >= 0.3% (์ž‘์€ ์บ”๋“ค ๋…ธ์ด์ฆˆ ์ฐจ๋‹จ) - # - ๊ฑฐ๋ž˜๋Ÿ‰: ์ง์ „ 5๋ด‰ ํ‰๊ท ์˜ 1.3๋ฐฐ ์ด์ƒ (ํ™•์‹ ) + # - ๊ฑฐ๋ž˜๋Ÿ‰: ์ง์ „ 3๋ด‰ ํ‰๊ท ์˜ 1.3๋ฐฐ ์ด์ƒ (ํ™•์‹ ) + # - ์ฟจ๋‹ค์šด: 3๋ด‰ prior_close = df["close"].shift(1) - prior_close_5 = df["close"].shift(5) - was_up = prior_close > prior_close_5 - was_down = prior_close < prior_close_5 + 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_avg5 = df["volume"].rolling(5).mean().shift(1) - vol_strong = df["volume"] > vol_avg5 * 1.3 + vol_avg3 = df["volume"].rolling(3).mean().shift(1) + vol_strong = df["volume"] > vol_avg3 * 1.3 rev_short_raw = was_up & (candle_body_pct < -0.003) & vol_strong rev_long_raw = was_down & (candle_body_pct > 0.003) & vol_strong - # 5๋ด‰ ์ฟจ๋‹ค์šด (์—ฐ์‡„ ๋ฐœํ™” ๋ฐฉ์ง€) - df["reversal_short_signal"] = rev_short_raw & (rev_short_raw.rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0) - df["reversal_long_signal"] = rev_long_raw & (rev_long_raw.rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0) + 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 @@ -871,6 +880,59 @@ def _build_stop_touch_lines(dfs, cutoff_kst, now_kst, symbol): lines.append(f"ํ•ฉ๊ณ„: {touch_all}/{total_all} (ํ„ฐ์น˜์œจ {rate:.2f}%)") return "\n".join(lines) +def _count_reversal_outcomes(df, cutoff_kst, lookahead=3): + """์ถ”์„ธ ๊บพ์ž„ ์‹ ํ˜ธ์˜ lookahead ๋ด‰ ํ›„ ๋ฐฉํ–ฅ ์ผ์น˜ ์นด์šดํŠธ. + - reversal_long: close[i+lookahead] > close[i] -> T (์ƒ์Šน ์ง€์†) + - reversal_short: close[i+lookahead] < close[i] -> T (ํ•˜๋ฝ ์ง€์†) + ๋ฐ˜ํ™˜: {sig_name: [total, failed]} + """ + result = {"reversal_long_signal": [0, 0], "reversal_short_signal": [0, 0]} + if df is None or df.empty or "open_time" not in df.columns: + return result + recent = df[df["open_time"] >= cutoff_kst].reset_index(drop=True) + if len(recent) <= lookahead: + return result + for sig in ("reversal_long_signal", "reversal_short_signal"): + if sig not in recent.columns: + continue + for i in range(len(recent) - lookahead): + row = recent.iloc[i] + if not bool(row.get(sig, False)): + continue + future = recent.iloc[i + lookahead] + entry_close = float(row["close"]) + future_close = float(future["close"]) + confirmed = (sig == "reversal_long_signal" and future_close > entry_close) or \ + (sig == "reversal_short_signal" and future_close < entry_close) + result[sig][0] += 1 + if not confirmed: + result[sig][1] += 1 + return result + +def _build_reversal_lines(dfs, cutoff_kst, now_kst, symbol): + lines = [ + f"๐Ÿ“Š ์ถ”์„ธ ๊บพ์ž„ ๊ฐ์ง€ ํ†ต๊ณ„ ({symbol})", + f"๊ธฐ์ค€: {now_kst.strftime('%Y-%m-%d %H:%M')} KST (3๋ด‰ ํ›„ ๋ฐฉํ–ฅ ์ผ์น˜)", + ] + for tf in DAILY_REPORT_TIMEFRAMES: + df = dfs.get(tf) + counts = _count_reversal_outcomes(df, cutoff_kst, lookahead=3) + lines.append("") + lines.append(f"[{TF_LABEL_MAP.get(tf, tf)}]") + total_all = 0 + failed_all = 0 + for sig, lbl in [("reversal_long_signal", "๐Ÿ”„ ๋กฑ ์ถ”์„ธ ๊บพ์ž„ ๊ฐ์ง€"), + ("reversal_short_signal", "๐Ÿ”„ ์ˆ ์ถ”์„ธ ๊บพ์ž„ ๊ฐ์ง€")]: + t, f = counts[sig] + passed = t - f + lines.append(f"{lbl}: {passed}T {f}F") + total_all += t + failed_all += f + passed_all = total_all - failed_all + rate = (passed_all / total_all * 100) if total_all > 0 else 0.0 + lines.append(f"ํ•ฉ๊ณ„: {passed_all}T {failed_all}F (์Šน๋ฅ  {rate:.2f}%)") + return "\n".join(lines) + def send_daily_report(symbol="BTCUSDT"): now_kst = (datetime.now(timezone.utc) + KST).replace(tzinfo=None) cutoff_kst = now_kst - timedelta(hours=24) @@ -887,6 +949,8 @@ def send_daily_report(symbol="BTCUSDT"): send_telegram(msg_2x) msg_touch = _build_stop_touch_lines(dfs, cutoff_kst, now_kst, symbol) send_telegram(msg_touch) + msg_rev = _build_reversal_lines(dfs, cutoff_kst, now_kst, symbol) + send_telegram(msg_rev) def _daily_report_loop(): while True: