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

246 lines
20 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
"""Сборка артефактов Ф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())