#!/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())