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