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
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Планка издержек на 5м (этап Э3): средние движения, комиссии, порог безубыточной точности.
Считает по фактическим 5м-данным: средний модуль 5м-доходности и 1ч-доходности (12 баров) по каждой
паре, агрегаты, и таблицу p* = (Стоп + Издержки) / (Цель + Стоп) для тейкера и мейкера.
Выход: reports/costs.json (машинный контракт гейта) + reports/COSTS.md (человекочитаемо).
"""
from __future__ import annotations
import glob
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__)))
FEE = {"taker": 0.055, "maker": 0.02} # % на ногу
SCENARIOS = [(0.1, 0.1), (0.2, 0.2), (0.3, 0.2), (0.5, 0.3), (1.0, 0.5), (1.0, 1.0), (2.0, 1.0)]
def main() -> int:
files = sorted(glob.glob(os.path.join(ROOT, "data", "5m", "*_5m.csv")))
if not files:
print("нет 5м-данных")
return 1
rows = []
for path in files:
sym = os.path.basename(path).replace("_5m.csv", "")
close = pd.read_csv(path, header=None, usecols=[4], names=["c"])["c"].to_numpy(float)
if len(close) < 1000:
continue
r5 = np.diff(np.log(close))
r1h = np.convolve(r5, np.ones(12), "valid")
rows.append({"symbol": sym, "bars": int(len(close)),
"mean_abs_r5m_bp": float(np.abs(r5).mean() * 1e4),
"std_r5m_bp": float(r5.std() * 1e4),
"p95_abs_r5m_bp": float(np.percentile(np.abs(r5), 95) * 1e4),
"mean_abs_r1h_bp": float(np.abs(r1h).mean() * 1e4)})
df = pd.DataFrame(rows)
agg = {"pairs": int(len(df)), "bars_total": int(df["bars"].sum()),
"mean_abs_r5m_bp": round(float(df["mean_abs_r5m_bp"].mean()), 2),
"median_abs_r5m_bp": round(float(df["mean_abs_r5m_bp"].median()), 2),
"mean_abs_r1h_bp": round(float(df["mean_abs_r1h_bp"].mean()), 2)}
p_star = []
for target, stop in SCENARIOS:
p_star.append({"target_pct": target, "stop_pct": stop,
"taker": round((stop + 2 * FEE["taker"]) / (target + stop) * 100, 1),
"maker": round((stop + 2 * FEE["maker"]) / (target + stop) * 100, 1)})
out = {"fees_pct_per_leg": FEE, "fee_rt_pct": {"taker": 2 * FEE["taker"], "maker": 2 * FEE["maker"]},
"slippage_bp_per_leg": 1.0, "slippage_bp_per_leg_illiquid": 3.0,
"mean_abs_r5m_bp": agg["mean_abs_r5m_bp"], "mean_abs_r1h_bp": agg["mean_abs_r1h_bp"],
"aggregate": agg, "per_pair": rows, "p_star": p_star}
os.makedirs(os.path.join(ROOT, "reports"), exist_ok=True)
with open(os.path.join(ROOT, "reports", "costs.json"), "w") as fh:
json.dump(out, fh, ensure_ascii=False, indent=1)
md = ["# Планка издержек на 5-минутных свечах", "",
f"Посчитано скриптом `tools/costs.py` по {len(df)} парам, {agg['bars_total']:,} барам 5м "
f"(данные Bybit).".replace(",", " "), "",
"## Что стоит торговля", "",
f"- Комиссия тейкера: {FEE['taker']}% на ногу, **{2*FEE['taker']:.2f}% на круг** (вход+выход);",
f"- Комиссия мейкера: {FEE['maker']}% на ногу, **{2*FEE['maker']:.2f}% на круг**;",
"- Проскальзывание: 1 б.п. на ногу для топ-30 по обороту, 3 б.п. для остальных пар;",
"- Funding учитывается только при удержании через 00/08/16 UTC (0,006% за 8ч на BTC — "
"на фоне комиссии незначимо, но в движке учтено).", "",
"## Типичные движения (факт по данным)", "",
f"- Средний модуль доходности за 5м: **{agg['mean_abs_r5m_bp']} б.п.** "
f"(медиана по парам {agg['median_abs_r5m_bp']} б.п.);",
f"- Средний модуль доходности за 1 час: **{agg['mean_abs_r1h_bp']} б.п.**", "",
"Вывод: комиссия на круг (11 б.п.) сопоставима с типичным движением за 5 минут. "
"Значит «скальпинг по одному 5м-бару» убыточен по построению, а осмысленны конструкции, "
"где цель в 3–10 раз больше комиссии (сигнал на 5м, удержание десятки минут) либо "
"мейкер-исполнение, либо кросс-секционные корзины.", "",
"## Порог безубыточной точности p* = (Стоп + Издержки) / (Цель + Стоп)", "",
"| Цель | Стоп | Тейкер (11 б.п.) | Мейкер (4 б.п.) |", "|---|---|---|---|"]
for r in p_star:
md.append(f"| {r['target_pct']}% | {r['stop_pct']}% | нужно выигрывать {r['taker']}% "
f"| нужно выигрывать {r['maker']}% |")
md += ["", "## Топ-15 пар по среднему движению 5м", "", "| Пара | баров | |r5м|, б.п. | σ 5м, б.п. | |r1ч|, б.п. |",
"|---|---|---|---|---|"]
for r in sorted(rows, key=lambda x: -x["mean_abs_r5m_bp"])[:15]:
md.append(f"| {r['symbol']} | {r['bars']} | {r['mean_abs_r5m_bp']:.2f} | "
f"{r['std_r5m_bp']:.2f} | {r['mean_abs_r1h_bp']:.2f} |")
with open(os.path.join(ROOT, "reports", "COSTS.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(md) + "\n")
print(f"пар {agg['pairs']}, баров {agg['bars_total']}, |r5м| {agg['mean_abs_r5m_bp']} б.п., "
f"|r1ч| {agg['mean_abs_r1h_bp']} б.п.")
print("записано reports/costs.json и reports/COSTS.md")
return 0
if __name__ == "__main__":
sys.exit(main())