324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""
|
|
Conservative BB backtest for bb_trade-style logic.
|
|
|
|
Assumptions:
|
|
1) Use 15m OHLC from 2020-01-01 to 2026-01-01 (exclusive).
|
|
2) Bollinger band is computed from CLOSED bars (shifted by 1 bar).
|
|
3) If bar i touches band, execute on bar i+1 open (no same-bar fill).
|
|
4) Fee: 0.05% each side; rebate: 90% of daily fee, credited next day at 08:00 UTC+8 (UTC 00:00).
|
|
5) Position sizing: 1% equity open, then +1%/+2%/+3% add ladder (max 3 adds).
|
|
6) Leverage 50x; optional liquidation model enabled.
|
|
"""
|
|
|
|
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 ConservativeConfig:
|
|
bb_period: int = 10
|
|
bb_std: float = 2.5
|
|
leverage: float = 50.0
|
|
initial_capital: float = 200.0
|
|
margin_pct: float = 0.01
|
|
pyramid_step: float = 0.01
|
|
pyramid_max: int = 3
|
|
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
|
|
|
|
|
|
def bollinger_prev_close(close: pd.Series, period: int, n_std: float) -> tuple[np.ndarray, np.ndarray]:
|
|
mid = close.rolling(period).mean().shift(1)
|
|
std = close.rolling(period).std(ddof=0).shift(1)
|
|
upper = (mid + n_std * std).to_numpy(dtype=float)
|
|
lower = (mid - n_std * std).to_numpy(dtype=float)
|
|
return upper, lower
|
|
|
|
|
|
def run_conservative(df: pd.DataFrame, cfg: ConservativeConfig):
|
|
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)
|
|
|
|
upper, lower = bollinger_prev_close(df["close"].astype(float), cfg.bb_period, cfg.bb_std)
|
|
|
|
balance = cfg.initial_capital
|
|
position = 0 # +1 long, -1 short
|
|
entry_price = 0.0
|
|
entry_qty = 0.0
|
|
entry_margin = 0.0
|
|
pyramid_count = 0
|
|
|
|
total_fee = 0.0
|
|
total_rebate = 0.0
|
|
trades = 0
|
|
win_trades = 0
|
|
liq_count = 0
|
|
|
|
day_fees = {}
|
|
current_day = None
|
|
day_start_equity = cfg.initial_capital
|
|
day_pnl = 0.0
|
|
day_stopped = False
|
|
rebate_applied_today = False
|
|
|
|
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:
|
|
# Buy higher, sell lower
|
|
return price * (1 + cfg.slippage_pct) if side == 1 else price * (1 - cfg.slippage_pct)
|
|
|
|
def apply_close_slippage(side: int, price: float) -> float:
|
|
# Close long = sell lower; close short = buy higher
|
|
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, pyramid_count
|
|
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
|
|
pyramid_count = 0
|
|
|
|
def open_position(side: int, exec_price: float, exec_idx: int, is_add: bool = False):
|
|
nonlocal balance, position, entry_price, entry_qty, entry_margin, pyramid_count
|
|
nonlocal day_pnl
|
|
px = apply_open_slippage(side, exec_price)
|
|
eq = balance + unrealised(px)
|
|
|
|
if is_add:
|
|
margin = eq * (cfg.margin_pct + cfg.pyramid_step * (pyramid_count + 1))
|
|
else:
|
|
margin = eq * cfg.margin_pct
|
|
|
|
margin = min(margin, 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
|
|
|
|
if is_add and position != 0:
|
|
old_notional = entry_qty * entry_price
|
|
new_notional = qty * px
|
|
entry_qty += qty
|
|
entry_price = (old_notional + new_notional) / entry_qty
|
|
entry_margin += margin
|
|
pyramid_count += 1
|
|
else:
|
|
position = side
|
|
entry_price = px
|
|
entry_qty = qty
|
|
entry_margin = margin
|
|
pyramid_count = 0
|
|
|
|
for i in range(n - 1):
|
|
ts = idx[i]
|
|
bar_day = ts.date()
|
|
|
|
if bar_day != current_day:
|
|
current_day = bar_day
|
|
day_start_equity = balance + unrealised(arr_close[i])
|
|
day_pnl = 0.0
|
|
day_stopped = False
|
|
rebate_applied_today = False
|
|
|
|
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
|
|
|
|
if not day_stopped:
|
|
if day_pnl + unrealised(arr_close[i]) <= -cfg.max_daily_loss:
|
|
close_position(arr_open[i + 1], i + 1)
|
|
day_stopped = True
|
|
|
|
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
|
|
pyramid_count = 0
|
|
trades += 1
|
|
liq_count += 1
|
|
elif position == -1:
|
|
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
|
|
pyramid_count = 0
|
|
trades += 1
|
|
liq_count += 1
|
|
|
|
if day_stopped or np.isnan(upper[i]) or np.isnan(lower[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
|
|
|
|
touched_upper = arr_high[i] >= upper[i]
|
|
touched_lower = arr_low[i] <= lower[i]
|
|
exec_px = arr_open[i + 1]
|
|
|
|
if touched_upper and touched_lower:
|
|
pass
|
|
elif touched_upper:
|
|
if position == 1:
|
|
close_position(exec_px, i + 1)
|
|
if position == 0:
|
|
open_position(-1, exec_px, i + 1, is_add=False)
|
|
elif position == -1 and pyramid_count < cfg.pyramid_max:
|
|
open_position(-1, exec_px, i + 1, is_add=True)
|
|
elif touched_lower:
|
|
if position == -1:
|
|
close_position(exec_px, i + 1)
|
|
if position == 0:
|
|
open_position(1, exec_px, i + 1, is_add=False)
|
|
elif position == 1 and pyramid_count < cfg.pyramid_max:
|
|
open_position(1, exec_px, i + 1, is_add=True)
|
|
|
|
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 main():
|
|
df = load_klines("15m", "2020-01-01", "2026-01-01")
|
|
cfg = ConservativeConfig()
|
|
result = run_conservative(df, cfg)
|
|
|
|
daily = result["daily"]
|
|
eq = daily["equity"].astype(float)
|
|
pnl = daily["pnl"].astype(float)
|
|
final_eq = float(eq.iloc[-1])
|
|
ret_pct = (final_eq - cfg.initial_capital) / cfg.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("=" * 110)
|
|
print("Conservative BB(10,2.5) | 15m | 2020-2025 | 200U | fee 0.05% each side | 90% rebate next day 08:00")
|
|
print("=" * 110)
|
|
print(f"Final equity: {final_eq:.8f} U")
|
|
print(f"Return: {ret_pct:+.2f}%")
|
|
print(f"Trades: {result['trade_count']}")
|
|
print(f"Win rate: {result['win_rate']*100:.2f}%")
|
|
print(f"Liquidations: {result['liq_count']}")
|
|
print(f"Max drawdown: {max_dd:.2f} U")
|
|
print(f"Total fee: {result['total_fee']:.8f}")
|
|
print(f"Total rebate: {result['total_rebate']:.8f}")
|
|
print(f"Sharpe: {sharpe:.4f}")
|
|
print("-" * 110)
|
|
for year in range(2020, 2026):
|
|
m = daily.index.year == year
|
|
if m.any():
|
|
print(f"{year} year-end equity: {float(daily.loc[m, 'equity'].iloc[-1]):.8f} U")
|
|
print("=" * 110)
|
|
|
|
out_dir = Path(__file__).resolve().parent / "results"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
out_csv = out_dir / "bb_15m_2020_2025_conservative_daily.csv"
|
|
daily.to_csv(out_csv)
|
|
print(f"Saved daily equity: {out_csv}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|