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
crypto-screener-5m/code/harness.py
T
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

209 lines
10 KiB
Python
Raw 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м-методов: загрузка вселенной, издержки, метрики честности.
Здесь только «измерение», без правил: правила живут в 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}