research(5m): прогон 14 методов на 250 парах, сверка движка, издержки, графики

ЧТО: code/ (стенд и методы), results/ (сводки), charts/ (3 графика), reports/ (издержки, плацебо, по годам), docs/REJECTED.md.

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

КАК ПРОВЕРЕНО: движок сверен с прод-движком TON-бота 1:1; плацебо и рандом-контроль в отчёте; бутстрап по дням.
This commit is contained in:
2026-09-29 02:46:08 +03:00
parent 7b048534ff
commit c6671410dc
26 changed files with 5514 additions and 2 deletions
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Измерительный стенд для 5м-методов: загрузка вселенной, издержки, метрики честности.
Здесь только «измерение», без правил: правила живут в tools/methods.py и возвращают сырые сделки
(time, symbol, side, gross_bp). Издержки, плацебо, рандом-контроль, OOS, block-bootstrap, разбивка
по годам и просадка считаются ЦЕНТРАЛЬНО — одинаково для всех методов, чтобы их нельзя было
«подкрутить» внутри метода.
Издержки (от нотионала, как требует цель):
тейкер 0,055% на ногу + проскальзывание 1 б.п. (топ-30 по обороту) или 3 б.п. (остальные),
на круг (вход+выход) = 2 × (5,5 + слиппедж) б.п. Мейкер 0,02% на ногу — отдельным прогоном.
"""
from __future__ import annotations
import glob
import json
import os
import numpy as np
import pandas as pd
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FEE_BP_TAKER, FEE_BP_MAKER = 5.5, 2.0 # 0,055% и 0,02% на ногу
SLIP_BP_LIQUID, SLIP_BP_OTHER = 1.0, 3.0
TOP_N_LIQUID = 30
BAR_MS = 300_000
BARS_PER_DAY = 288
# ------------------------------------------------------------- загрузка ----
def list_pairs(data_dir: str | None = None) -> list[str]:
d = data_dir or os.path.join(ROOT, "data", "5m")
return sorted(os.path.basename(p).replace("_5m.csv", "") for p in glob.glob(os.path.join(d, "*_5m.csv")))
def load_long(pairs: list[str] | None = None, data_dir: str | None = None) -> pd.DataFrame:
"""Все пары в один длинный кадр: ts, symbol, open, high, low, close, volume."""
frames = []
for sym in (pairs or list_pairs(data_dir)):
path = os.path.join(data_dir or os.path.join(ROOT, "data", "5m"), f"{sym}_5m.csv")
if not os.path.isfile(path):
continue
df = pd.read_csv(path, header=None, names=["ts", "open", "high", "low", "close", "volume"],
usecols=[0, 1, 2, 3, 4, 5])
df = df.drop_duplicates("ts")
df["symbol"] = sym
frames.append(df)
if not frames:
return pd.DataFrame(columns=["ts", "symbol", "open", "high", "low", "close", "volume"])
return pd.concat(frames, ignore_index=True).sort_values(["symbol", "ts"]).reset_index(drop=True)
def pivot_close(long_df: pd.DataFrame) -> pd.DataFrame:
"""Матрица close: индекс — 5м-сетка времени, колонки — пары (с продолжением вперёд)."""
m = long_df.pivot_table(index="ts", columns="symbol", values="close", aggfunc="last")
m = m.sort_index()
m = m.ffill(limit=12) # дырки больше часа не тянем
return m
def turnover_top(long_df: pd.DataFrame, n: int = TOP_N_LIQUID) -> set[str]:
"""Топ-N пар по обороту (средний объём × цена) — для проскальзывания и теста ликвидности."""
g = long_df.groupby("symbol").apply(
lambda d: float((d["volume"] * d["close"]).tail(20_000).mean()) if len(d) else 0.0,
include_groups=False)
return set(g.sort_values(ascending=False).head(n).index)
def cost_bp_per_turn(symbol: str, liquid: set[str], maker: bool = False) -> float:
"""Издержки на круг (вход+выход) в б.п. от нотионала позиции."""
fee = FEE_BP_MAKER if maker else FEE_BP_TAKER
slip = SLIP_BP_LIQUID if symbol in liquid else SLIP_BP_OTHER
return 2.0 * (fee + slip)
def apply_costs(trades: pd.DataFrame, liquid: set[str], maker: bool = False) -> pd.DataFrame:
"""Издержки начисляются НА МЕСТЕ (без копии) — на полной вселенной копия не влезает в RAM."""
if trades.empty:
return trades.assign(ret_bp=[])
trades["cost_bp"] = trades["symbol"].map(lambda s: cost_bp_per_turn(s, liquid, maker))
trades["ret_bp"] = trades["gross_bp"] - trades["cost_bp"]
return trades
# -------------------------------------------------------------- метрики ----
def block_bootstrap_t(x: np.ndarray, block: int = BARS_PER_DAY, iters: int = 2000, seed: int = 11) -> float:
"""t-статистика среднего по блочному бутстрапу (блоки по дню) — вместо наивного t."""
x = np.asarray(x, dtype=float)
x = x[np.isfinite(x)]
if len(x) < 10:
return 0.0
rng = np.random.default_rng(seed)
n = len(x)
nb = int(np.ceil(n / block))
means = np.empty(iters)
for k in range(iters):
starts = rng.integers(0, max(1, n - block), size=nb)
s = np.concatenate([x[i:i + block] for i in starts])[:n]
means[k] = s.mean()
se = means.std(ddof=1)
return float(means.mean() / se) if se > 0 else 0.0
def daily_series(trades: pd.DataFrame) -> pd.Series:
if trades.empty:
return pd.Series(dtype=float)
d = pd.to_datetime(trades["time"], unit="ms", utc=True).dt.floor("D")
return trades.groupby(d)["ret_bp"].sum()
def sharpe_and_dd(trades: pd.DataFrame) -> tuple[float, float]:
s = daily_series(trades)
if len(s) < 5:
return 0.0, 0.0
sharpe = float(s.mean() / s.std(ddof=1) * np.sqrt(365)) if s.std(ddof=1) > 0 else 0.0
eq = s.cumsum()
dd = float((eq - eq.cummax()).min()) # в б.п.
return sharpe, dd
def summarize(method: str, trades: pd.DataFrame, placebo: pd.DataFrame, rand: pd.DataFrame,
oos_frac: float = 0.5) -> dict:
"""Строка сводки по методу (все метрики считаются здесь, а не в методе)."""
if trades.empty:
return {"method": method, "n_trades": 0, "ev_bp": 0.0, "ev_bp_oos": 0.0, "t_boot": 0.0,
"placebo_ev_bp": 0.0, "rand_ev_bp": 0.0, "sharpe": 0.0, "max_dd_pct": 0.0, "by_year_ok": "нет"}
tr = trades.sort_values("time").reset_index(drop=True)
cut = tr["time"].quantile(1.0 - oos_frac)
oos = tr[tr["time"] >= cut]
sharpe, dd_bp = sharpe_and_dd(tr)
daily = daily_series(tr)
years = pd.to_datetime(tr["time"], unit="ms", utc=True).dt.year
by_year = tr.groupby(years)["ret_bp"].mean()
pos_years = int((by_year > 0).sum())
return {
"method": method,
"n_trades": int(len(tr)),
"ev_bp": round(float(tr["ret_bp"].mean()), 2),
"ev_bp_oos": round(float(oos["ret_bp"].mean()), 2) if len(oos) else 0.0,
"t_boot": round(block_bootstrap_t(daily.to_numpy(), block=5), 2),
"placebo_ev_bp": round(float(placebo["ret_bp"].mean()), 2) if not placebo.empty else 0.0,
"rand_ev_bp": round(float(rand["ret_bp"].mean()), 2) if not rand.empty else 0.0,
"sharpe": round(sharpe, 2),
"max_dd_pct": round(dd_bp / 100.0, 2), # б.п. суммы → % «в единицах выигрыша»
"by_year_ok": "да" if pos_years >= 2 else "нет",
"by_year": {int(y): round(float(v), 2) for y, v in by_year.items()},
}
def save_trades(method: str, trades: pd.DataFrame) -> None:
d = os.path.join(ROOT, "results", "methods")
os.makedirs(d, exist_ok=True)
cols = [c for c in ("time", "symbol", "side", "gross_bp", "cost_bp", "ret_bp") if c in trades.columns]
trades.sort_values("time")[cols].to_csv(os.path.join(d, f"{method}.csv"), index=False)
def save_by_year(rows: list[dict], tag: str = "") -> None:
recs = []
for r in rows:
for y, v in (r.get("by_year") or {}).items():
recs.append({"method": r["method"], "year": y, "ev_bp": v, "n": r["n_trades"]})
d = os.path.join(ROOT, "reports")
os.makedirs(d, exist_ok=True)
pd.DataFrame(recs).to_csv(os.path.join(d, f"by_year{tag}.csv"), index=False)
def save_counts(n_tests: int, tag: str = "") -> None:
"""Порог Бонферрони для числа проверенных вариантов (двусторонний, alpha=0,05)."""
from scipy import stats
thr = float(stats.norm.ppf(1 - 0.05 / (2 * max(n_tests, 1))))
d = os.path.join(ROOT, "reports")
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, f"count_tests{tag}.json"), "w") as fh:
json.dump({"n_tests": int(n_tests), "bonferroni_t": round(thr, 2),
"alpha": 0.05, "note": "двусторонний порог |t| для числа проверенных вариантов"}, fh,
ensure_ascii=False, indent=1)
# ------------------------------------------- потоковая работа (экономно по памяти) ----
def iter_pairs(data_dir: str | None = None):
"""По одной паре за раз: 250 пар × 210 тыс. баров не влезают в RAM (на машине 7 ГБ)."""
d = data_dir or os.path.join(ROOT, "data", "5m")
for sym in list_pairs(d):
path = os.path.join(d, f"{sym}_5m.csv")
if not os.path.isfile(path):
continue
df = pd.read_csv(path, header=None, names=["ts", "open", "high", "low", "close", "volume"])
df = df.drop_duplicates("ts").sort_values("ts").reset_index(drop=True)
yield sym, df
def build_matrix(data_dir: str | None = None):
"""Матрица close (float32) на общей 5м-сетке + набор ликвидных пар. Память ≈ 200 МБ."""
d = data_dir or os.path.join(ROOT, "data", "5m")
syms, tss, closes, turns = [], {}, {}, {}
for sym, df in iter_pairs(d):
syms.append(sym)
tss[sym] = df["ts"].to_numpy()
closes[sym] = df["close"].to_numpy(np.float32)
tail = df.tail(20_000)
turns[sym] = float((tail["volume"] * tail["close"]).mean())
grid = np.unique(np.concatenate(list(tss.values())))
C = np.full((len(grid), len(syms)), np.nan, dtype=np.float32)
for j, sym in enumerate(syms):
C[np.searchsorted(grid, tss[sym]), j] = closes[sym]
liquid = set(sorted(turns, key=lambda s: -turns[s])[:TOP_N_LIQUID])
hours = pd.to_datetime(grid, unit="ms", utc=True).hour.to_numpy()
return {"grid": grid, "C": C, "syms": syms, "liquid": liquid, "hours": hours}