haha
This commit is contained in:
1
strategy/__init__.py
Normal file
1
strategy/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .bb_backtest import BBConfig, BBResult, BBTrade, run_bb_backtest
|
||||
315
strategy/bb_backtest.py
Normal file
315
strategy/bb_backtest.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Bollinger Band mean-reversion strategy backtest.
|
||||
|
||||
Logic:
|
||||
- Price touches upper BB → close any long, open short
|
||||
- Price touches lower BB → close any short, open long
|
||||
- Always in position (flip between long and short)
|
||||
|
||||
Uses 5-minute OHLC data from the database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .data_loader import KlineSource, load_klines
|
||||
from .indicators import bollinger
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config & result types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class BBConfig:
|
||||
# Bollinger Band parameters
|
||||
bb_period: int = 20 # SMA window
|
||||
bb_std: float = 2.0 # standard deviation multiplier
|
||||
|
||||
# Position sizing
|
||||
margin_per_trade: float = 80.0
|
||||
leverage: float = 100.0
|
||||
initial_capital: float = 1000.0
|
||||
|
||||
# Risk management
|
||||
max_daily_loss: float = 150.0 # stop trading after this daily loss
|
||||
stop_loss_pct: float = 0.0 # 0 = disabled; e.g. 0.02 = 2% SL from entry
|
||||
|
||||
# Dynamic sizing: if > 0, margin = equity * margin_pct (overrides margin_per_trade)
|
||||
margin_pct: float = 0.0 # e.g. 0.01 = 1% of equity per trade
|
||||
|
||||
# Fee structure (taker)
|
||||
fee_rate: float = 0.0006 # 0.06%
|
||||
rebate_rate: float = 0.0 # instant maker rebate (if any)
|
||||
|
||||
# Delayed rebate: rebate_pct of daily fees returned next day at rebate_hour UTC
|
||||
rebate_pct: float = 0.0 # e.g. 0.70 = 70% rebate
|
||||
rebate_hour_utc: int = 0 # hour in UTC when rebate arrives (0 = 8am UTC+8)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BBTrade:
|
||||
side: str # "long" or "short"
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
entry_time: object # pd.Timestamp
|
||||
exit_time: object
|
||||
margin: float
|
||||
leverage: float
|
||||
qty: float
|
||||
gross_pnl: float
|
||||
fee: float
|
||||
net_pnl: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BBResult:
|
||||
equity_curve: pd.DataFrame # columns: equity, balance, price, position
|
||||
trades: List[BBTrade]
|
||||
daily_stats: pd.DataFrame # daily equity + pnl
|
||||
total_fee: float
|
||||
total_rebate: float
|
||||
config: BBConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backtest engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult:
|
||||
"""Run Bollinger Band mean-reversion backtest on 5m OHLC data."""
|
||||
|
||||
close = df["close"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
n = len(df)
|
||||
|
||||
# Compute Bollinger Bands
|
||||
bb_mid, bb_upper, bb_lower, bb_width = bollinger(close, cfg.bb_period, cfg.bb_std)
|
||||
|
||||
# Convert to numpy for speed
|
||||
arr_close = close.values
|
||||
arr_high = high.values
|
||||
arr_low = low.values
|
||||
arr_upper = bb_upper.values
|
||||
arr_lower = bb_lower.values
|
||||
ts_index = df.index
|
||||
|
||||
# State
|
||||
balance = cfg.initial_capital
|
||||
position = 0 # +1 = long, -1 = short, 0 = flat
|
||||
entry_price = 0.0
|
||||
entry_time = None
|
||||
entry_margin = 0.0
|
||||
entry_qty = 0.0
|
||||
|
||||
trades: List[BBTrade] = []
|
||||
total_fee = 0.0
|
||||
total_rebate = 0.0
|
||||
|
||||
# Daily tracking
|
||||
day_pnl = 0.0
|
||||
day_stopped = False
|
||||
current_day = None
|
||||
|
||||
# Delayed rebate tracking
|
||||
pending_rebate = 0.0 # fees from previous day to be rebated
|
||||
today_fees = 0.0 # fees accumulated today
|
||||
rebate_applied_today = False
|
||||
|
||||
# Output arrays
|
||||
out_equity = np.full(n, np.nan)
|
||||
out_balance = np.full(n, np.nan)
|
||||
out_position = np.zeros(n)
|
||||
|
||||
def unrealised(price):
|
||||
if position == 0:
|
||||
return 0.0
|
||||
if position == 1:
|
||||
return entry_qty * (price - entry_price)
|
||||
else:
|
||||
return entry_qty * (entry_price - price)
|
||||
|
||||
def close_position(exit_price, exit_idx):
|
||||
nonlocal balance, position, entry_price, entry_time, entry_margin, entry_qty
|
||||
nonlocal total_fee, total_rebate, day_pnl, today_fees
|
||||
|
||||
if position == 0:
|
||||
return
|
||||
|
||||
if position == 1:
|
||||
gross = entry_qty * (exit_price - entry_price)
|
||||
else:
|
||||
gross = entry_qty * (entry_price - exit_price)
|
||||
|
||||
exit_notional = entry_qty * exit_price
|
||||
fee = exit_notional * cfg.fee_rate
|
||||
rebate = exit_notional * cfg.rebate_rate # instant rebate only
|
||||
net = gross - fee + rebate
|
||||
|
||||
trades.append(BBTrade(
|
||||
side="long" if position == 1 else "short",
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
entry_time=entry_time,
|
||||
exit_time=ts_index[exit_idx],
|
||||
margin=entry_margin,
|
||||
leverage=cfg.leverage,
|
||||
qty=entry_qty,
|
||||
gross_pnl=gross,
|
||||
fee=fee,
|
||||
net_pnl=net,
|
||||
))
|
||||
|
||||
balance += net
|
||||
total_fee += fee
|
||||
total_rebate += rebate
|
||||
today_fees += fee
|
||||
day_pnl += net
|
||||
position = 0
|
||||
entry_price = 0.0
|
||||
entry_time = None
|
||||
entry_margin = 0.0
|
||||
entry_qty = 0.0
|
||||
|
||||
def open_position(side, price, idx):
|
||||
nonlocal position, entry_price, entry_time, entry_margin, entry_qty
|
||||
nonlocal balance, total_fee, day_pnl, today_fees
|
||||
|
||||
if cfg.margin_pct > 0:
|
||||
equity = balance + unrealised(price) if position != 0 else balance
|
||||
margin = equity * cfg.margin_pct
|
||||
else:
|
||||
margin = cfg.margin_per_trade
|
||||
margin = min(margin, balance * 0.95)
|
||||
if margin <= 0:
|
||||
return
|
||||
notional = margin * cfg.leverage
|
||||
qty = notional / price
|
||||
fee = notional * cfg.fee_rate
|
||||
|
||||
balance -= fee
|
||||
total_fee += fee
|
||||
today_fees += fee
|
||||
day_pnl -= fee
|
||||
|
||||
position = 1 if side == "long" else -1
|
||||
entry_price = price
|
||||
entry_time = ts_index[idx]
|
||||
entry_margin = margin
|
||||
entry_qty = qty
|
||||
|
||||
# Main loop
|
||||
for i in range(n):
|
||||
# Daily reset + delayed rebate
|
||||
bar_day = ts_index[i].date() if hasattr(ts_index[i], 'date') else None
|
||||
bar_hour = ts_index[i].hour if hasattr(ts_index[i], 'hour') else 0
|
||||
if bar_day is not None and bar_day != current_day:
|
||||
# New day: move today's fees to pending, reset
|
||||
if cfg.rebate_pct > 0:
|
||||
pending_rebate = today_fees * cfg.rebate_pct
|
||||
today_fees = 0.0
|
||||
rebate_applied_today = False
|
||||
day_pnl = 0.0
|
||||
day_stopped = False
|
||||
current_day = bar_day
|
||||
|
||||
# Apply delayed rebate at specified hour
|
||||
if cfg.rebate_pct > 0 and not rebate_applied_today and bar_hour >= cfg.rebate_hour_utc and pending_rebate > 0:
|
||||
balance += pending_rebate
|
||||
total_rebate += pending_rebate
|
||||
pending_rebate = 0.0
|
||||
rebate_applied_today = True
|
||||
|
||||
# Skip if BB not ready
|
||||
if np.isnan(arr_upper[i]) or np.isnan(arr_lower[i]):
|
||||
out_equity[i] = balance + unrealised(arr_close[i])
|
||||
out_balance[i] = balance
|
||||
out_position[i] = position
|
||||
continue
|
||||
|
||||
# Daily loss check
|
||||
if day_stopped:
|
||||
out_equity[i] = balance + unrealised(arr_close[i])
|
||||
out_balance[i] = balance
|
||||
out_position[i] = position
|
||||
continue
|
||||
|
||||
cur_equity = balance + unrealised(arr_close[i])
|
||||
if day_pnl + unrealised(arr_close[i]) <= -cfg.max_daily_loss:
|
||||
close_position(arr_close[i], i)
|
||||
day_stopped = True
|
||||
out_equity[i] = balance
|
||||
out_balance[i] = balance
|
||||
out_position[i] = 0
|
||||
continue
|
||||
|
||||
# Stop loss check
|
||||
if position != 0 and cfg.stop_loss_pct > 0:
|
||||
if position == 1 and arr_low[i] <= entry_price * (1 - cfg.stop_loss_pct):
|
||||
sl_price = entry_price * (1 - cfg.stop_loss_pct)
|
||||
close_position(sl_price, i)
|
||||
elif position == -1 and arr_high[i] >= entry_price * (1 + cfg.stop_loss_pct):
|
||||
sl_price = entry_price * (1 + cfg.stop_loss_pct)
|
||||
close_position(sl_price, i)
|
||||
|
||||
# Signal detection: use high/low to check if price touched BB
|
||||
touched_upper = arr_high[i] >= arr_upper[i]
|
||||
touched_lower = arr_low[i] <= arr_lower[i]
|
||||
|
||||
if touched_upper and touched_lower:
|
||||
# Both touched in same bar (wide bar) — skip, too volatile
|
||||
pass
|
||||
elif touched_upper:
|
||||
# Price touched upper BB → go short
|
||||
if position == 1:
|
||||
# Close long at upper BB price
|
||||
close_position(arr_upper[i], i)
|
||||
if position != -1:
|
||||
# Open short
|
||||
open_position("short", arr_upper[i], i)
|
||||
elif touched_lower:
|
||||
# Price touched lower BB → go long
|
||||
if position == -1:
|
||||
# Close short at lower BB price
|
||||
close_position(arr_lower[i], i)
|
||||
if position != 1:
|
||||
# Open long
|
||||
open_position("long", arr_lower[i], i)
|
||||
|
||||
# Record equity
|
||||
out_equity[i] = balance + unrealised(arr_close[i])
|
||||
out_balance[i] = balance
|
||||
out_position[i] = position
|
||||
|
||||
# Force close at end
|
||||
if position != 0:
|
||||
close_position(arr_close[n - 1], n - 1)
|
||||
out_equity[n - 1] = balance
|
||||
out_balance[n - 1] = balance
|
||||
out_position[n - 1] = 0
|
||||
|
||||
# Build equity DataFrame
|
||||
eq_df = pd.DataFrame({
|
||||
"equity": out_equity,
|
||||
"balance": out_balance,
|
||||
"price": arr_close,
|
||||
"position": out_position,
|
||||
}, index=ts_index)
|
||||
|
||||
# Daily stats
|
||||
daily_eq = eq_df["equity"].resample("1D").last().dropna().to_frame("equity")
|
||||
daily_eq["pnl"] = daily_eq["equity"].diff().fillna(0.0)
|
||||
|
||||
return BBResult(
|
||||
equity_curve=eq_df,
|
||||
trades=trades,
|
||||
daily_stats=daily_eq,
|
||||
total_fee=total_fee,
|
||||
total_rebate=total_rebate,
|
||||
config=cfg,
|
||||
)
|
||||
53
strategy/data_loader.py
Normal file
53
strategy/data_loader.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
import sqlite3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KlineSource:
|
||||
db_path: Path
|
||||
table_name: str
|
||||
|
||||
|
||||
def _to_ms(dt: datetime) -> int:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
def load_klines(
|
||||
source: KlineSource,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> pd.DataFrame:
|
||||
start_ms = _to_ms(start)
|
||||
end_ms = _to_ms(end)
|
||||
|
||||
con = sqlite3.connect(str(source.db_path))
|
||||
try:
|
||||
df = pd.read_sql_query(
|
||||
f"SELECT id, open, high, low, close FROM {source.table_name} WHERE id >= ? AND id <= ? ORDER BY id ASC",
|
||||
con,
|
||||
params=(start_ms, end_ms),
|
||||
)
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
df["timestamp_ms"] = df["id"].astype("int64")
|
||||
df["dt"] = pd.to_datetime(df["timestamp_ms"], unit="ms", utc=True)
|
||||
df = df.drop(columns=["id"]).set_index("dt")
|
||||
|
||||
for c in ("open", "high", "low", "close"):
|
||||
df[c] = pd.to_numeric(df[c], errors="coerce")
|
||||
|
||||
df = df.dropna(subset=["open", "high", "low", "close"])
|
||||
return df
|
||||
104
strategy/indicators.py
Normal file
104
strategy/indicators.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def ema(s: pd.Series, span: int) -> pd.Series:
|
||||
return s.ewm(span=span, adjust=False).mean()
|
||||
|
||||
|
||||
def rsi(close: pd.Series, period: int) -> pd.Series:
|
||||
delta = close.diff()
|
||||
up = delta.clip(lower=0.0)
|
||||
down = (-delta).clip(lower=0.0)
|
||||
|
||||
roll_up = up.ewm(alpha=1 / period, adjust=False).mean()
|
||||
roll_down = down.ewm(alpha=1 / period, adjust=False).mean()
|
||||
|
||||
rs = roll_up / roll_down.replace(0.0, np.nan)
|
||||
return 100.0 - (100.0 / (1.0 + rs))
|
||||
|
||||
|
||||
def atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int) -> pd.Series:
|
||||
prev_close = close.shift(1)
|
||||
tr = pd.concat(
|
||||
[
|
||||
(high - low).abs(),
|
||||
(high - prev_close).abs(),
|
||||
(low - prev_close).abs(),
|
||||
],
|
||||
axis=1,
|
||||
).max(axis=1)
|
||||
return tr.ewm(alpha=1 / period, adjust=False).mean()
|
||||
|
||||
|
||||
def bollinger(close: pd.Series, window: int, n_std: float):
|
||||
mid = close.rolling(window=window, min_periods=window).mean()
|
||||
std = close.rolling(window=window, min_periods=window).std(ddof=0)
|
||||
upper = mid + n_std * std
|
||||
lower = mid - n_std * std
|
||||
width = (upper - lower) / mid
|
||||
return mid, upper, lower, width
|
||||
|
||||
|
||||
def macd(close: pd.Series, fast: int, slow: int, signal: int):
|
||||
fast_ema = ema(close, fast)
|
||||
slow_ema = ema(close, slow)
|
||||
line = fast_ema - slow_ema
|
||||
sig = ema(line, signal)
|
||||
hist = line - sig
|
||||
return line, sig, hist
|
||||
|
||||
|
||||
def stochastic(high: pd.Series, low: pd.Series, close: pd.Series,
|
||||
k_period: int = 14, d_period: int = 3):
|
||||
"""Stochastic Oscillator (%K and %D)."""
|
||||
lowest = low.rolling(window=k_period, min_periods=k_period).min()
|
||||
highest = high.rolling(window=k_period, min_periods=k_period).max()
|
||||
denom = highest - lowest
|
||||
k = 100.0 * (close - lowest) / denom.replace(0.0, np.nan)
|
||||
d = k.rolling(window=d_period, min_periods=d_period).mean()
|
||||
return k, d
|
||||
|
||||
|
||||
def cci(high: pd.Series, low: pd.Series, close: pd.Series,
|
||||
period: int = 20) -> pd.Series:
|
||||
"""Commodity Channel Index."""
|
||||
tp = (high + low + close) / 3.0
|
||||
sma = tp.rolling(window=period, min_periods=period).mean()
|
||||
mad = tp.rolling(window=period, min_periods=period).apply(
|
||||
lambda x: np.mean(np.abs(x - np.mean(x))), raw=True
|
||||
)
|
||||
return (tp - sma) / (0.015 * mad.replace(0.0, np.nan))
|
||||
|
||||
|
||||
def adx(high: pd.Series, low: pd.Series, close: pd.Series,
|
||||
period: int = 14) -> pd.Series:
|
||||
"""Average Directional Index (returns ADX line only)."""
|
||||
up_move = high.diff()
|
||||
down_move = -low.diff()
|
||||
|
||||
plus_dm = pd.Series(np.where((up_move > down_move) & (up_move > 0), up_move, 0.0),
|
||||
index=high.index)
|
||||
minus_dm = pd.Series(np.where((down_move > up_move) & (down_move > 0), down_move, 0.0),
|
||||
index=high.index)
|
||||
|
||||
atr_val = atr(high, low, close, period)
|
||||
|
||||
plus_di = 100.0 * plus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr_val.replace(0.0, np.nan)
|
||||
minus_di = 100.0 * minus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr_val.replace(0.0, np.nan)
|
||||
|
||||
dx = 100.0 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0.0, np.nan)
|
||||
adx_line = dx.ewm(alpha=1 / period, adjust=False).mean()
|
||||
return adx_line
|
||||
|
||||
|
||||
def keltner_channel(high: pd.Series, low: pd.Series, close: pd.Series,
|
||||
ema_period: int = 20, atr_period: int = 14, atr_mult: float = 1.5):
|
||||
"""Keltner Channel (mid, upper, lower)."""
|
||||
mid = ema(close, ema_period)
|
||||
atr_val = atr(high, low, close, atr_period)
|
||||
upper = mid + atr_mult * atr_val
|
||||
lower = mid - atr_mult * atr_val
|
||||
return mid, upper, lower
|
||||
BIN
strategy/results/bb_200u_2025.png
Normal file
BIN
strategy/results/bb_200u_2025.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
BIN
strategy/results/bb_200u_2026.png
Normal file
BIN
strategy/results/bb_200u_2026.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
strategy/results/bb_2025_report.png
Normal file
BIN
strategy/results/bb_2025_report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
BIN
strategy/results/bb_79rebate_report.png
Normal file
BIN
strategy/results/bb_79rebate_report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
BIN
strategy/results/bb_strategy_report.png
Normal file
BIN
strategy/results/bb_strategy_report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
199
strategy/run_bb_backtest.py
Normal file
199
strategy/run_bb_backtest.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Run Bollinger Band mean-reversion backtest on ETH 2023+2024.
|
||||
|
||||
Preloads data once, then sweeps parameters in-memory for speed.
|
||||
"""
|
||||
import sys, time
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1]))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
from strategy.bb_backtest import BBConfig, run_bb_backtest
|
||||
from strategy.data_loader import KlineSource, load_klines
|
||||
from datetime import datetime, timezone
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
src = KlineSource(db_path=root / "models" / "database.db", table_name="bitmart_eth_5m")
|
||||
out_dir = root / "strategy" / "results"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# Preload data once
|
||||
print("Loading data...")
|
||||
df_23 = load_klines(src, datetime(2023,1,1,tzinfo=timezone.utc),
|
||||
datetime(2023,12,31,23,59,tzinfo=timezone.utc))
|
||||
df_24 = load_klines(src, datetime(2024,1,1,tzinfo=timezone.utc),
|
||||
datetime(2024,12,31,23,59,tzinfo=timezone.utc))
|
||||
data = {2023: df_23, 2024: df_24}
|
||||
print(f"Loaded: 2023={len(df_23)} bars, 2024={len(df_24)} bars ({time.time()-t0:.1f}s)")
|
||||
|
||||
# ================================================================
|
||||
# Sweep
|
||||
# ================================================================
|
||||
print("\n" + "=" * 120)
|
||||
print(" Bollinger Band Mean-Reversion — ETH 5min | 1000U capital")
|
||||
print(" touch upper BB -> short, touch lower BB -> long (flip)")
|
||||
print("=" * 120)
|
||||
|
||||
results = []
|
||||
|
||||
def test(label, cfg):
|
||||
"""Run on both years, print summary, store results."""
|
||||
row = {"label": label, "cfg": cfg}
|
||||
for year in [2023, 2024]:
|
||||
r = run_bb_backtest(data[year], cfg)
|
||||
d = r.daily_stats
|
||||
pnl = d["pnl"].astype(float)
|
||||
eq = d["equity"].astype(float)
|
||||
dd = float((eq - eq.cummax()).min())
|
||||
final = float(eq.iloc[-1])
|
||||
nt = len(r.trades)
|
||||
wr = sum(1 for t in r.trades if t.net_pnl > 0) / max(nt, 1) * 100
|
||||
nf = r.total_fee - r.total_rebate
|
||||
row[f"a{year}"] = float(pnl.mean())
|
||||
row[f"d{year}"] = dd
|
||||
row[f"r{year}"] = r
|
||||
row[f"n{year}"] = nt
|
||||
row[f"w{year}"] = wr
|
||||
row[f"f{year}"] = nf
|
||||
row[f"eq{year}"] = final
|
||||
mn = min(row["a2023"], row["a2024"])
|
||||
avg = (row["a2023"] + row["a2024"]) / 2
|
||||
mark = " <<<" if mn >= 20 else (" **" if mn >= 10 else "")
|
||||
print(f" {label:52s} 23:{row['a2023']:+6.1f} 24:{row['a2024']:+6.1f} "
|
||||
f"avg:{avg:+5.1f} n23:{row['n2023']:3d} n24:{row['n2024']:3d} "
|
||||
f"dd:{min(row['d2023'],row['d2024']):+7.0f}{mark}")
|
||||
row["mn"] = mn; row["avg"] = avg
|
||||
results.append(row)
|
||||
|
||||
# [1] BB period
|
||||
print("\n[1] Period sweep")
|
||||
for p in [10, 15, 20, 30, 40]:
|
||||
test(f"BB({p},2.0) 80u 100x", BBConfig(bb_period=p, bb_std=2.0, margin_per_trade=80, leverage=100))
|
||||
|
||||
# [2] BB std
|
||||
print("\n[2] Std sweep")
|
||||
for s in [1.5, 1.8, 2.0, 2.5, 3.0]:
|
||||
test(f"BB(20,{s}) 80u 100x", BBConfig(bb_period=20, bb_std=s, margin_per_trade=80, leverage=100))
|
||||
|
||||
# [3] Margin
|
||||
print("\n[3] Margin sweep")
|
||||
for m in [40, 60, 80, 100, 120]:
|
||||
test(f"BB(20,2.0) {m}u 100x", BBConfig(bb_period=20, bb_std=2.0, margin_per_trade=m, leverage=100))
|
||||
|
||||
# [4] SL
|
||||
print("\n[4] Stop-loss sweep")
|
||||
for sl in [0.0, 0.01, 0.02, 0.03, 0.05]:
|
||||
test(f"BB(20,2.0) 80u SL={sl:.0%}", BBConfig(bb_period=20, bb_std=2.0, margin_per_trade=80, leverage=100, stop_loss_pct=sl))
|
||||
|
||||
# [5] MDL
|
||||
print("\n[5] Max daily loss")
|
||||
for mdl in [50, 100, 150, 200]:
|
||||
test(f"BB(20,2.0) 80u mdl={mdl}", BBConfig(bb_period=20, bb_std=2.0, margin_per_trade=80, leverage=100, max_daily_loss=mdl))
|
||||
|
||||
# [6] Combined fine-tune
|
||||
print("\n[6] Fine-tune")
|
||||
for p in [15, 20, 30]:
|
||||
for s in [1.5, 2.0, 2.5]:
|
||||
for m in [80, 100]:
|
||||
test(f"BB({p},{s}) {m}u mdl=150",
|
||||
BBConfig(bb_period=p, bb_std=s, margin_per_trade=m, leverage=100, max_daily_loss=150))
|
||||
|
||||
# ================================================================
|
||||
# Ranking
|
||||
# ================================================================
|
||||
results.sort(key=lambda x: x["mn"], reverse=True)
|
||||
print(f"\n{'='*120}")
|
||||
print(f" TOP 10 — ranked by min(daily_avg_2023, daily_avg_2024)")
|
||||
print(f"{'='*120}")
|
||||
for i, r in enumerate(results[:10]):
|
||||
print(f" {i+1:2d}. {r['label']:50s} 23:{r['a2023']:+6.1f} 24:{r['a2024']:+6.1f} "
|
||||
f"min:{r['mn']:+6.1f} dd:{min(r['d2023'],r['d2024']):+7.0f} "
|
||||
f"wr23:{r['w2023']:.0f}% wr24:{r['w2024']:.0f}%")
|
||||
|
||||
# ================================================================
|
||||
# Detailed report for best
|
||||
# ================================================================
|
||||
best = results[0]
|
||||
print(f"\n{'#'*70}")
|
||||
print(f" BEST: {best['label']}")
|
||||
print(f"{'#'*70}")
|
||||
|
||||
for year in [2023, 2024]:
|
||||
r = best[f"r{year}"]
|
||||
cfg = best["cfg"]
|
||||
d = r.daily_stats
|
||||
pnl = d["pnl"].astype(float)
|
||||
eq = d["equity"].astype(float)
|
||||
dd = (eq - eq.cummax()).min()
|
||||
final = float(eq.iloc[-1])
|
||||
nt = len(r.trades)
|
||||
wr = sum(1 for t in r.trades if t.net_pnl > 0) / max(nt, 1)
|
||||
nf = r.total_fee - r.total_rebate
|
||||
|
||||
loss_streak = max_ls = 0
|
||||
for v in pnl.values:
|
||||
if v < 0: loss_streak += 1; max_ls = max(max_ls, loss_streak)
|
||||
else: loss_streak = 0
|
||||
|
||||
print(f"\n --- {year} ---")
|
||||
print(f" Final equity : {final:,.2f} U ({final-cfg.initial_capital:+,.2f}, "
|
||||
f"{(final-cfg.initial_capital)/cfg.initial_capital*100:+.1f}%)")
|
||||
print(f" Max drawdown : {dd:,.2f} U")
|
||||
print(f" Avg daily PnL : {pnl.mean():+,.2f} U")
|
||||
print(f" Median daily PnL : {pnl.median():+,.2f} U")
|
||||
print(f" Best/worst day : {pnl.max():+,.2f} / {pnl.min():+,.2f}")
|
||||
print(f" Profitable days : {(pnl>0).sum()}/{len(pnl)} ({(pnl>0).mean():.1%})")
|
||||
print(f" Days >= 20U : {(pnl>=20).sum()}")
|
||||
print(f" Max loss streak : {max_ls} days")
|
||||
print(f" Trades : {nt} (win rate {wr:.1%})")
|
||||
print(f" Net fees : {nf:,.0f} U")
|
||||
sharpe = pnl.mean() / max(pnl.std(), 1e-10) * np.sqrt(365)
|
||||
print(f" Sharpe (annual) : {sharpe:.2f}")
|
||||
|
||||
# ================================================================
|
||||
# Chart
|
||||
# ================================================================
|
||||
fig, axes = plt.subplots(3, 2, figsize=(18, 12),
|
||||
gridspec_kw={"height_ratios": [3, 1.5, 1]})
|
||||
|
||||
for col, year in enumerate([2023, 2024]):
|
||||
r = best[f"r{year}"]
|
||||
cfg = best["cfg"]
|
||||
d = r.daily_stats
|
||||
eq = d["equity"].astype(float)
|
||||
pnl = d["pnl"].astype(float)
|
||||
dd = eq - eq.cummax()
|
||||
|
||||
axes[0, col].plot(eq.index, eq.values, linewidth=1.2, color="#1f77b4")
|
||||
axes[0, col].axhline(cfg.initial_capital, color="gray", ls="--", lw=0.5)
|
||||
axes[0, col].set_title(f"BB Strategy Equity — {year}\n"
|
||||
f"BB({cfg.bb_period},{cfg.bb_std}) {cfg.margin_per_trade}u {cfg.leverage:.0f}x",
|
||||
fontsize=11)
|
||||
axes[0, col].set_ylabel("Equity (U)")
|
||||
axes[0, col].grid(True, alpha=0.3)
|
||||
|
||||
colors = ["#2ca02c" if v >= 0 else "#d62728" for v in pnl.values]
|
||||
axes[1, col].bar(pnl.index, pnl.values, color=colors, width=0.8)
|
||||
axes[1, col].axhline(20, color="orange", ls="--", lw=1, label="20U target")
|
||||
axes[1, col].axhline(0, color="gray", lw=0.5)
|
||||
axes[1, col].set_ylabel("Daily PnL (U)")
|
||||
axes[1, col].legend(fontsize=8)
|
||||
axes[1, col].grid(True, alpha=0.3)
|
||||
|
||||
axes[2, col].fill_between(dd.index, dd.values, 0, color="#d62728", alpha=0.4)
|
||||
axes[2, col].set_ylabel("Drawdown (U)")
|
||||
axes[2, col].grid(True, alpha=0.3)
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(out_dir / "bb_strategy_report.png", dpi=150)
|
||||
plt.close(fig)
|
||||
print(f"\nChart: {out_dir / 'bb_strategy_report.png'}")
|
||||
print(f"Total time: {time.time()-t0:.0f}s")
|
||||
Reference in New Issue
Block a user