Files
codex_jxs_code/strategy/run_bb_5m_practical_upgrade.py
2026-02-26 16:34:30 +08:00

382 lines
12 KiB
Python

"""
Practical upgraded BB backtest on 5m ETH data (2020-2025).
Execution model (conservative):
1) BB uses closed bars only (shift by 1 bar).
2) Signal on bar i, execution at bar i+1 open.
3) Fee = 0.05% each side, rebate = 90% next day at UTC 00:00 (UTC+8 08:00).
4) Daily loss stop, liquidation, and slippage are enabled.
Compares:
- Baseline: BB(30, 3.0), 1x, no trend filter.
- Practical: BB(30, 3.2), 1x, EMA trend filter, BB width filter, cooldown.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import sys
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from strategy.data_loader import load_klines
@dataclass
class PracticalConfig:
bb_period: int = 30
bb_std: float = 3.2
leverage: float = 1.0
initial_capital: float = 200.0
margin_pct: float = 0.01
fee_rate: float = 0.0005
rebate_pct: float = 0.90
rebate_hour_utc: int = 0
slippage_pct: float = 0.0005
liq_enabled: bool = True
maint_margin_rate: float = 0.005
max_daily_loss: float = 50.0
# Practical risk controls
trend_ema_period: int = 288 # 288 * 5m = 24h EMA
min_bandwidth: float = 0.01 # (upper-lower)/mid minimum
cooldown_bars: int = 6 # 6 * 5m = 30 minutes
def run_practical_backtest(df: pd.DataFrame, cfg: PracticalConfig):
arr_open = df["open"].to_numpy(dtype=float)
arr_high = df["high"].to_numpy(dtype=float)
arr_low = df["low"].to_numpy(dtype=float)
arr_close = df["close"].to_numpy(dtype=float)
idx = df.index
n = len(df)
close_s = df["close"].astype(float)
mid = close_s.rolling(cfg.bb_period).mean().shift(1)
std = close_s.rolling(cfg.bb_period).std(ddof=0).shift(1)
upper_s = mid + cfg.bb_std * std
lower_s = mid - cfg.bb_std * std
bandwidth_s = (upper_s - lower_s) / mid
upper = upper_s.to_numpy(dtype=float)
lower = lower_s.to_numpy(dtype=float)
bandwidth = bandwidth_s.to_numpy(dtype=float)
if cfg.trend_ema_period > 0:
trend_ema = close_s.ewm(span=cfg.trend_ema_period, adjust=False).mean().shift(1).to_numpy(dtype=float)
else:
trend_ema = np.full(n, np.nan)
balance = cfg.initial_capital
position = 0 # +1 long, -1 short
entry_price = 0.0
entry_qty = 0.0
entry_margin = 0.0
trades = 0
win_trades = 0
liq_count = 0
total_fee = 0.0
total_rebate = 0.0
current_day = None
day_pnl = 0.0
day_stopped = False
rebate_applied_today = False
next_trade_bar = 0
day_fees = {}
equity_arr = np.full(n, np.nan)
position_arr = np.zeros(n)
def unrealised(price: float) -> float:
if position == 0:
return 0.0
if position == 1:
return entry_qty * (price - entry_price)
return entry_qty * (entry_price - price)
def apply_open_slippage(side: int, price: float) -> float:
return price * (1 + cfg.slippage_pct) if side == 1 else price * (1 - cfg.slippage_pct)
def apply_close_slippage(side: int, price: float) -> float:
return price * (1 - cfg.slippage_pct) if side == 1 else price * (1 + cfg.slippage_pct)
def add_fee(ts: pd.Timestamp, fee: float):
nonlocal total_fee
total_fee += fee
d = ts.date()
day_fees[d] = day_fees.get(d, 0.0) + fee
def close_position(exec_price: float, exec_idx: int):
nonlocal balance, position, entry_price, entry_qty, entry_margin
nonlocal trades, win_trades, day_pnl
if position == 0:
return
px = apply_close_slippage(position, exec_price)
gross = entry_qty * (px - entry_price) if position == 1 else entry_qty * (entry_price - px)
notional = entry_qty * px
fee = notional * cfg.fee_rate
net = gross - fee
balance += net
add_fee(idx[exec_idx], fee)
day_pnl += net
trades += 1
if net > 0:
win_trades += 1
position = 0
entry_price = 0.0
entry_qty = 0.0
entry_margin = 0.0
def open_position(side: int, exec_price: float, exec_idx: int):
nonlocal balance, position, entry_price, entry_qty, entry_margin
nonlocal day_pnl
px = apply_open_slippage(side, exec_price)
eq = balance + unrealised(px)
margin = min(eq * cfg.margin_pct, balance * 0.95)
if margin <= 0:
return
notional = margin * cfg.leverage
qty = notional / px
fee = notional * cfg.fee_rate
balance -= fee
add_fee(idx[exec_idx], fee)
day_pnl -= fee
position = side
entry_price = px
entry_qty = qty
entry_margin = margin
for i in range(n - 1):
ts = idx[i]
bar_day = ts.date()
if bar_day != current_day:
current_day = bar_day
day_pnl = 0.0
day_stopped = False
rebate_applied_today = False
# Fee rebate settlement (next day 08:00 UTC+8 = UTC 00:00)
if (not rebate_applied_today) and ts.hour >= cfg.rebate_hour_utc:
prev_day = bar_day - pd.Timedelta(days=1)
prev_fee = day_fees.get(prev_day, 0.0)
if prev_fee > 0:
rebate = prev_fee * cfg.rebate_pct
balance += rebate
total_rebate += rebate
rebate_applied_today = True
# Daily loss stop
if not day_stopped and (day_pnl + unrealised(arr_close[i]) <= -cfg.max_daily_loss):
close_position(arr_open[i + 1], i + 1)
day_stopped = True
# Liquidation check
if cfg.liq_enabled and position != 0 and entry_margin > 0:
liq_threshold = 1.0 / cfg.leverage * (1 - cfg.maint_margin_rate)
if position == 1:
liq_price = entry_price * (1 - liq_threshold)
if arr_low[i] <= liq_price:
balance -= entry_margin
day_pnl -= entry_margin
position = 0
entry_price = 0.0
entry_qty = 0.0
entry_margin = 0.0
trades += 1
liq_count += 1
else:
liq_price = entry_price * (1 + liq_threshold)
if arr_high[i] >= liq_price:
balance -= entry_margin
day_pnl -= entry_margin
position = 0
entry_price = 0.0
entry_qty = 0.0
entry_margin = 0.0
trades += 1
liq_count += 1
if (
day_stopped
or i < next_trade_bar
or np.isnan(upper[i])
or np.isnan(lower[i])
or np.isnan(bandwidth[i])
):
equity_arr[i] = balance + unrealised(arr_close[i])
position_arr[i] = position
if balance <= 0:
balance = 0.0
equity_arr[i:] = 0.0
position_arr[i:] = 0
break
continue
if cfg.trend_ema_period > 0 and np.isnan(trend_ema[i]):
equity_arr[i] = balance + unrealised(arr_close[i])
position_arr[i] = position
continue
if bandwidth[i] < cfg.min_bandwidth:
equity_arr[i] = balance + unrealised(arr_close[i])
position_arr[i] = position
continue
touched_upper = arr_high[i] >= upper[i]
touched_lower = arr_low[i] <= lower[i]
exec_px = arr_open[i + 1]
if cfg.trend_ema_period > 0:
long_ok = arr_close[i] > trend_ema[i]
short_ok = arr_close[i] < trend_ema[i]
else:
long_ok = True
short_ok = True
if touched_upper and touched_lower:
pass
elif touched_upper:
if position == 1:
close_position(exec_px, i + 1)
next_trade_bar = max(next_trade_bar, i + 1 + cfg.cooldown_bars)
if position == 0 and short_ok:
open_position(-1, exec_px, i + 1)
next_trade_bar = max(next_trade_bar, i + 1 + cfg.cooldown_bars)
elif touched_lower:
if position == -1:
close_position(exec_px, i + 1)
next_trade_bar = max(next_trade_bar, i + 1 + cfg.cooldown_bars)
if position == 0 and long_ok:
open_position(1, exec_px, i + 1)
next_trade_bar = max(next_trade_bar, i + 1 + cfg.cooldown_bars)
equity_arr[i] = balance + unrealised(arr_close[i])
position_arr[i] = position
if balance <= 0:
balance = 0.0
equity_arr[i:] = 0.0
position_arr[i:] = 0
break
if position != 0 and balance > 0:
close_position(arr_close[-1], n - 1)
equity_arr[-1] = balance
position_arr[-1] = 0
eq_df = pd.DataFrame({"equity": equity_arr, "position": position_arr}, index=idx)
daily = eq_df["equity"].resample("1D").last().dropna().to_frame("equity")
daily["pnl"] = daily["equity"].diff().fillna(0.0)
return {
"equity_curve": eq_df,
"daily": daily,
"final_equity": float(daily["equity"].iloc[-1]),
"trade_count": int(trades),
"win_rate": float(win_trades / max(trades, 1)),
"liq_count": int(liq_count),
"total_fee": float(total_fee),
"total_rebate": float(total_rebate),
}
def summarize(label: str, result: dict, initial_capital: float):
daily = result["daily"]
eq = daily["equity"].astype(float)
pnl = daily["pnl"].astype(float)
final_eq = float(eq.iloc[-1])
ret_pct = (final_eq - initial_capital) / initial_capital * 100
max_dd = float((eq - eq.cummax()).min()) if len(eq) else 0.0
sharpe = float(pnl.mean() / pnl.std() * np.sqrt(365)) if pnl.std() > 0 else 0.0
print(f"[{label}]")
print(f" Final equity: {final_eq:.6f} U")
print(f" Return: {ret_pct:+.4f}%")
print(f" Trades: {result['trade_count']} | Win rate: {result['win_rate']*100:.2f}% | Liquidations: {result['liq_count']}")
print(f" Max drawdown: {max_dd:.6f} U | Sharpe: {sharpe:.4f}")
print(f" Total fee: {result['total_fee']:.6f} | Total rebate: {result['total_rebate']:.6f}")
print(" Year-end equity:")
for year in range(2020, 2026):
m = daily.index.year == year
if m.any():
print(f" {year}: {float(daily.loc[m, 'equity'].iloc[-1]):.6f} U")
print()
return {
"label": label,
"final_equity": final_eq,
"return_pct": ret_pct,
"trade_count": result["trade_count"],
"win_rate_pct": result["win_rate"] * 100,
"liq_count": result["liq_count"],
"max_drawdown": max_dd,
"total_fee": result["total_fee"],
"total_rebate": result["total_rebate"],
"sharpe": sharpe,
}
def main():
df = load_klines("5m", "2020-01-01", "2026-01-01")
baseline_cfg = PracticalConfig(
bb_period=30,
bb_std=3.0,
leverage=1.0,
trend_ema_period=0,
min_bandwidth=0.0,
cooldown_bars=0,
)
practical_cfg = PracticalConfig(
bb_period=30,
bb_std=3.2,
leverage=1.0,
trend_ema_period=288,
min_bandwidth=0.01,
cooldown_bars=6,
)
print("=" * 118)
print("Practical Upgrade Backtest | 5m | 2020-2025 | capital=200U | fee=0.05% each side | rebate=90% next day 08:00")
print("=" * 118)
print("Execution: signal at bar i, fill at bar i+1 open (conservative)")
print()
baseline = run_practical_backtest(df, baseline_cfg)
practical = run_practical_backtest(df, practical_cfg)
summary_rows = []
summary_rows.append(summarize("Baseline (BB30/3.0, no filters)", baseline, baseline_cfg.initial_capital))
summary_rows.append(summarize("Practical (BB30/3.2 + EMA + BW + cooldown)", practical, practical_cfg.initial_capital))
out_dir = Path(__file__).resolve().parent / "results"
out_dir.mkdir(parents=True, exist_ok=True)
baseline_daily = baseline["daily"].rename(columns={"equity": "baseline_equity", "pnl": "baseline_pnl"})
practical_daily = practical["daily"].rename(columns={"equity": "practical_equity", "pnl": "practical_pnl"})
daily_compare = baseline_daily.join(practical_daily, how="outer")
daily_compare.to_csv(out_dir / "bb_5m_practical_upgrade_daily.csv")
pd.DataFrame(summary_rows).to_csv(out_dir / "bb_5m_practical_upgrade_summary.csv", index=False)
print(f"Saved: {out_dir / 'bb_5m_practical_upgrade_daily.csv'}")
print(f"Saved: {out_dir / 'bb_5m_practical_upgrade_summary.csv'}")
if __name__ == "__main__":
main()