Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
Backtest: static grid vs adaptive grid (with Kronos advisor).
|
||||
|
||||
Использование:
|
||||
python -m kronos.backtest --csv ./data/BTCUSDT-1h.csv --tf 60 --step 0.005 --levels 10
|
||||
|
||||
Что делает:
|
||||
1) Загружает историю.
|
||||
2) На каждом шаге t (от lookback до конца истории):
|
||||
- запускает Kronos на окне [t-lookback .. t] с прогнозом на pred_len свечей
|
||||
- вычисляет adaptive step
|
||||
- симулирует сделку на следующих M свечей:
|
||||
* static — фиксированный шаг, центр = close[t]
|
||||
* adaptive — шаг от Kronos, центр = close[t] + center_offset
|
||||
3) Сравнивает метрики: PnL, max drawdown, число сделок, % прибыльных.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BTResult:
|
||||
name: str
|
||||
total_pnl_pct: float
|
||||
n_trades: int
|
||||
winrate: float
|
||||
max_dd_pct: float
|
||||
sharpe: float
|
||||
|
||||
|
||||
def simulate_grid(
|
||||
df: pd.DataFrame,
|
||||
start: int,
|
||||
horizon: int,
|
||||
step_pct: float,
|
||||
levels: int,
|
||||
qty: float = 0.001,
|
||||
center_offset_pct: float = 0.0,
|
||||
fee_pct: float = 0.001, # 0.1% taker fee per order (типично для Tradernet)
|
||||
) -> tuple[float, int, int]:
|
||||
"""
|
||||
Простая сеточная симуляция с реалистичной логикой матчинга.
|
||||
|
||||
Правила:
|
||||
- Одна сделка на свечу (BUY или SELL, не обе).
|
||||
- BUY разрешён, если pos_open is None.
|
||||
- SELL разрешён, если pos_open is not None.
|
||||
- При исполнении снимаем комиссию (fee_pct × qty × price).
|
||||
- Возвращаем (pnl_pct, n_roundtrips, n_winning_roundtrips).
|
||||
"""
|
||||
if start >= len(df) - 1:
|
||||
return 0.0, 0, 0
|
||||
|
||||
center = float(df["close"].iloc[start]) * (1.0 + center_offset_pct)
|
||||
step = center * step_pct
|
||||
if step <= 0:
|
||||
return 0.0, 0, 0
|
||||
|
||||
buy_levels = sorted([center - n * step for n in range(1, levels + 1) if center - n * step > 0])
|
||||
sell_levels = sorted([center + n * step for n in range(1, levels + 1)])
|
||||
|
||||
end = min(start + horizon, len(df))
|
||||
pnl = 0.0
|
||||
n_roundtrips = 0
|
||||
n_wins = 0
|
||||
pos_open: Optional[float] = None
|
||||
|
||||
for i in range(start + 1, end):
|
||||
hi = float(df["high"].iloc[i])
|
||||
lo = float(df["low"].iloc[i])
|
||||
|
||||
if pos_open is None:
|
||||
# Ищем ближайший BUY-уровень, который достигнут
|
||||
for bl in buy_levels:
|
||||
if lo <= bl:
|
||||
# fee на вход
|
||||
pnl -= bl * qty * fee_pct
|
||||
pos_open = bl
|
||||
break
|
||||
else:
|
||||
# Ищем ближайший SELL-уровень, который достигнут
|
||||
for sl in sell_levels:
|
||||
if hi >= sl:
|
||||
gross = (sl - pos_open) * qty
|
||||
fee = sl * qty * fee_pct + pos_open * qty * fee_pct
|
||||
net = gross - fee
|
||||
pnl += net
|
||||
if net > 0:
|
||||
n_wins += 1
|
||||
n_roundtrips += 1
|
||||
pos_open = None
|
||||
break
|
||||
|
||||
# PnL в долях от notional (center * qty)
|
||||
pnl_pct = pnl / (center * qty) if center > 0 else 0.0
|
||||
return pnl_pct, n_roundtrips, n_wins
|
||||
|
||||
|
||||
def run_backtest(
|
||||
df: pd.DataFrame,
|
||||
tf_min: int,
|
||||
lookback: int,
|
||||
pred_len: int,
|
||||
static_step_pct: float,
|
||||
levels: int,
|
||||
advisor=None,
|
||||
stride: int = 24,
|
||||
regime_window_hours: int = 24 * 30, # окно для классификации режима (тренд/боковик)
|
||||
) -> list[BTResult]:
|
||||
"""
|
||||
Прогоняет статическую и (если задан advisor) адаптивную сетки.
|
||||
stride — через сколько свечей делаем новый «тик» сетки.
|
||||
|
||||
Дополнительно возвращает _raw_arrays в results[0].__dict__:
|
||||
- regimes_at_t: список ('trending'|'sideways') для каждого тика
|
||||
- static_pnls_per_t, adapt_pnls_per_t
|
||||
"""
|
||||
n = len(df)
|
||||
if n < lookback + pred_len + 1:
|
||||
raise ValueError(f"need at least {lookback + pred_len + 1} candles, got {n}")
|
||||
|
||||
static_pnls, static_n, static_w = [], [], []
|
||||
adapt_pnls, adapt_n, adapt_w = [], [], []
|
||||
regimes = []
|
||||
pause_hits = 0
|
||||
|
||||
peak_equity_s, peak_equity_a = 1.0, 1.0
|
||||
eq_s, eq_a = 1.0, 1.0
|
||||
max_dd_s, max_dd_a = 0.0, 0.0
|
||||
|
||||
for t in range(lookback, n - pred_len, stride):
|
||||
horizon = min(pred_len, stride * 2)
|
||||
center = float(df["close"].iloc[t])
|
||||
|
||||
# Режим рынка: в окне [t-regime_window, t] считаем |return|
|
||||
win_start = max(0, t - regime_window_hours)
|
||||
win = df["close"].iloc[win_start:t + 1]
|
||||
ret = (win.iloc[-1] - win.iloc[0]) / win.iloc[0] if len(win) > 1 and win.iloc[0] > 0 else 0.0
|
||||
regime = "trending" if abs(ret) > 0.05 else "sideways" # ±5% за месяц → тренд
|
||||
regimes.append(regime)
|
||||
|
||||
# STATIC
|
||||
s_pnl, s_nt, s_w = simulate_grid(
|
||||
df, t, horizon,
|
||||
step_pct=static_step_pct,
|
||||
levels=levels,
|
||||
center_offset_pct=0.0,
|
||||
)
|
||||
static_pnls.append(s_pnl)
|
||||
static_n.append(s_nt)
|
||||
static_w.append(s_w)
|
||||
eq_s *= (1.0 + s_pnl)
|
||||
peak_equity_s = max(peak_equity_s, eq_s)
|
||||
dd = (eq_s - peak_equity_s) / peak_equity_s
|
||||
max_dd_s = min(max_dd_s, dd)
|
||||
|
||||
# ADAPTIVE
|
||||
if advisor is not None:
|
||||
try:
|
||||
advice = advisor.advise(df.iloc[:t + 1], lookback=lookback, pred_len=pred_len, tf_min=tf_min)
|
||||
if advice.pause_grid and advice.confidence > 0.5:
|
||||
pause_hits += 1
|
||||
adapt_pnls.append(0.0)
|
||||
adapt_n.append(0)
|
||||
adapt_w.append(0)
|
||||
else:
|
||||
a_pnl, a_nt, a_w = simulate_grid(
|
||||
df, t, horizon,
|
||||
step_pct=max(static_step_pct * 0.5, advice.step_percent),
|
||||
levels=levels,
|
||||
center_offset_pct=advice.center_offset_pct,
|
||||
)
|
||||
adapt_pnls.append(a_pnl)
|
||||
adapt_n.append(a_nt)
|
||||
adapt_w.append(a_w)
|
||||
eq_a *= (1.0 + a_pnl)
|
||||
peak_equity_a = max(peak_equity_a, eq_a)
|
||||
dd = (eq_a - peak_equity_a) / peak_equity_a
|
||||
max_dd_a = min(max_dd_a, dd)
|
||||
except Exception as e:
|
||||
logger.warning("Advisor failed at t=%d: %s", t, e)
|
||||
continue
|
||||
|
||||
def _agg(name, pnls, ns, ws, dd):
|
||||
if not pnls:
|
||||
return BTResult(name, 0.0, 0, 0.0, 0.0, 0.0)
|
||||
total = float(np.prod(1.0 + np.array(pnls)) - 1.0)
|
||||
n_total = sum(ns)
|
||||
n_w = sum(ws)
|
||||
wr = n_w / n_total if n_total else 0.0
|
||||
if len(pnls) > 1:
|
||||
sharpe = float(np.mean(pnls) / (np.std(pnls) + 1e-9) * math.sqrt(252 / max(1, len(pnls) // 24)))
|
||||
else:
|
||||
sharpe = 0.0
|
||||
return BTResult(name, total, n_total, wr, dd, sharpe)
|
||||
|
||||
results = [_agg("static", static_pnls, static_n, static_w, max_dd_s)]
|
||||
if advisor is not None:
|
||||
results.append(_agg("adaptive", adapt_pnls, adapt_n, adapt_w, max_dd_a))
|
||||
# Метрики по режимам
|
||||
for regime in ("trending", "sideways"):
|
||||
mask = [i for i, r in enumerate(regimes[:len(static_pnls)]) if r == regime]
|
||||
if not mask:
|
||||
continue
|
||||
s_pnls_r = [static_pnls[i] for i in mask]
|
||||
a_pnls_r = [adapt_pnls[i] for i in mask]
|
||||
s_total = float(np.prod(1.0 + np.array(s_pnls_r)) - 1.0) if s_pnls_r else 0.0
|
||||
a_total = float(np.prod(1.0 + np.array(a_pnls_r)) - 1.0) if a_pnls_r else 0.0
|
||||
logger.info(
|
||||
"regime=%-9s n_ticks=%-4d static_pnl=%+7.2f%% adaptive_pnl=%+7.2f%% delta=%+.2f%% pause_hits_total=%d",
|
||||
regime, len(mask), s_total * 100, a_total * 100,
|
||||
(a_total - s_total) * 100, pause_hits,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--csv", required=True, help="CSV с колонками open,high,low,close,volume")
|
||||
ap.add_argument("--tf", type=int, default=60)
|
||||
ap.add_argument("--lookback", type=int, default=400)
|
||||
ap.add_argument("--pred-len", type=int, default=24)
|
||||
ap.add_argument("--step", type=float, default=0.005)
|
||||
ap.add_argument("--levels", type=int, default=10)
|
||||
ap.add_argument("--stride", type=int, default=24)
|
||||
ap.add_argument("--with-advisor", action="store_true")
|
||||
ap.add_argument("--model", default="kronos-mini")
|
||||
ap.add_argument("--device", default="cpu")
|
||||
ap.add_argument("--out", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
log = logging.getLogger("backtest")
|
||||
|
||||
df = pd.read_csv(args.csv)
|
||||
# нормализуем имена колонок
|
||||
df.columns = [c.strip().lower() for c in df.columns]
|
||||
if "timestamps" in df.columns:
|
||||
df["timestamps"] = pd.to_datetime(df["timestamps"])
|
||||
df = df.set_index("timestamps")
|
||||
elif "timestamp" in df.columns:
|
||||
df["timestamps"] = pd.to_datetime(df["timestamp"])
|
||||
df = df.set_index("timestamps")
|
||||
elif "open_time" in df.columns:
|
||||
df.index = pd.to_datetime(df["open_time"], unit="ms", utc=True).tz_convert(None)
|
||||
elif "date" in df.columns:
|
||||
df["timestamps"] = pd.to_datetime(df["date"])
|
||||
df = df.set_index("timestamps")
|
||||
df = df[["open", "high", "low", "close", "volume"]].astype(float).sort_index()
|
||||
log.info("Loaded %d candles, last close=%.2f", len(df), float(df["close"].iloc[-1]))
|
||||
|
||||
advisor = None
|
||||
if args.with_advisor:
|
||||
from kronos import KronosAdvisor
|
||||
advisor = KronosAdvisor(model_name=args.model, device=args.device)
|
||||
log.info("Loaded advisor %s", args.model)
|
||||
|
||||
results = run_backtest(
|
||||
df,
|
||||
tf_min=args.tf,
|
||||
lookback=args.lookback,
|
||||
pred_len=args.pred_len,
|
||||
static_step_pct=args.step,
|
||||
levels=args.levels,
|
||||
advisor=advisor,
|
||||
stride=args.stride,
|
||||
)
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print(f"{'MODE':<12} {'PnL %':>10} {'#Trades':>10} {'WinRate':>10} {'MaxDD %':>10} {'Sharpe':>10}")
|
||||
print("-" * 72)
|
||||
for r in results:
|
||||
print(f"{r.name:<12} {r.total_pnl_pct*100:>9.2f}% {r.n_trades:>10d} "
|
||||
f"{r.winrate*100:>9.1f}% {r.max_dd_pct*100:>9.2f}% {r.sharpe:>9.2f}")
|
||||
print("=" * 72)
|
||||
|
||||
if args.out:
|
||||
import json
|
||||
Path(args.out).write_text(json.dumps([r.__dict__ for r in results], indent=2))
|
||||
log.info("Wrote %s", args.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user