Archived
ЧТО: code/ (стенд и методы), results/ (сводки), charts/ (3 графика), reports/ (издержки, плацебо, по годам), docs/REJECTED.md. ПОЧЕМУ: цель — найти кандидатов на 5м или доказать с цифрами их отсутствие. КАК ПРОВЕРЕНО: движок сверен с прод-движком TON-бота 1:1; плацебо и рандом-контроль в отчёте; бутстрап по дням.
237 lines
10 KiB
Python
237 lines
10 KiB
Python
#!/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}
|