| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- #!/usr/bin/env python3
- """Backtest two Chinext50 quant variants discussed in chat.
- Strategies
- 1. StandardBreakoutAtrStrategy
- - close > MA20 > MA60
- - close breaks prior 20-day high
- - optional volume confirmation when usable volume exists
- - exits on close < MA20, close < prior 10-day low,
- trailing stop at highest_close_since_entry - 2.5 * ATR14,
- or profitable pullback below MA10 after 15% gain
- 2. MinimalBreakoutTrailStrategy
- - close > MA20 > MA60
- - close breaks prior 20-day high
- - exits on close < MA20 or trailing drawdown > 8%
- Notes
- - The local chinext50 index CSV has volume=0 for all rows, so volume confirmation
- is automatically disabled instead of producing a permanently false rule.
- - Orders execute on next bar open, matching the usual Backtrader default.
- """
- from __future__ import annotations
- import json
- from datetime import datetime
- from pathlib import Path
- import backtrader as bt
- import pandas as pd
- ROOT = Path(__file__).resolve().parent
- DATA_FILE = ROOT / "chinext50.csv"
- RUN_TS = datetime.now().strftime("%Y%m%d-%H%M%S")
- JSON_OUT = ROOT / f"cyb50_quant_two_strategies_{RUN_TS}.json"
- MD_OUT = ROOT / f"cyb50_quant_two_strategies_{RUN_TS}.md"
- INITIAL_CASH = 100000.0
- COMMISSION = 0.001
- TRADING_DAYS = 252
- class Chinext50Data(bt.feeds.PandasData):
- params = (
- ("datetime", None),
- ("open", "open"),
- ("high", "high"),
- ("low", "low"),
- ("close", "close"),
- ("volume", "volume"),
- ("openinterest", None),
- )
- class BaseStrategy(bt.Strategy):
- params = (("printlog", False),)
- def __init__(self):
- self.order = None
- self.entry_count = 0
- self.highest_close_since_entry = None
- self.exposure_sum = 0.0
- self.exposure_bars = 0
- def notify_order(self, order):
- if order.status in [order.Submitted, order.Accepted]:
- return
- if order.status == order.Completed and order.isbuy():
- self.entry_count += 1
- self.highest_close_since_entry = float(self.data.close[0])
- self.order = None
- def next(self):
- portfolio = self.broker.getvalue()
- if portfolio > 0:
- exposure = abs(self.position.size) * float(self.data.close[0]) / portfolio
- self.exposure_sum += exposure
- self.exposure_bars += 1
- if self.position:
- close = float(self.data.close[0])
- if self.highest_close_since_entry is None:
- self.highest_close_since_entry = close
- else:
- self.highest_close_since_entry = max(self.highest_close_since_entry, close)
- else:
- self.highest_close_since_entry = None
- def avg_exposure(self) -> float:
- if self.exposure_bars == 0:
- return 0.0
- return self.exposure_sum / self.exposure_bars
- def rebalance_to_weight(self, weight: float):
- weight = max(0.0, min(1.0, weight))
- value = self.broker.getvalue()
- price = float(self.data.close[0])
- if value <= 0 or price <= 0:
- return
- target_size = int(value * weight / price)
- delta = target_size - self.position.size
- if delta > 0:
- self.order = self.buy(size=delta)
- elif delta < 0:
- self.order = self.sell(size=abs(delta))
- def go_long(self):
- self.rebalance_to_weight(1.0)
- def go_flat(self):
- if self.position:
- self.order = self.close()
- class StandardBreakoutAtrStrategy(BaseStrategy):
- params = (
- ("ma_fast", 20),
- ("ma_slow", 60),
- ("breakout", 20),
- ("atr_period", 14),
- ("atr_mult", 2.5),
- ("exit_low_period", 10),
- ("profit_arm", 0.15),
- ("vol_period", 5),
- ("use_volume_filter", True),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.ma20 = bt.ind.SMA(close, period=self.p.ma_fast)
- self.ma60 = bt.ind.SMA(close, period=self.p.ma_slow)
- self.ma10 = bt.ind.SMA(close, period=10)
- self.atr = bt.ind.ATR(self.data, period=self.p.atr_period)
- self.highest_prev20 = bt.ind.Highest(self.data.high(-1), period=self.p.breakout)
- self.lowest_prev10 = bt.ind.Lowest(self.data.low(-1), period=self.p.exit_low_period)
- self.vol_ma5 = bt.ind.SMA(self.data.volume, period=self.p.vol_period)
- self.volume_filter_enabled = False
- def start(self):
- data_volume = getattr(self.data, "p", None)
- # enable volume filter only when the dataset has meaningful positive volume
- try:
- df = self.data._dataname
- if isinstance(df, pd.DataFrame) and "volume" in df.columns and (df["volume"] > 0).any():
- self.volume_filter_enabled = bool(self.p.use_volume_filter)
- except Exception:
- self.volume_filter_enabled = False
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) < max(self.p.ma_slow, self.p.breakout + 1, self.p.exit_low_period + 1, self.p.atr_period + 1):
- return
- close = float(self.data.close[0])
- ma20 = float(self.ma20[0])
- ma60 = float(self.ma60[0])
- ma10 = float(self.ma10[0])
- atr = float(self.atr[0])
- highest_prev20 = float(self.highest_prev20[0])
- lowest_prev10 = float(self.lowest_prev10[0])
- volume_ok = True
- if self.volume_filter_enabled:
- volume_ok = float(self.data.volume[0]) > float(self.vol_ma5[0])
- buy_signal = close > ma20 and ma20 > ma60 and close > highest_prev20 and volume_ok
- if not self.position and buy_signal:
- self.go_long()
- return
- if self.position:
- profit_pct = close / self.position.price - 1.0 if self.position.price else 0.0
- trailing_stop = self.highest_close_since_entry - self.p.atr_mult * atr
- sell_signal = (
- close < ma20
- or close < lowest_prev10
- or close < trailing_stop
- or (profit_pct > self.p.profit_arm and close < ma10)
- )
- if sell_signal:
- self.go_flat()
- class MinimalBreakoutTrailStrategy(BaseStrategy):
- params = (
- ("ma_fast", 20),
- ("ma_slow", 60),
- ("breakout", 20),
- ("trail_dd", 0.08),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.ma20 = bt.ind.SMA(close, period=self.p.ma_fast)
- self.ma60 = bt.ind.SMA(close, period=self.p.ma_slow)
- self.highest_prev20 = bt.ind.Highest(self.data.high(-1), period=self.p.breakout)
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) < max(self.p.ma_slow, self.p.breakout + 1):
- return
- close = float(self.data.close[0])
- ma20 = float(self.ma20[0])
- ma60 = float(self.ma60[0])
- highest_prev20 = float(self.highest_prev20[0])
- buy_signal = close > ma20 and ma20 > ma60 and close > highest_prev20
- if not self.position and buy_signal:
- self.go_long()
- return
- if self.position:
- trail_stop = self.highest_close_since_entry * (1.0 - self.p.trail_dd)
- if close < ma20 or close < trail_stop:
- self.go_flat()
- class ValueRecorder(bt.Analyzer):
- def start(self):
- self.values = []
- def next(self):
- dt = self.strategy.datas[0].datetime.date(0)
- self.values.append((dt, self.strategy.broker.getvalue()))
- def get_analysis(self):
- return self.values
- def load_df() -> pd.DataFrame:
- return pd.read_csv(DATA_FILE, parse_dates=["datetime"]).set_index("datetime")
- def run_one(strategy_cls, df: pd.DataFrame) -> dict:
- cerebro = bt.Cerebro(stdstats=False)
- cerebro.broker.setcash(INITIAL_CASH)
- cerebro.broker.setcommission(commission=COMMISSION)
- cerebro.addstrategy(strategy_cls)
- cerebro.adddata(Chinext50Data(dataname=df))
- cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
- cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
- cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe", timeframe=bt.TimeFrame.Days, annualize=True, riskfreerate=0.02)
- cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
- cerebro.addanalyzer(ValueRecorder, _name="values")
- results = cerebro.run()
- strat = results[0]
- returns = strat.analyzers.returns.get_analysis()
- dd = strat.analyzers.drawdown.get_analysis()
- sharpe = strat.analyzers.sharpe.get_analysis()
- trades = strat.analyzers.trades.get_analysis()
- value_series = strat.analyzers.values.get_analysis()
- closed = trades.get("total", {}).get("closed", 0)
- won = trades.get("won", {}).get("total", 0)
- win_rate = (won / closed * 100.0) if closed else 0.0
- final_value = cerebro.broker.getvalue()
- return {
- "strategy": strategy_cls.__name__,
- "final_value": round(final_value, 2),
- "total_return_pct": round((final_value / INITIAL_CASH - 1.0) * 100.0, 2),
- "annual_return_pct": round(returns.get("rnorm100", 0.0), 2),
- "sharpe": None if sharpe.get("sharperatio") is None else round(sharpe.get("sharperatio"), 3),
- "max_drawdown_pct": round(dd.get("max", {}).get("drawdown", 0.0), 2),
- "entries": strat.entry_count,
- "closed_trades": closed,
- "win_rate_pct": round(win_rate, 2),
- "avg_exposure_pct": round(strat.avg_exposure() * 100.0, 2),
- "first_bar": value_series[0][0].isoformat() if value_series else None,
- "last_bar": value_series[-1][0].isoformat() if value_series else None,
- }
- def build_markdown(results: list[dict], df: pd.DataFrame) -> str:
- lines = [
- "# CYB50 Two-Strategy Quant Backtest",
- "",
- f"- Data: `{DATA_FILE.name}` ({df.index.min().date()} to {df.index.max().date()}, {len(df)} bars)",
- f"- Initial cash: `{INITIAL_CASH:.0f}`",
- f"- Commission: `{COMMISSION:.4f}`",
- "- Note: local index CSV has `volume=0` on all rows, so volume confirmation was auto-disabled for the standard strategy.",
- "",
- "## Rules",
- "",
- "### StandardBreakoutAtrStrategy",
- "- Entry: close > MA20 > MA60 and close > prior 20-day high",
- "- Extra filter: volume > 5-day average only when usable volume exists",
- "- Exit: close < MA20, close < prior 10-day low, close < highest close since entry - 2.5 * ATR14, or after 15% gain a close < MA10",
- "",
- "### MinimalBreakoutTrailStrategy",
- "- Entry: close > MA20 > MA60 and close > prior 20-day high",
- "- Exit: close < MA20 or trailing drawdown from highest close since entry > 8%",
- "",
- "## Results",
- "",
- "| Strategy | Final Value | Total Return | Annual Return | Sharpe | Max DD | Entries | Closed Trades | Win Rate | Avg Exposure |",
- "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
- ]
- for row in results:
- lines.append(
- f"| {row['strategy']} | {row['final_value']:.2f} | {row['total_return_pct']:.2f}% | {row['annual_return_pct']:.2f}% | "
- f"{('n/a' if row['sharpe'] is None else row['sharpe'])} | {row['max_drawdown_pct']:.2f}% | {row['entries']} | "
- f"{row['closed_trades']} | {row['win_rate_pct']:.2f}% | {row['avg_exposure_pct']:.2f}% |"
- )
- best_return = max(results, key=lambda x: x['annual_return_pct'])
- best_defensive = min(results, key=lambda x: x['max_drawdown_pct'])
- lines += [
- "",
- "## Verdict",
- "",
- f"- Higher return: **{best_return['strategy']}** ({best_return['annual_return_pct']:.2f}% annual return, {best_return['max_drawdown_pct']:.2f}% max DD)",
- f"- Lower drawdown: **{best_defensive['strategy']}** ({best_defensive['max_drawdown_pct']:.2f}% max DD, {best_defensive['annual_return_pct']:.2f}% annual return)",
- ]
- return "\n".join(lines) + "\n"
- def main():
- df = load_df()
- results = [
- run_one(StandardBreakoutAtrStrategy, df.copy()),
- run_one(MinimalBreakoutTrailStrategy, df.copy()),
- ]
- JSON_OUT.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
- MD_OUT.write_text(build_markdown(results, df), encoding="utf-8")
- print(f"JSON: {JSON_OUT}")
- print(f"MD: {MD_OUT}")
- print(json.dumps(results, ensure_ascii=False, indent=2))
- if __name__ == "__main__":
- main()
|