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/methods.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

467 lines
21 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минутных свечах + централизованный протокол честности.
Все методы принимают ОДИН аргумент — словарь рынка из harness.build_matrix()
(grid, C float32, syms, liquid, hours) и возвращают сырые сделки
(time, symbol, side, gross_bp) БЕЗ издержек. Издержки, плацебо, рандом-контроль, OOS,
block-bootstrap (по дням), разбивка по годам и просадка считаются в harness.py одинаково
для всех — чтобы их нельзя было «подкрутить» внутри метода.
Память: полная вселенная (250 пар × ~210 тыс. баров) не влезает в RAM, поэтому кросс-секционные
методы работают по матрице float32, а временные ряды — потоково, по одной паре за раз.
Правила каждого метода зафиксированы ДО прогона и после просмотра результата не меняются.
Запуск: .venv/bin/python tools/methods.py [--maker] [имена методов...]
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
import pandas as pd
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from tools import harness as H # noqa: E402
BAR_MS = 300_000
MAX_REBALANCES = 20_000
EMPTY = ["time", "symbol", "side", "gross_bp"]
# --------------------------------------------------------------- утилиты ----
def fwd_extremes(high: np.ndarray, low: np.ndarray, hold: int):
n = len(high)
hi_f = np.full(n, np.nan)
lo_f = np.full(n, np.nan)
if n > hold:
hm = np.lib.stride_tricks.sliding_window_view(high, hold)
lm = np.lib.stride_tricks.sliding_window_view(low, hold)
hi_f[: n - hold] = hm[1: n - hold + 1].max(axis=1)
lo_f[: n - hold] = lm[1: n - hold + 1].min(axis=1)
return hi_f, lo_f
def nonoverlap(candidates: np.ndarray, hold: int) -> list[int]:
out, next_free = [], -1
for i in candidates:
if i >= next_free:
out.append(int(i))
next_free = i + hold + 1
return out
def trades_from_signals(ts, close, high, low, entries, hold, sl=None, tp=None, symbol=""):
if not entries:
return pd.DataFrame(columns=EMPTY)
hi_f, lo_f = fwd_extremes(high, low, hold)
n = len(close)
rows = []
for i, side in entries:
if i + 1 >= n:
continue
e = close[i]
if e <= 0:
continue
j = min(i + hold, n - 1)
d = 1.0 if side == "long" else -1.0
pnl = None
if sl and i in sl and not np.isnan(lo_f[i]):
if side == "long" and lo_f[i] <= sl[i]:
pnl = (sl[i] - e) / e
elif side == "short" and hi_f[i] >= sl[i]:
pnl = (e - sl[i]) / e
if pnl is None and tp and i in tp and not np.isnan(hi_f[i]):
if side == "long" and hi_f[i] >= tp[i]:
pnl = (tp[i] - e) / e
elif side == "short" and lo_f[i] <= tp[i]:
pnl = (e - tp[i]) / e
if pnl is None:
pnl = (close[j] - e) / e
rows.append({"time": int(ts[i]), "symbol": symbol, "side": side,
"gross_bp": float(d * pnl * 1e4)})
return pd.DataFrame(rows)
def ts_trades(sym, df, mask, sides, hold, sl_map=None, tp_map=None):
entries = [(i, sides[i]) for i in nonoverlap(np.flatnonzero(mask), hold)]
return trades_from_signals(df["ts"].to_numpy(), df["close"].to_numpy(float),
df["high"].to_numpy(float), df["low"].to_numpy(float),
entries, hold, sl=sl_map, tp=tp_map, symbol=sym)
def _run_ts(fn) -> pd.DataFrame:
out = []
for sym, df in H.iter_pairs():
tr = fn(sym, df)
if tr is not None and not tr.empty:
out.append(tr)
return pd.concat(out, ignore_index=True) if out else pd.DataFrame(columns=EMPTY)
# ------------------------------------------------- кросс-секционные методы ----
def xs_book(mkt, name, k, hold, mode="reversal", quantile=0.1, hours_filter=None,
shift=0, random_pick=False, seed=3, min_dispersion=0.0) -> pd.DataFrame:
"""Книга: шорт верхнего / лонг нижнего квантиля по доходности за k баров.
shift>0 — плацебо: сигнал сдвинут вперёд на горизонт (то же правило на «будущих» данных).
min_dispersion>0 — cost-aware гейт: торгуем только если разброс доходностей в вселенной
превышает заданный порог (в долях), иначе оборот съедает всё.
"""
C, grid, syms, hours = mkt["C"], mkt["grid"], mkt["syms"], mkt["hours"]
n_cols = C.shape[1]
k_names = max(1, int(round(n_cols * quantile)))
rng = np.random.default_rng(seed)
rows = []
n_reb = 0
for pos in range(k + 1, len(grid) - hold - shift, hold):
if hours_filter is not None and hours[pos] not in hours_filter:
continue
if n_reb >= MAX_REBALANCES:
break
n_reb += 1
with np.errstate(invalid="ignore", divide="ignore"):
sc = C[pos] / C[pos - k] - 1.0
fw = C[pos + hold + shift] / C[pos + shift] - 1.0
ok = np.isfinite(sc) & np.isfinite(fw)
if ok.sum() < max(6, 2 * k_names):
continue
idxs = np.flatnonzero(ok)
scv = sc[idxs]
if min_dispersion > 0 and float(np.std(scv)) < min_dispersion:
continue
if random_pick:
pick = rng.permutation(len(idxs))[: 2 * k_names]
longs, shorts = idxs[pick[:k_names]], idxs[pick[k_names:]]
else:
order = np.argsort(scv)
longs, shorts = idxs[order[:k_names]], idxs[order[-k_names:]]
if mode == "momentum":
longs, shorts = shorts, longs
t = int(grid[pos])
for j in longs:
rows.append((t, syms[j], "long", float(fw[j]) * 1e4))
for j in shorts:
rows.append((t, syms[j], "short", -float(fw[j]) * 1e4))
return pd.DataFrame(rows, columns=EMPTY)
def m_xs_reversal(mkt) -> pd.DataFrame:
return xs_book(mkt, "xs_reversal_15m", k=3, hold=12, mode="reversal")
def m_xs_momentum(mkt) -> pd.DataFrame:
return xs_book(mkt, "xs_momentum_15m", k=3, hold=12, mode="momentum")
def m_hour_xs_reversal(mkt) -> pd.DataFrame:
return xs_book(mkt, "hour_xs_reversal", k=3, hold=12, mode="reversal",
hours_filter={0, 1, 2, 3, 4, 5, 6, 7})
def m_xs_reversal_costaware(mkt) -> pd.DataFrame:
"""Cost-aware вариант: торгуем только когда разброс доходностей больше 2 кругов издержек."""
return xs_book(mkt, "xs_reversal_costaware", k=3, hold=12, mode="reversal",
min_dispersion=2 * (H.FEE_BP_TAKER + H.SLIP_BP_OTHER) / 1e4)
def m_xs_reversal_placebo(mkt) -> pd.DataFrame:
return xs_book(mkt, "placebo_xs_reversal", k=3, hold=12, mode="reversal", shift=12)
def m_xs_reversal_random(mkt) -> pd.DataFrame:
return xs_book(mkt, "random_xs_reversal", k=3, hold=12, random_pick=True)
def m_intraday_momentum(mkt) -> pd.DataFrame:
"""Импульс первой половины суток (00:00–10:00 UTC) → позиция на вторую (10:00–23:00)."""
C, grid, syms, hours = mkt["C"], mkt["grid"], mkt["syms"], mkt["hours"]
dt = pd.to_datetime(grid, unit="ms", utc=True)
day = dt.strftime("%Y-%m-%d").to_numpy()
minute = (dt.hour * 60 + dt.minute).to_numpy()
i_open = np.flatnonzero(minute == 0)
i_cut = np.flatnonzero(minute == 600)
i_last = np.flatnonzero(minute == 1380)
if not (len(i_open) and len(i_cut) and len(i_last)):
return pd.DataFrame(columns=EMPTY)
n = min(len(i_open), len(i_cut), len(i_last))
rows = []
for a, b, c in zip(i_open[:n], i_cut[:n], i_last[:n]):
s1 = C[b] / C[a] - 1.0
seg = C[c] / C[b] - 1.0
ok = np.isfinite(s1) & np.isfinite(seg)
if ok.sum() < 6:
continue
idxs = np.flatnonzero(ok)
med = np.median(s1[idxs])
longs = idxs[s1[idxs] >= med]
shorts = idxs[s1[idxs] < med]
t = int(grid[b])
for j in longs:
rows.append((t, syms[j], "long", float(seg[j]) * 1e4))
for j in shorts:
rows.append((t, syms[j], "short", -float(seg[j]) * 1e4))
return pd.DataFrame(rows, columns=EMPTY)
def m_lead_lag_btc(mkt) -> pd.DataFrame:
"""Импульс BTC за 2 бара → позиция в альте на 12 баров; доход — альфа сверх хода BTC."""
C, grid, syms = mkt["C"], mkt["grid"], mkt["syms"]
if "BTCUSDT" not in syms:
return pd.DataFrame(columns=EMPTY)
jb = syms.index("BTCUSDT")
btc = C[:, jb]
with np.errstate(invalid="ignore", divide="ignore"):
r2 = btc / np.roll(btc, 2) - 1.0
fwd_btc = np.roll(btc, -12) / btc - 1.0
r2[:3] = np.nan
fwd_btc[-12:] = np.nan
thr = pd.Series(r2).rolling(288).quantile(0.8).to_numpy()
mask = np.isfinite(r2) & (np.abs(r2) > thr)
rows = []
for pos in nonoverlap(np.flatnonzero(mask), 12):
side = "long" if r2[pos] > 0 else "short"
t = int(grid[pos])
fb = fwd_btc[pos]
with np.errstate(invalid="ignore", divide="ignore"):
fw_all = C[pos + 12] / C[pos] - 1.0
for j in range(len(syms)):
if j == jb:
continue
fw = fw_all[j]
if not np.isfinite(fw) or not np.isfinite(fb):
continue
alpha = fw - fb
rows.append((t, syms[j], side, float(alpha * 1e4 if side == "long" else -alpha * 1e4)))
return pd.DataFrame(rows, columns=EMPTY)
def m_pairs_btc_eth(mkt) -> pd.DataFrame:
"""Спред log(ETH/BTC): z-скор за 288 баров, вход |z|>2, выход |z|<0.5 или 288 баров."""
C, grid, syms = mkt["C"], mkt["grid"], mkt["syms"]
if not {"BTCUSDT", "ETHUSDT"}.issubset(set(syms)):
return pd.DataFrame(columns=EMPTY)
jb, je = syms.index("BTCUSDT"), syms.index("ETHUSDT")
with np.errstate(invalid="ignore", divide="ignore"):
spread = np.log(C[:, je]) - np.log(C[:, jb])
s = pd.Series(spread)
z = ((s - s.rolling(288).mean()) / s.rolling(288).std(ddof=0)).to_numpy()
rows, pos = [], 288
while pos < len(grid) - 1:
zi = z[pos]
if not np.isfinite(zi) or abs(zi) <= 2:
pos += 1
continue
side = "short" if zi > 0 else "long"
end = min(pos + 288, len(grid) - 1)
for k in range(pos + 1, end):
if np.isfinite(z[k]) and abs(z[k]) < 0.5:
end = k
break
t = int(grid[pos])
eth_ret = C[end, je] / C[pos, je] - 1
btc_ret = C[end, jb] / C[pos, jb] - 1
if side == "short":
rows += [(t, "ETHUSDT", "short", float(-eth_ret * 1e4)),
(t, "BTCUSDT", "long", float(btc_ret * 1e4))]
else:
rows += [(t, "ETHUSDT", "long", float(eth_ret * 1e4)),
(t, "BTCUSDT", "short", float(-btc_ret * 1e4))]
pos = end + 1
return pd.DataFrame(rows, columns=EMPTY)
# ------------------------------------------------ временные ряды по парам ----
def m_ou_ema(mkt, costaware: bool = False) -> pd.DataFrame:
"""Возврат к EMA(20): вход |z|>2, выход через 12 баров; cost-aware — только если ожидаемое
движение до средней >= 2 кругов издержек (приём из arXiv 2606.00060)."""
liquid = mkt["liquid"]
def fn(sym, d):
c = d["close"].to_numpy(float)
ema = pd.Series(c).ewm(span=20, adjust=False).mean().to_numpy()
sd = pd.Series(c).rolling(20).std(ddof=0).to_numpy()
with np.errstate(invalid="ignore", divide="ignore"):
z = (c - ema) / np.where(sd > 0, sd, np.nan)
mask = (np.abs(z) > 2) & np.isfinite(z)
if costaware:
cost_rt = H.cost_bp_per_turn(sym, liquid) / 1e4
mask &= (np.abs(c - ema) / c) >= 2 * cost_rt
return ts_trades(sym, d, mask, np.where(z < 0, "long", "short"), 12)
return _run_ts(fn)
def m_ou_ema_costaware(mkt) -> pd.DataFrame:
return m_ou_ema(mkt, costaware=True)
def m_vwap_revert(mkt) -> pd.DataFrame:
"""Откат к VWAP дня: вход при уходе за 2σ от VWAP, выход через 12 баров."""
def fn(sym, d):
c = d["close"].to_numpy(float)
v = d["volume"].to_numpy(float)
with np.errstate(invalid="ignore", divide="ignore"):
vwap = np.cumsum(c * v) / np.cumsum(v)
dev = c / vwap - 1
sd = pd.Series(dev).rolling(288).std(ddof=0).to_numpy()
with np.errstate(invalid="ignore", divide="ignore"):
z = dev / np.where(sd > 0, sd, np.nan)
mask = np.abs(z) > 2
return ts_trades(sym, d, mask, np.where(z < 0, "long", "short"), 12)
return _run_ts(fn)
def m_clv_flow(mkt) -> pd.DataFrame:
"""Ордер-флоу прокси: CLV на краю + всплеск объёма → вход против импульса, выход 6 баров."""
def fn(sym, d):
c, h, l = d["close"].to_numpy(float), d["high"].to_numpy(float), d["low"].to_numpy(float)
v = d["volume"].to_numpy(float)
with np.errstate(invalid="ignore", divide="ignore"):
clv = (2 * c - h - l) / np.where((h - l) > 0, h - l, np.nan)
vz = (v - pd.Series(v).rolling(288).mean().to_numpy()) / pd.Series(v).rolling(288).std(ddof=0).to_numpy()
mask = (np.abs(clv) > 0.8) & (vz > 2)
return ts_trades(sym, d, mask, np.where(clv < 0, "long", "short"), 6)
return _run_ts(fn)
def m_sweep(mkt) -> pd.DataFrame:
"""Liquidity sweep: прокол экстремума за 48 баров и возврат внутрь диапазона, выход 6 баров."""
def fn(sym, d):
c, h, l = d["close"].to_numpy(float), d["high"].to_numpy(float), d["low"].to_numpy(float)
lo_min = pd.Series(l).rolling(48).min().shift(1).to_numpy()
hi_max = pd.Series(h).rolling(48).max().shift(1).to_numpy()
long_m = (l < lo_min) & (c > lo_min)
short_m = (h > hi_max) & (c < hi_max)
mask = np.nan_to_num(long_m | short_m, nan=False).astype(bool)
sides = np.where(np.nan_to_num(long_m, nan=False), "long", "short")
return ts_trades(sym, d, mask, sides, 6)
return _run_ts(fn)
def m_vol_breakout(mkt) -> pd.DataFrame:
"""Пробой диапазона 48 баров при ATR ниже 80-го персентиля, выход 12 баров."""
def fn(sym, d):
c, h, l = d["close"].to_numpy(float), d["high"].to_numpy(float), d["low"].to_numpy(float)
hi_max = pd.Series(h).rolling(48).max().shift(1).to_numpy()
lo_min = pd.Series(l).rolling(48).min().shift(1).to_numpy()
prev = pd.Series(c).shift(1)
tr = pd.concat([pd.Series(h) - pd.Series(l), (pd.Series(h) - prev).abs(),
(pd.Series(l) - prev).abs()], axis=1).max(axis=1)
atr_pct = tr.rolling(14).mean().rolling(288).rank(pct=True).to_numpy()
long_m = np.nan_to_num((c > hi_max) & (atr_pct < 0.8), nan=False)
short_m = np.nan_to_num((c < lo_min) & (atr_pct < 0.8), nan=False)
mask = (long_m | short_m).astype(bool)
return ts_trades(sym, d, mask, np.where(long_m, "long", "short"), 12)
return _run_ts(fn)
def m_prod_mr_5m(mkt) -> pd.DataFrame:
"""Прод-правила MR TON-бота, применённые НАПРЯМУЮ к 5м-барам (боковик ADX, полосы, RSI, SL/TP)."""
from tools.mr_engine import MRParamsStand, bollinger, rsi, adx as adx_fn, atr as atr_fn
p = MRParamsStand()
def fn(sym, d):
f = d.copy()
f["atr"] = atr_fn(f)
f["mr_rsi"] = rsi(f["close"])
f["mr_adx"] = adx_fn(f)
f = pd.concat([f, bollinger(f["close"])], axis=1)
c = f["close"].to_numpy(float)
mid, up, lo = (f["bb_middle"].to_numpy(float), f["bb_upper"].to_numpy(float),
f["bb_lower"].to_numpy(float))
with np.errstate(invalid="ignore", divide="ignore"):
bn, rs, ax, at = (f["bb_norm"].to_numpy(float), f["mr_rsi"].to_numpy(float),
f["mr_adx"].to_numpy(float), f["atr"].to_numpy(float))
expected = np.abs(c - mid) / c
base = (ax <= p.adx_max) & (at > 0) & (expected >= 2 * 0.0011)
long_m = np.nan_to_num((bn <= -p.bb_extreme) & (rs <= p.rsi_os) & base, nan=False)
short_m = np.nan_to_num((bn >= p.bb_extreme) & (rs >= p.rsi_ob) & base, nan=False)
mask = (long_m | short_m).astype(bool)
sides = np.where(long_m, "long", "short")
sl_map, tp_map = {}, {}
for i in np.flatnonzero(mask):
if sides[i] == "long":
sl_map[int(i)] = min(lo[i] - p.sl_buffer_atr * at[i], c[i] * (1 - p.sl_min_pct / 100))
else:
sl_map[int(i)] = max(up[i] + p.sl_buffer_atr * at[i], c[i] * (1 + p.sl_min_pct / 100))
tp_map[int(i)] = mid[i]
return ts_trades(sym, d, mask, sides, 8, sl_map=sl_map, tp_map=tp_map)
return _run_ts(fn)
METHODS = {
"xs_reversal_15m": m_xs_reversal,
"xs_momentum_15m": m_xs_momentum,
"hour_xs_reversal": m_hour_xs_reversal,
"xs_reversal_costaware": m_xs_reversal_costaware,
"intraday_momentum": m_intraday_momentum,
"lead_lag_btc": m_lead_lag_btc,
"pairs_btc_eth": m_pairs_btc_eth,
"ou_ema_revert": m_ou_ema,
"ou_ema_costaware": m_ou_ema_costaware,
"vwap_revert": m_vwap_revert,
"clv_flow": m_clv_flow,
"liquidity_sweep": m_sweep,
"vol_breakout": m_vol_breakout,
"prod_mr_5m": m_prod_mr_5m,
}
def main() -> int:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--maker", action="store_true", help="считать издержки по мейкеру (0,02% на ногу)")
ap.add_argument("methods", nargs="*")
args = ap.parse_args()
want = args.methods or list(METHODS)
tag = "_maker" if args.maker else ""
print("сканирую вселенную...", flush=True)
mkt = H.build_matrix()
liquid = mkt["liquid"]
print(f"пар {len(mkt['syms'])} | 5м-сетка {len(mkt['grid'])} интервалов | ликвидных (топ-30): {len(liquid)}",
flush=True)
rows, tests = [], len(want)
for name in want:
raw = METHODS[name](mkt)
if name == "xs_reversal_15m": # контроли привязаны к базовому методу
place_raw = m_xs_reversal_placebo(mkt)
rand_raw = m_xs_reversal_random(mkt)
tests += 2
else:
place_raw = rand_raw = pd.DataFrame(columns=EMPTY)
tr = H.apply_costs(raw, liquid, maker=args.maker)
pl = H.apply_costs(place_raw, liquid, maker=args.maker)
rn = H.apply_costs(rand_raw, liquid, maker=args.maker)
H.save_trades(name + tag, tr)
row = H.summarize(name, tr, pl, rn)
rows.append(row)
os.makedirs(os.path.join(ROOT, "results", "rows"), exist_ok=True)
pd.DataFrame([row])[["method", "n_trades", "ev_bp", "ev_bp_oos", "t_boot", "placebo_ev_bp",
"rand_ev_bp", "sharpe", "max_dd_pct", "by_year_ok"]].to_csv(
os.path.join(ROOT, "results", "rows", f"{name}{tag}.csv"), index=False)
print(f"{name}: сделок {row['n_trades']}, EV {row['ev_bp']} б.п., OOS {row['ev_bp_oos']}, "
f"t {row['t_boot']}, плацебо {row['placebo_ev_bp']}, рандом {row['rand_ev_bp']}, "
f"Sharpe {row['sharpe']}, by_year {row['by_year_ok']}", flush=True)
del raw, tr, pl, rn
if place_raw is not None and not place_raw.empty:
del place_raw, rand_raw
cols = ["method", "n_trades", "ev_bp", "ev_bp_oos", "t_boot", "placebo_ev_bp", "rand_ev_bp",
"sharpe", "max_dd_pct", "by_year_ok"]
os.makedirs(os.path.join(ROOT, "results"), exist_ok=True)
pd.DataFrame(rows)[cols].to_csv(os.path.join(ROOT, "results", f"summary_5m{tag}.csv"), index=False)
H.save_by_year(rows, tag=tag)
H.save_counts(tests, tag=tag)
with open(os.path.join(ROOT, "results", f"summary_5m{tag}.json"), "w") as fh:
json.dump(rows, fh, ensure_ascii=False, indent=1)
print(f"записано results/summary_5m{tag}.csv, reports/by_year{tag}.csv, reports/count_tests{tag}.json")
return 0
if __name__ == "__main__":
sys.exit(main())