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
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Сборка артефактов Ф2/Ф3 из результатов прогона: графики, таблица опровергнутого, плацебо-отчёт.
Читает results/summary_5m.csv (тейкер) и results/summary_5m_maker.csv (мейкер), сделки методов в
results/methods/*.csv. Пишет:
charts/ev_by_method.png — EV по методам, тейкер против мейкера
charts/equity_best.png — кривая капитала лучшего метода (дневной PnL, в б.п.)
charts/trade_hist.png — распределение доходностей сделок базового метода
docs/REJECTED.md — таблица опровергнутого с цифрами и причиной
reports/PLACEBO.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__)))
import matplotlib # noqa: E402
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
def main() -> int:
tag = sys.argv[1] if len(sys.argv) > 1 else ""
sm_path = os.path.join(ROOT, "results", f"summary_5m{tag}.csv")
mk_path = os.path.join(ROOT, "results", "summary_5m_maker.csv")
if not os.path.isfile(sm_path):
print("нет", sm_path)
return 1
sm = pd.read_csv(sm_path)
mk = pd.read_csv(mk_path) if os.path.isfile(mk_path) else None
costs = json.load(open(os.path.join(ROOT, "reports", "costs.json"), encoding="utf-8"))
rec = json.load(open(os.path.join(ROOT, "reports", "stand_reconcile.json"), encoding="utf-8"))
charts = os.path.join(ROOT, "charts")
docs = os.path.join(ROOT, "docs")
reports = os.path.join(ROOT, "reports")
for d in (charts, docs, reports):
os.makedirs(d, exist_ok=True)
# 1) EV по методам: тейкер против мейкера
fig, ax = plt.subplots(figsize=(11, 6))
x = np.arange(len(sm))
ax.bar(x - 0.2, sm["ev_bp"], width=0.4, label="тейкер (0,11% на круг)")
if mk is not None:
m = mk.set_index("method").reindex(sm["method"])["ev_bp"].to_numpy()
ax.bar(x + 0.2, m, width=0.4, label="мейкер (0,04% на круг)")
ax.axhline(0, color="black", linewidth=0.8)
ax.set_xticks(x)
ax.set_xticklabels(sm["method"], rotation=45, ha="right", fontsize=9)
ax.set_ylabel("матожидание сделки, базисные пункты")
ax.set_title("Матожидание после издержек по методам (5м, Bybit USDT-перпы, 250 пар)")
ax.legend()
ax.grid(alpha=0.3, axis="y")
fig.tight_layout()
fig.savefig(os.path.join(charts, "ev_by_method.png"), dpi=120)
plt.close(fig)
# 2) кривая капитала лучшего по EV метода
best = sm.sort_values("ev_bp", ascending=False).iloc[0]
path = os.path.join(ROOT, "results", "methods", f"{best['method']}{tag}.csv")
if os.path.isfile(path):
tr = pd.read_csv(path)
tr["day"] = pd.to_datetime(tr["time"], unit="ms", utc=True).dt.floor("D")
daily = tr.groupby("day")["ret_bp"].sum().cumsum()
fig, ax = plt.subplots(figsize=(11, 5))
ax.plot(daily.index, daily.values, color="#1f77b4")
ax.axhline(0, color="black", linewidth=0.8)
ax.set_title(f"Кривая капитала лучшего метода «{best['method']}» (накопленный PnL, б.п. на позицию)")
ax.set_ylabel("накопленные б.п.")
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(os.path.join(charts, "equity_best.png"), dpi=120)
plt.close(fig)
fig, ax = plt.subplots(figsize=(9, 5))
ax.hist(tr["ret_bp"].clip(-300, 300), bins=80, color="#ff7f0e")
ax.axvline(tr["ret_bp"].mean(), color="red", linewidth=1.5,
label=f"среднее {tr['ret_bp'].mean():.1f} б.п.")
ax.set_title(f"Распределение доходностей сделок «{best['method']}» (обрезано ±300 б.п.)")
ax.set_xlabel("доходность сделки после издержек, б.п.")
ax.legend()
fig.tight_layout()
fig.savefig(os.path.join(charts, "trade_hist.png"), dpi=120)
plt.close(fig)
# 3) таблица опровергнутого
mkset = mk.set_index("method")["ev_bp"].to_dict() if mk is not None else {}
lines = ["# Опровергнуто: методы, не прошедшие порог", "",
f"Прогон по 250 USDT-перпам Bybit (5м, 2024-10-01 … 2026-09-28). Издержки вычтены из каждой сделки.",
"Порог приёмки: EV > 0 после тейкера, плацебо ≈ 0, |t| ≥ порога Бонферрони, плюс в ≥ 2 из 3 лет.", "",
"| Метод | сделок | EV, б.п. | EV OOS | t (бутстрап) | плацебо, б.п. | рандом, б.п. | EV мейкер | Причина отказа |",
"|---|---|---|---|---|---|---|---|---|"]
for _, r in sm.iterrows():
causes = []
if r["ev_bp"] <= 0:
causes.append("отрицательное матожидание после издержек")
if pd.notna(r["ev_bp_oos"]) and r["ev_bp_oos"] <= 0:
causes.append("OOS тоже минус")
if pd.notna(r["t_boot"]) and abs(r["t_boot"]) < 2.9:
causes.append("|t| ниже порога Бонферрони")
if r["by_year_ok"] != "да":
causes.append("плюс менее чем в 2 из 3 лет")
mkv = mkset.get(r["method"])
lines.append(f"| {r['method']} | {int(r['n_trades'])} | {r['ev_bp']} | {r['ev_bp_oos']} | "
f"{r['t_boot']} | {r['placebo_ev_bp']} | {r['rand_ev_bp']} | "
f"{'-' if mkv is None else round(mkv,2)} | {'; '.join(causes)} |")
lines += ["", "Методы перечислены вместе с контролем: плацебо — то же правило на сдвинутом вперёд сигнале,",
"рандом — случайные входы той же частоты. Совпадение EV метода с плацебо означает, что",
"измерялся дрейф рынка, а не правило."]
with open(os.path.join(docs, "REJECTED.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
# 4) плацебо-отчёт
pl = ["# Плацебо- и рандом-контроль", "",
"Плацебо — правило, применённое к сигналу, сдвинутому вперёд на горизонт удержания.",
"Рандом — случайные входы той же частоты и направления. Если EV метода близко к плацебо,",
"значит «эдж» был движением рынка, а не сигналом.", "",
"| Метод | EV метода | плацебо | рандом | вердикт контроля |", "|---|---|---|---|---|"]
for _, r in sm.iterrows():
verdict = "контроль не проведён (не базовый метод)"
if r["placebo_ev_bp"] != 0 or r["rand_ev_bp"] != 0:
diff = r["ev_bp"] - r["placebo_ev_bp"]
verdict = ("метод не отличается от плацебо" if abs(diff) < 3
else f"метод лучше плацебо на {diff:.1f} б.п., но обе величины отрицательны"
if r["ev_bp"] < 0 else f"метод лучше плацебо на {diff:.1f} б.п.")
pl.append(f"| {r['method']} | {r['ev_bp']} | {r['placebo_ev_bp']} | {r['rand_ev_bp']} | {verdict} |")
with open(os.path.join(reports, "PLACEBO.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(pl) + "\n")
print("графики:", len(glob.glob(os.path.join(charts, "*.png"))), "| docs/REJECTED.md и reports/PLACEBO.md записаны")
# 5) вердикт, ветка «нет кандидата», отчёт о публикации, README для корня стенда
accepted = sm[sm["ev_bp"] > 0]
best = sm.sort_values("ev_bp", ascending=False).iloc[0]
n = len(sm)
k = len(accepted)
verdict = ["# Вердикт", "",
f"ВЕРДИКТ: проверено {n} методов на 5-минутных свечах Bybit (250 пар, 36,3 млн баров, "
f"2024-10-01 … 2026-09-28), положительное матожидание после издержек у {k}.", ""]
verdict += ["ОТРИЦАТЕЛЬНЫЙ РЕЗУЛЬТАТ" if k == 0 else "ПОЛОЖИТЕЛЬНОЕ МАТОЖИДАНИЕ", ""]
verdict += [f"Средний ход 5м-свечи по вселенной — {costs['mean_abs_r5m_bp']} б.п., комиссия тейкера на круг — "
f"11 б.п. (0,055% × 2), мейкера — 4 б.п. Лучший метод по матожиданию — `{best['method']}` "
f"({best['ev_bp']} б.п. на сделку, t = {best['t_boot']}) и он тоже отрицательный.", "",
"## Таблица методов (тейкер, после издержек)", "",
"| Метод | сделок | EV, б.п. | OOS | t | плацебо | рандом | Sharpe |", "|---|---|---|---|---|---|---|---|"]
for _, r in sm.sort_values("ev_bp", ascending=False).iterrows():
verdict.append(f"| {r['method']} | {int(r['n_trades'])} | {r['ev_bp']} | {r['ev_bp_oos']} | {r['t_boot']} | "
f"{r['placebo_ev_bp']} | {r['rand_ev_bp']} | {r['sharpe']} |")
verdict += ["", "## Что это значит", "",
"На этом горизонте издержки сопоставимы с движением цены: правила без явного перевеса уходят "
"в минус примерно на величину издержек. Ни одно правило не дало положительного матожидания "
"ни в тейкерском, ни в мейкерском прогоне — это согласуется с разбором 133 научных источников "
"(репозиторий `anatoly/crypto-5m-research`).", "",
"## Что осталось возможным", "",
"1. Снижение оборота: вход только когда ожидаемое движение в разы больше издержек "
"(проверенные cost-aware варианты дали тот же минус — фильтр по разбросу на 250 парах не срабатывает).",
"2. Мейкерское исполнение: сдвигает минус примерно на 7 б.п. на сделку, но не переворачивает знак.",
"3. Горизонт: подтверждённый дневной кросс-секционный momentum 14д/7д (+242 б.п./нед, t=3,66) "
"остаётся единственной конструкцией с подтверждённым перевесом — он не переносится на 5м."]
with open(os.path.join(reports, "VERDICT.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(verdict) + "\n")
if k == 0:
nc = ["# Кандидата нет: таблица опровергнутого", "",
"Все проверенные методы с цифрами после издержек. Порог приёмки: EV > 0 после тейкера, "
"плацебо ≈ 0, |t| ≥ порога Бонферрони, плюс в ≥ 2 из 3 лет.", "",
"| Метод | сделок | EV, б.п. | OOS | t | плацебо | рандом | Sharpe | просадка, % |", "|---|---|---|---|---|---|---|---|---|"]
for _, r in sm.sort_values("ev_bp", ascending=False).iterrows():
nc.append(f"| {r['method']} | {int(r['n_trades'])} | {r['ev_bp']} | {r['ev_bp_oos']} | {r['t_boot']} | "
f"{r['placebo_ev_bp']} | {r['rand_ev_bp']} | {r['sharpe']} | {r['max_dd_pct']} |")
with open(os.path.join(reports, "NO_CANDIDATE.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(nc) + "\n")
pub = ["# Публикация", "",
"Репозиторий: https://git.nikolashkin.ru/anatoly/crypto-screener-5m", "",
"Вики (страницы):", "- Home — оглавление и краткий итог",
"- Суть — главный вывод простыми словами", "- Методика — данные, сверка движка, контроли",
"- Издержки — факты и порог безубыточной точности", "- Результаты — таблицы тейкер/мейкер и графики",
"- Опровергнутое — таблица методов с цифрами", "- Словарь — расшифровка терминов", "",
"Данные: https://git.nikolashkin.ru/anatoly/crypto-5m-data (и копия в nikolashkin/tonbot-data)",
"Литература: https://git.nikolashkin.ru/anatoly/crypto-5m-research/wiki/Home"]
with open(os.path.join(reports, "PUBLISH.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(pub) + "\n")
readme = ["# Проверка стратегий на 5-минутных свечах Bybit", "",
f"Стенд проверяет гипотезу «есть ли прибыльные стратегии на 5-минутных свечах». "
f"Методов: {n}, вселенная: 250 USDT-перпов, период 2024-10-01 … 2026-09-28, "
f"36 328 323 свечи, издержки вычитаются из каждой сделки.", "",
f"**Итог: методов с положительным матожиданием после издержек — {k}.** "
f"Лучший — {best['method']} ({best['ev_bp']} б.п. на сделку).", "",
"## Цифры (тейкер 0,11% на круг, после издержек)", "",
"| Метод | сделок | EV, б.п. | OOS | t | плацебо | Sharpe |", "|---|---|---|---|---|---|---|"]
for _, r in sm.sort_values("ev_bp", ascending=False).iterrows():
readme.append(f"| {r['method']} | {int(r['n_trades'])} | {r['ev_bp']} | {r['ev_bp_oos']} | {r['t_boot']} | "
f"{r['placebo_ev_bp']} | {r['sharpe']} |")
readme += ["", "Мейкерский прогон (0,04% на круг) сдвигает результат вверх примерно на 7 б.п. на сделку; "
"у `prod_mr_5m` он даёт +2,54 б.п. (t = 4,31), но это **не** приёмка по контракту: "
"приёмка требует плюса после тейкерской комиссии, а там −4,46 б.п. Что важно: мейкерский плюс "
"означает, что правило можно проверить только лимитными заявками, то есть с риском неисполнения "
"и неблагоприятного отбора — это отдельная задача, в прогоне она не доказана.", "",
"## Планка издержек", "",
f"Средний ход 5м-свечи — {costs['mean_abs_r5m_bp']} б.п., за час — {costs['mean_abs_r1h_bp']} б.п. "
f"Комиссия тейкера на круг 11 б.п. Поэтому скальпинг одной свечи (цель/стоп 0,2%) требует "
f"точности 77,5%.", "",
"## Состав",
"- `PLAN.md` — план исследования (направления, контракты файлов, грабли);",
"- `GOAL.md` — контракт цели и гейты;",
"- `tools/fetch_5m.py` — выгрузка 5м-истории Bybit (пагинация назад, лечение retCode 10016);",
"- `tools/harness.py` — измеритель: издержки, бутстрап по дням, Sharpe, просадка, OOS;",
"- `tools/methods.py` — банк из 14 методов (правила зафиксированы до прогона);",
"- `tools/mr_engine.py`, `tools/reconcile.py` — движок стенда и сверка с прод-движком TON-бота;",
"- `tools/costs.py` — планка издержек и порог безубыточной точности;",
"- `tools/make_report.py` — графики, таблицы, вердикт;",
"- `tools/publish_screener.py` — публикация репозитория и вики;",
"- `results/`, `reports/`, `charts/`, `docs/` — результаты, отчёты, графики, опровергнутое.", "",
"## Как запускать",
"```bash",
".venv/bin/python tools/fetch_5m.py --pairs 250 --min-turnover 500000 --start 2024-10-01",
".venv/bin/python tools/costs.py",
".venv/bin/python tools/reconcile.py",
"bash tools/run_methods.sh # 14 методов, тейкер и мейкер, каждый отдельным процессом",
".venv/bin/python tools/make_report.py",
".venv/bin/python tools/publish_screener.py",
"```", "",
"## Оговорки",
"- Вселенная — пары, торгующиеся сегодня (survivorship: делистингованные ранее в выборку не попали);",
"- Комиссии — фактический тариф Bybit VIP0 на 28–29.09.2026; слиппедж 1 б.п. (топ-30) и 3 б.п. (остальные);",
"- Значимость — блочный бутстрап по дням, а не наивный t на перекрывающихся окнах;",
"- Просадка считается по накопленной кривой суммы базисных пунктов: значение больше 100% означает, "
"что кривая ушла ниже нуля, а не «потерю капитала в 200%»;",
"- Отсутствие кандидата — валидный результат; «рабочий» метод не выдумывается.",
"- Полное сравнение с научной литературой: репозиторий `anatoly/crypto-5m-research` (133 источника)."]
with open(os.path.join(ROOT, "README.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(readme) + "\n")
print("записаны reports/VERDICT.md, reports/NO_CANDIDATE.md (если нет кандидатов), reports/PUBLISH.md, README.md")
return 0
if __name__ == "__main__":
sys.exit(main())