#!/usr/bin/env python3 """Стенд 5м-исследования: независимая реализация движка и утилиты. Здесь: * ресемпл 5м → 30м (полные бары по 12 свечей) для сверки с прод-движком TON-бота; * индикаторы Wilder (RSI/ADX/ATR) и полосы Боллинджера — считаются сами, без импорта прод-кода; * НЕЗАВИСИМАЯ реализация машины состояний MR (те же правила) — чтобы сверка движка ловила именно расхождения в состоянии, а не в правилах входа. Правила MR (совпадают с прод-модулем app/trading/mean_reversion.py): вход только в боковике (ADX <= adx_max), у внешней полосы (|bb_norm| >= bb_extreme) с подтверждением RSI, при ожидаемом движении до средней >= min_move_fee_mult × fee_rt, SL за полосой + sl_buffer_atr×ATR с флором sl_min_pct, TP1 = средняя, TP2 = противоположная полоса; один вход одновременно, окно исхода horizon_bars, кулдаун cooldown_bars, дневной лимит daily_max, после SL — пауза до возврата к средней; комиссия вычитается из результата сделки. """ from __future__ import annotations from dataclasses import dataclass import numpy as np import pandas as pd BARS_30M = 6 # 30м = 6 свечей по 5м (1ч = 12 баров) # ---------------------------------------------------------------- данные ---- def load_5m(path: str) -> pd.DataFrame: df = pd.read_csv(path, header=None, names=["timestamp", "open", "high", "low", "close", "volume"]) df = df.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True) return df def resample_30m(df5: pd.DataFrame) -> pd.DataFrame: """5м → 30м: берём только полные бары (ровно 12 свечей) — неполные отбрасываем.""" g = df5.groupby(df5["timestamp"] // (30 * 60 * 1000)) out = pd.DataFrame({ "timestamp": g["timestamp"].first(), "open": g["open"].first(), "high": g["high"].max(), "low": g["low"].min(), "close": g["close"].last(), "volume": g["volume"].sum(), "bars": g["close"].count(), }) return out[out["bars"] == BARS_30M].drop(columns=["bars"]).reset_index(drop=True) # ------------------------------------------------------------ индикаторы ---- def _wilder(x: pd.Series, period: int) -> pd.Series: return x.ewm(alpha=1.0 / period, adjust=False, min_periods=period).mean() def rsi(close: pd.Series, period: int = 14) -> pd.Series: diff = close.diff() up = diff.clip(lower=0.0) down = (-diff).clip(lower=0.0) rs = _wilder(up, period) / _wilder(down, period).replace(0.0, np.nan) return (100 - 100 / (1 + rs)).fillna(50.0) def atr(df: pd.DataFrame, period: int = 14) -> pd.Series: prev_close = df["close"].shift(1) tr = pd.concat([(df["high"] - df["low"]), (df["high"] - prev_close).abs(), (df["low"] - prev_close).abs()], axis=1).max(axis=1) return _wilder(tr, period) def adx(df: pd.DataFrame, period: int = 14) -> pd.Series: up_move = df["high"].diff() down_move = -df["low"].diff() plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0) minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0) tr = pd.concat([(df["high"] - df["low"]), (df["high"] - df["close"].shift(1)).abs(), (df["low"] - df["close"].shift(1)).abs()], axis=1).max(axis=1) atr_w = _wilder(tr, period) plus_di = 100 * _wilder(pd.Series(plus_dm, index=df.index), period) / atr_w minus_di = 100 * _wilder(pd.Series(minus_dm, index=df.index), period) / atr_w dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0.0, np.nan) return _wilder(dx, period).fillna(0.0) def bollinger(close: pd.Series, period: int = 20, std_mult: float = 2.0) -> pd.DataFrame: mid = close.rolling(period).mean() sd = close.rolling(period).std(ddof=0) half = (mid + std_mult * sd - mid).replace(0.0, np.nan) return pd.DataFrame({ "bb_middle": mid, "bb_upper": mid + std_mult * sd, "bb_lower": mid - std_mult * sd, "bb_norm": ((close - mid) / half).clip(-3.0, 3.0), }) def prepare_frame(df30: pd.DataFrame) -> pd.DataFrame: """Кадр с индикаторами в терминах прод-модуля (0-100 шкала rsi/adx).""" out = df30.copy() out["atr"] = atr(out) out["trend_rsi14"] = rsi(out["close"]) # 0-100 (>1.5 → прод не пересчитывает) out["trend_adx"] = adx(out) # 0-100 out = pd.concat([out, bollinger(out["close"])], axis=1) out["mr_rsi"] = out["trend_rsi14"] out["mr_adx"] = out["trend_adx"] return out # ---------------------------------------------------------------- движок ---- @dataclass class MRParamsStand: bb_period: int = 20 bb_std: float = 2.0 bb_extreme: float = 0.8 adx_max: float = 20.0 rsi_ob: float = 70.0 rsi_os: float = 30.0 sl_buffer_atr: float = 0.3 sl_min_pct: float = 0.5 min_move_fee_mult: float = 2.0 cooldown_bars: int = 8 daily_max: int = 2 horizon_bars: int = 8 def pnl_sl_first(action: str, entry: float, sl: float, tp1: float, highs, lows, close_end: float): """Исход сделки по пути (стоп проверяется ПЕРВЫМ). Возвращает (pnl_доля, направление рынка).""" hi = max(highs) if len(highs) else close_end lo = min(lows) if len(lows) else close_end if action == "LONG": if lo <= sl: return (sl - entry) / entry, -1 if hi >= tp1: return (tp1 - entry) / entry, 1 return (close_end - entry) / entry, (1 if close_end > entry else -1) else: if hi >= sl: return (entry - sl) / entry, 1 if lo <= tp1: return (entry - tp1) / entry, -1 return (entry - close_end) / entry, (-1 if close_end < entry else 1) def entry_signal(i: int, frame: pd.DataFrame, p: MRParamsStand, fee_rt: float): """Возвращает (action, entry, sl, tp1, tp2, reason). Чистая функция от бара i.""" row = frame.iloc[i] price, bb_norm = float(row["close"]), float(row["bb_norm"]) rsi_v, adx_v = float(row["mr_rsi"]), float(row["mr_adx"]) atr_v = float(row["atr"]) if pd.notna(row["atr"]) else 0.0 mid, upper, lower = float(row["bb_middle"]), float(row["bb_upper"]), float(row["bb_lower"]) if any(pd.isna(v) for v in (bb_norm, mid, upper, lower, price)): return None, "warmup" if adx_v > p.adx_max: return None, "adx_trend" if atr_v <= 0: return None, "no_atr" if bb_norm >= p.bb_extreme and rsi_v >= p.rsi_ob: action = "SHORT" elif bb_norm <= -p.bb_extreme and rsi_v <= p.rsi_os: action = "LONG" else: return None, "no_extreme" expected = abs(price - mid) / price if expected < p.min_move_fee_mult * fee_rt: return None, "fee_gate" if action == "SHORT": sl, tp1, tp2 = upper + p.sl_buffer_atr * atr_v, mid, lower else: sl, tp1, tp2 = lower - p.sl_buffer_atr * atr_v, mid, upper min_dist = price * p.sl_min_pct / 100.0 sl = max(sl, price + min_dist) if action == "SHORT" else min(sl, price - min_dist) if action == "SHORT": ok = sl > price > tp1 else: ok = sl < price < tp1 if not ok: return None, "invalid_tpsl" return (action, price, sl, tp1, tp2), "ok" def stand_mr_backtest(frame: pd.DataFrame, p: MRParamsStand, fee_rt: float) -> dict: """Независимая машина состояний (та же семантика, что у прод-движка).""" n = len(frame) closes = frame["close"].to_numpy(float) highs = frame["high"].to_numpy(float) lows = frame["low"].to_numpy(float) bb_norm = frame["bb_norm"].to_numpy(float) ts = frame["timestamp"].to_numpy() days = pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d").to_numpy() trades = [] next_free_bar, day_entries, current_day, sl_pause_sign = 0, 0, None, 0 warmup = max(p.bb_period + 1, 15) for i in range(warmup, n): if days[i] != current_day: current_day = days[i] day_entries = 0 if i < next_free_bar: continue if sl_pause_sign != 0: bn = bb_norm[i] if not np.isnan(bn) and (abs(bn) <= 0.1 or bn * sl_pause_sign < 0): sl_pause_sign = 0 else: continue sig, reason = entry_signal(i, frame, p, fee_rt) if sig is None: continue action, entry, sl, tp1, _tp2 = sig if day_entries >= p.daily_max: continue j0, j1 = i + 1, min(i + 1 + p.horizon_bars, n) pnl, market = pnl_sl_first(action, entry, sl, tp1, highs[j0:j1], lows[j0:j1], closes[min(j1, n - 1)]) pred = 1 if action == "LONG" else -1 trades.append({"i": i, "pred": pred, "actual": market, "pnl": pnl - fee_rt, "action": action, "ts": str(days[i])}) if market == -pred: sl_pause_sign = -pred day_entries += 1 next_free_bar = j1 + p.cooldown_bars return {"trades": trades, "metrics": metrics(trades)} def metrics(trades) -> dict: pnl = [float(t["pnl"]) for t in trades] wins = [x for x in pnl if x > 0] losses = [abs(x) for x in pnl if x < 0] total_win, total_loss = sum(wins), sum(losses) or 1e-10 directed = [t for t in trades if t.get("actual") in (1, -1)] da = (sum(1 for t in directed if t["pred"] == t["actual"]) / len(directed)) if directed else 0.0 return {"total_trades": len(trades), "profit_factor": min(total_win / total_loss, 10.0), "win_rate": len(wins) / len(trades) if trades else 0.0, "sum_pnl": sum(pnl), "avg_pnl": sum(pnl) / len(pnl) if pnl else 0.0, "da": da, "total_win": total_win, "total_loss": total_loss}