cyb50_quant_two_strategies.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. #!/usr/bin/env python3
  2. """Backtest two Chinext50 quant variants discussed in chat.
  3. Strategies
  4. 1. StandardBreakoutAtrStrategy
  5. - close > MA20 > MA60
  6. - close breaks prior 20-day high
  7. - optional volume confirmation when usable volume exists
  8. - exits on close < MA20, close < prior 10-day low,
  9. trailing stop at highest_close_since_entry - 2.5 * ATR14,
  10. or profitable pullback below MA10 after 15% gain
  11. 2. MinimalBreakoutTrailStrategy
  12. - close > MA20 > MA60
  13. - close breaks prior 20-day high
  14. - exits on close < MA20 or trailing drawdown > 8%
  15. Notes
  16. - The local chinext50 index CSV has volume=0 for all rows, so volume confirmation
  17. is automatically disabled instead of producing a permanently false rule.
  18. - Orders execute on next bar open, matching the usual Backtrader default.
  19. """
  20. from __future__ import annotations
  21. import json
  22. from datetime import datetime
  23. from pathlib import Path
  24. import backtrader as bt
  25. import pandas as pd
  26. ROOT = Path(__file__).resolve().parent
  27. DATA_FILE = ROOT / "chinext50.csv"
  28. RUN_TS = datetime.now().strftime("%Y%m%d-%H%M%S")
  29. JSON_OUT = ROOT / f"cyb50_quant_two_strategies_{RUN_TS}.json"
  30. MD_OUT = ROOT / f"cyb50_quant_two_strategies_{RUN_TS}.md"
  31. INITIAL_CASH = 100000.0
  32. COMMISSION = 0.001
  33. TRADING_DAYS = 252
  34. class Chinext50Data(bt.feeds.PandasData):
  35. params = (
  36. ("datetime", None),
  37. ("open", "open"),
  38. ("high", "high"),
  39. ("low", "low"),
  40. ("close", "close"),
  41. ("volume", "volume"),
  42. ("openinterest", None),
  43. )
  44. class BaseStrategy(bt.Strategy):
  45. params = (("printlog", False),)
  46. def __init__(self):
  47. self.order = None
  48. self.entry_count = 0
  49. self.highest_close_since_entry = None
  50. self.exposure_sum = 0.0
  51. self.exposure_bars = 0
  52. def notify_order(self, order):
  53. if order.status in [order.Submitted, order.Accepted]:
  54. return
  55. if order.status == order.Completed and order.isbuy():
  56. self.entry_count += 1
  57. self.highest_close_since_entry = float(self.data.close[0])
  58. self.order = None
  59. def next(self):
  60. portfolio = self.broker.getvalue()
  61. if portfolio > 0:
  62. exposure = abs(self.position.size) * float(self.data.close[0]) / portfolio
  63. self.exposure_sum += exposure
  64. self.exposure_bars += 1
  65. if self.position:
  66. close = float(self.data.close[0])
  67. if self.highest_close_since_entry is None:
  68. self.highest_close_since_entry = close
  69. else:
  70. self.highest_close_since_entry = max(self.highest_close_since_entry, close)
  71. else:
  72. self.highest_close_since_entry = None
  73. def avg_exposure(self) -> float:
  74. if self.exposure_bars == 0:
  75. return 0.0
  76. return self.exposure_sum / self.exposure_bars
  77. def rebalance_to_weight(self, weight: float):
  78. weight = max(0.0, min(1.0, weight))
  79. value = self.broker.getvalue()
  80. price = float(self.data.close[0])
  81. if value <= 0 or price <= 0:
  82. return
  83. target_size = int(value * weight / price)
  84. delta = target_size - self.position.size
  85. if delta > 0:
  86. self.order = self.buy(size=delta)
  87. elif delta < 0:
  88. self.order = self.sell(size=abs(delta))
  89. def go_long(self):
  90. self.rebalance_to_weight(1.0)
  91. def go_flat(self):
  92. if self.position:
  93. self.order = self.close()
  94. class StandardBreakoutAtrStrategy(BaseStrategy):
  95. params = (
  96. ("ma_fast", 20),
  97. ("ma_slow", 60),
  98. ("breakout", 20),
  99. ("atr_period", 14),
  100. ("atr_mult", 2.5),
  101. ("exit_low_period", 10),
  102. ("profit_arm", 0.15),
  103. ("vol_period", 5),
  104. ("use_volume_filter", True),
  105. )
  106. def __init__(self):
  107. super().__init__()
  108. close = self.data.close
  109. self.ma20 = bt.ind.SMA(close, period=self.p.ma_fast)
  110. self.ma60 = bt.ind.SMA(close, period=self.p.ma_slow)
  111. self.ma10 = bt.ind.SMA(close, period=10)
  112. self.atr = bt.ind.ATR(self.data, period=self.p.atr_period)
  113. self.highest_prev20 = bt.ind.Highest(self.data.high(-1), period=self.p.breakout)
  114. self.lowest_prev10 = bt.ind.Lowest(self.data.low(-1), period=self.p.exit_low_period)
  115. self.vol_ma5 = bt.ind.SMA(self.data.volume, period=self.p.vol_period)
  116. self.volume_filter_enabled = False
  117. def start(self):
  118. data_volume = getattr(self.data, "p", None)
  119. # enable volume filter only when the dataset has meaningful positive volume
  120. try:
  121. df = self.data._dataname
  122. if isinstance(df, pd.DataFrame) and "volume" in df.columns and (df["volume"] > 0).any():
  123. self.volume_filter_enabled = bool(self.p.use_volume_filter)
  124. except Exception:
  125. self.volume_filter_enabled = False
  126. def next(self):
  127. super().next()
  128. if self.order:
  129. return
  130. if len(self) < max(self.p.ma_slow, self.p.breakout + 1, self.p.exit_low_period + 1, self.p.atr_period + 1):
  131. return
  132. close = float(self.data.close[0])
  133. ma20 = float(self.ma20[0])
  134. ma60 = float(self.ma60[0])
  135. ma10 = float(self.ma10[0])
  136. atr = float(self.atr[0])
  137. highest_prev20 = float(self.highest_prev20[0])
  138. lowest_prev10 = float(self.lowest_prev10[0])
  139. volume_ok = True
  140. if self.volume_filter_enabled:
  141. volume_ok = float(self.data.volume[0]) > float(self.vol_ma5[0])
  142. buy_signal = close > ma20 and ma20 > ma60 and close > highest_prev20 and volume_ok
  143. if not self.position and buy_signal:
  144. self.go_long()
  145. return
  146. if self.position:
  147. profit_pct = close / self.position.price - 1.0 if self.position.price else 0.0
  148. trailing_stop = self.highest_close_since_entry - self.p.atr_mult * atr
  149. sell_signal = (
  150. close < ma20
  151. or close < lowest_prev10
  152. or close < trailing_stop
  153. or (profit_pct > self.p.profit_arm and close < ma10)
  154. )
  155. if sell_signal:
  156. self.go_flat()
  157. class MinimalBreakoutTrailStrategy(BaseStrategy):
  158. params = (
  159. ("ma_fast", 20),
  160. ("ma_slow", 60),
  161. ("breakout", 20),
  162. ("trail_dd", 0.08),
  163. )
  164. def __init__(self):
  165. super().__init__()
  166. close = self.data.close
  167. self.ma20 = bt.ind.SMA(close, period=self.p.ma_fast)
  168. self.ma60 = bt.ind.SMA(close, period=self.p.ma_slow)
  169. self.highest_prev20 = bt.ind.Highest(self.data.high(-1), period=self.p.breakout)
  170. def next(self):
  171. super().next()
  172. if self.order:
  173. return
  174. if len(self) < max(self.p.ma_slow, self.p.breakout + 1):
  175. return
  176. close = float(self.data.close[0])
  177. ma20 = float(self.ma20[0])
  178. ma60 = float(self.ma60[0])
  179. highest_prev20 = float(self.highest_prev20[0])
  180. buy_signal = close > ma20 and ma20 > ma60 and close > highest_prev20
  181. if not self.position and buy_signal:
  182. self.go_long()
  183. return
  184. if self.position:
  185. trail_stop = self.highest_close_since_entry * (1.0 - self.p.trail_dd)
  186. if close < ma20 or close < trail_stop:
  187. self.go_flat()
  188. class ValueRecorder(bt.Analyzer):
  189. def start(self):
  190. self.values = []
  191. def next(self):
  192. dt = self.strategy.datas[0].datetime.date(0)
  193. self.values.append((dt, self.strategy.broker.getvalue()))
  194. def get_analysis(self):
  195. return self.values
  196. def load_df() -> pd.DataFrame:
  197. return pd.read_csv(DATA_FILE, parse_dates=["datetime"]).set_index("datetime")
  198. def run_one(strategy_cls, df: pd.DataFrame) -> dict:
  199. cerebro = bt.Cerebro(stdstats=False)
  200. cerebro.broker.setcash(INITIAL_CASH)
  201. cerebro.broker.setcommission(commission=COMMISSION)
  202. cerebro.addstrategy(strategy_cls)
  203. cerebro.adddata(Chinext50Data(dataname=df))
  204. cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
  205. cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
  206. cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe", timeframe=bt.TimeFrame.Days, annualize=True, riskfreerate=0.02)
  207. cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
  208. cerebro.addanalyzer(ValueRecorder, _name="values")
  209. results = cerebro.run()
  210. strat = results[0]
  211. returns = strat.analyzers.returns.get_analysis()
  212. dd = strat.analyzers.drawdown.get_analysis()
  213. sharpe = strat.analyzers.sharpe.get_analysis()
  214. trades = strat.analyzers.trades.get_analysis()
  215. value_series = strat.analyzers.values.get_analysis()
  216. closed = trades.get("total", {}).get("closed", 0)
  217. won = trades.get("won", {}).get("total", 0)
  218. win_rate = (won / closed * 100.0) if closed else 0.0
  219. final_value = cerebro.broker.getvalue()
  220. return {
  221. "strategy": strategy_cls.__name__,
  222. "final_value": round(final_value, 2),
  223. "total_return_pct": round((final_value / INITIAL_CASH - 1.0) * 100.0, 2),
  224. "annual_return_pct": round(returns.get("rnorm100", 0.0), 2),
  225. "sharpe": None if sharpe.get("sharperatio") is None else round(sharpe.get("sharperatio"), 3),
  226. "max_drawdown_pct": round(dd.get("max", {}).get("drawdown", 0.0), 2),
  227. "entries": strat.entry_count,
  228. "closed_trades": closed,
  229. "win_rate_pct": round(win_rate, 2),
  230. "avg_exposure_pct": round(strat.avg_exposure() * 100.0, 2),
  231. "first_bar": value_series[0][0].isoformat() if value_series else None,
  232. "last_bar": value_series[-1][0].isoformat() if value_series else None,
  233. }
  234. def build_markdown(results: list[dict], df: pd.DataFrame) -> str:
  235. lines = [
  236. "# CYB50 Two-Strategy Quant Backtest",
  237. "",
  238. f"- Data: `{DATA_FILE.name}` ({df.index.min().date()} to {df.index.max().date()}, {len(df)} bars)",
  239. f"- Initial cash: `{INITIAL_CASH:.0f}`",
  240. f"- Commission: `{COMMISSION:.4f}`",
  241. "- Note: local index CSV has `volume=0` on all rows, so volume confirmation was auto-disabled for the standard strategy.",
  242. "",
  243. "## Rules",
  244. "",
  245. "### StandardBreakoutAtrStrategy",
  246. "- Entry: close > MA20 > MA60 and close > prior 20-day high",
  247. "- Extra filter: volume > 5-day average only when usable volume exists",
  248. "- Exit: close < MA20, close < prior 10-day low, close < highest close since entry - 2.5 * ATR14, or after 15% gain a close < MA10",
  249. "",
  250. "### MinimalBreakoutTrailStrategy",
  251. "- Entry: close > MA20 > MA60 and close > prior 20-day high",
  252. "- Exit: close < MA20 or trailing drawdown from highest close since entry > 8%",
  253. "",
  254. "## Results",
  255. "",
  256. "| Strategy | Final Value | Total Return | Annual Return | Sharpe | Max DD | Entries | Closed Trades | Win Rate | Avg Exposure |",
  257. "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
  258. ]
  259. for row in results:
  260. lines.append(
  261. f"| {row['strategy']} | {row['final_value']:.2f} | {row['total_return_pct']:.2f}% | {row['annual_return_pct']:.2f}% | "
  262. f"{('n/a' if row['sharpe'] is None else row['sharpe'])} | {row['max_drawdown_pct']:.2f}% | {row['entries']} | "
  263. f"{row['closed_trades']} | {row['win_rate_pct']:.2f}% | {row['avg_exposure_pct']:.2f}% |"
  264. )
  265. best_return = max(results, key=lambda x: x['annual_return_pct'])
  266. best_defensive = min(results, key=lambda x: x['max_drawdown_pct'])
  267. lines += [
  268. "",
  269. "## Verdict",
  270. "",
  271. f"- Higher return: **{best_return['strategy']}** ({best_return['annual_return_pct']:.2f}% annual return, {best_return['max_drawdown_pct']:.2f}% max DD)",
  272. f"- Lower drawdown: **{best_defensive['strategy']}** ({best_defensive['max_drawdown_pct']:.2f}% max DD, {best_defensive['annual_return_pct']:.2f}% annual return)",
  273. ]
  274. return "\n".join(lines) + "\n"
  275. def main():
  276. df = load_df()
  277. results = [
  278. run_one(StandardBreakoutAtrStrategy, df.copy()),
  279. run_one(MinimalBreakoutTrailStrategy, df.copy()),
  280. ]
  281. JSON_OUT.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
  282. MD_OUT.write_text(build_markdown(results, df), encoding="utf-8")
  283. print(f"JSON: {JSON_OUT}")
  284. print(f"MD: {MD_OUT}")
  285. print(json.dumps(results, ensure_ascii=False, indent=2))
  286. if __name__ == "__main__":
  287. main()