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