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

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()