Archived
ЧТО: code/ (стенд и методы), results/ (сводки), charts/ (3 графика), reports/ (издержки, плацебо, по годам), docs/REJECTED.md. ПОЧЕМУ: цель — найти кандидатов на 5м или доказать с цифрами их отсутствие. КАК ПРОВЕРЕНО: движок сверен с прод-движком TON-бота 1:1; плацебо и рандом-контроль в отчёте; бутстрап по дням.
246 lines
19 KiB
Python
246 lines
19 KiB
Python
#!/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())
|