This repository has been archived on 2026-10-22. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
anatoly c6671410dc research(5m): прогон 14 методов на 250 парах, сверка движка, издержки, графики
ЧТО: code/ (стенд и методы), results/ (сводки), charts/ (3 графика), reports/ (издержки, плацебо, по годам), docs/REJECTED.md.

ПОЧЕМУ: цель — найти кандидатов на 5м или доказать с цифрами их отсутствие.

КАК ПРОВЕРЕНО: движок сверен с прод-движком TON-бота 1:1; плацебо и рандом-контроль в отчёте; бутстрап по дням.
2026-09-29 02:46:08 +03:00

237 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}