shortlist_combo_trials.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import json
  4. import math
  5. from datetime import datetime
  6. from pathlib import Path
  7. import backtrader as bt
  8. import pandas as pd
  9. ROOT = Path(__file__).resolve().parent
  10. DATA_FILE = ROOT / "chinext50.csv"
  11. INITIAL_CASH = 100000.0
  12. COMMISSION = 0.001
  13. TRADING_DAYS = 252
  14. class Chinext50Data(bt.feeds.PandasData):
  15. params = (
  16. ("datetime", None),
  17. ("open", "open"),
  18. ("high", "high"),
  19. ("low", "low"),
  20. ("close", "close"),
  21. ("volume", "volume"),
  22. ("openinterest", None),
  23. )
  24. class BasePortfolioStrategy(bt.Strategy):
  25. params = (("rebalance_band", 0.05),)
  26. def __init__(self):
  27. self.order = None
  28. self.entry_count = 0
  29. self.bars_in_market = 0
  30. self.exposure_sum = 0.0
  31. def notify_order(self, order):
  32. if order.status in [order.Submitted, order.Accepted]:
  33. return
  34. if order.status == order.Completed and order.isbuy():
  35. self.entry_count += 1
  36. self.order = None
  37. def next(self):
  38. portfolio_value = self.broker.getvalue()
  39. if portfolio_value > 0:
  40. position_value = abs(self.position.size) * self.data.close[0]
  41. exposure = position_value / portfolio_value
  42. self.exposure_sum += exposure
  43. if exposure > 0:
  44. self.bars_in_market += 1
  45. def _target_size_for_weight(self, target_weight: float) -> int:
  46. target_weight = max(0.0, min(1.0, target_weight))
  47. portfolio_value = self.broker.getvalue()
  48. price = self.data.close[0]
  49. if portfolio_value <= 0 or price <= 0:
  50. return 0
  51. target_value = portfolio_value * target_weight
  52. return max(int(target_value / price), 0)
  53. def _rebalance_to_weight(self, target_weight: float):
  54. target_size = self._target_size_for_weight(target_weight)
  55. current_size = self.position.size
  56. size_delta = target_size - current_size
  57. if size_delta > 0:
  58. self.order = self.buy(size=size_delta)
  59. elif size_delta < 0:
  60. self.order = self.sell(size=abs(size_delta))
  61. class Core3ComboStrategy(BasePortfolioStrategy):
  62. """Equal-weight average of DualThrustBasic + DonchianRegime + MVT(0.30)."""
  63. def __init__(self):
  64. super().__init__()
  65. close = self.data.close
  66. self.roc_short = bt.indicators.ROC(close, period=20)
  67. self.roc_long = bt.indicators.ROC(close, period=120)
  68. self.sma150 = bt.indicators.SMA(close, period=150)
  69. returns = bt.indicators.PctChange(close, period=1)
  70. self.volatility = bt.indicators.StdDev(returns, period=30)
  71. self.highest_high = bt.indicators.Highest(self.data.high, period=55)
  72. self.lowest_low = bt.indicators.Lowest(self.data.low, period=30)
  73. self.dual_active = False
  74. self.don_active = False
  75. def next(self):
  76. super().next()
  77. if self.order:
  78. return
  79. weights = []
  80. # DualThrustBasic (20, 0.3, 0.3)
  81. dual_w = 0.0
  82. if len(self) > 20:
  83. closes = [float(self.data.close[-offset]) for offset in range(1, 21)]
  84. thrust_range = max(closes) - min(closes)
  85. reference_price = float(self.data.close[-1])
  86. upper = reference_price + 0.3 * thrust_range
  87. lower = reference_price - 0.3 * thrust_range
  88. if not self.dual_active and self.data.close[0] > upper:
  89. self.dual_active = True
  90. elif self.dual_active and self.data.close[0] < lower:
  91. self.dual_active = False
  92. dual_w = 1.0 if self.dual_active else 0.0
  93. weights.append(dual_w)
  94. # DonchianRegime (55, 30, 150)
  95. don_w = 0.0
  96. if len(self) > 150 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.sma150[0]]):
  97. breakout_signal = self.data.close[0] > self.highest_high[-1] and self.data.close[0] > self.sma150[0]
  98. exit_signal = self.data.close[0] < self.lowest_low[-1] or self.data.close[0] < self.sma150[0]
  99. if not self.don_active and breakout_signal:
  100. self.don_active = True
  101. elif self.don_active and exit_signal:
  102. self.don_active = False
  103. don_w = 1.0 if self.don_active else 0.0
  104. weights.append(don_w)
  105. # MomentumVolTarget (0.30)
  106. mvt_w = 0.0
  107. if not any(math.isnan(x) for x in [self.roc_short[0], self.roc_long[0], self.sma150[0], self.volatility[0]]) and self.volatility[0] > 0:
  108. signal = self.roc_short[0] > 0 and self.roc_long[0] > 0 and self.data.close[0] > self.sma150[0]
  109. if signal:
  110. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  111. mvt_w = min(1.0, 0.30 / annualized_vol)
  112. weights.append(mvt_w)
  113. target_weight = sum(weights) / len(weights)
  114. portfolio_value = self.broker.getvalue()
  115. current_weight = 0.0
  116. if portfolio_value > 0:
  117. current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
  118. if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
  119. self._rebalance_to_weight(target_weight)
  120. class Balanced3ComboStrategy(BasePortfolioStrategy):
  121. """Equal-weight average of DualThrustRegime035 + MVT(0.29) + DonchianHybrid(0.25)."""
  122. def __init__(self):
  123. super().__init__()
  124. close = self.data.close
  125. returns = bt.indicators.PctChange(close, period=1)
  126. self.volatility = bt.indicators.StdDev(returns, period=30)
  127. self.atr = bt.indicators.ATR(self.data, period=20)
  128. self.roc_short = bt.indicators.ROC(close, period=20)
  129. self.roc_long = bt.indicators.ROC(close, period=120)
  130. self.sma120 = bt.indicators.SMA(close, period=120)
  131. self.sma150 = bt.indicators.SMA(close, period=150)
  132. self.highest_high = bt.indicators.Highest(self.data.high, period=55)
  133. self.lowest_low = bt.indicators.Lowest(self.data.low, period=30)
  134. self.dt_reg_active = False
  135. self.hybrid_active = False
  136. self.hybrid_highest_close = None
  137. def next(self):
  138. super().next()
  139. if self.order:
  140. return
  141. weights = []
  142. # DualThrustRegime (20, 0.35, 0.35, 120)
  143. dt_w = 0.0
  144. if len(self) > 120 and not math.isnan(self.sma120[0]):
  145. closes = [float(self.data.close[-offset]) for offset in range(1, 21)]
  146. thrust_range = max(closes) - min(closes)
  147. reference_price = float(self.data.close[-1])
  148. upper = reference_price + 0.35 * thrust_range
  149. lower = reference_price - 0.35 * thrust_range
  150. entry_signal = self.data.close[0] > upper and self.data.close[0] > self.sma120[0]
  151. exit_signal = self.data.close[0] < lower or self.data.close[0] < self.sma120[0]
  152. if not self.dt_reg_active and entry_signal:
  153. self.dt_reg_active = True
  154. elif self.dt_reg_active and exit_signal:
  155. self.dt_reg_active = False
  156. dt_w = 1.0 if self.dt_reg_active else 0.0
  157. weights.append(dt_w)
  158. # MVT (0.29)
  159. mvt_w = 0.0
  160. if not any(math.isnan(x) for x in [self.roc_short[0], self.roc_long[0], self.sma150[0], self.volatility[0]]) and self.volatility[0] > 0:
  161. signal = self.roc_short[0] > 0 and self.roc_long[0] > 0 and self.data.close[0] > self.sma150[0]
  162. if signal:
  163. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  164. mvt_w = min(1.0, 0.29 / annualized_vol)
  165. weights.append(mvt_w)
  166. # DonchianHybrid (55,30,tv=0.25,atr=4)
  167. hy_w = 0.0
  168. if len(self) > 55 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.volatility[0], self.atr[0]]) and self.volatility[0] > 0:
  169. breakout_signal = self.data.close[0] > self.highest_high[-1]
  170. channel_exit = self.data.close[0] < self.lowest_low[-1]
  171. if not self.hybrid_active:
  172. if breakout_signal:
  173. self.hybrid_active = True
  174. self.hybrid_highest_close = float(self.data.close[0])
  175. else:
  176. self.hybrid_highest_close = max(self.hybrid_highest_close or float(self.data.close[0]), float(self.data.close[0]))
  177. trailing_stop = self.hybrid_highest_close - 4.0 * self.atr[0]
  178. if channel_exit or self.data.close[0] < trailing_stop:
  179. self.hybrid_active = False
  180. self.hybrid_highest_close = None
  181. if self.hybrid_active:
  182. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  183. hy_w = min(1.0, 0.25 / annualized_vol)
  184. weights.append(hy_w)
  185. target_weight = sum(weights) / len(weights)
  186. portfolio_value = self.broker.getvalue()
  187. current_weight = 0.0
  188. if portfolio_value > 0:
  189. current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
  190. if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
  191. self._rebalance_to_weight(target_weight)
  192. class Top5BlendStrategy(BasePortfolioStrategy):
  193. """Average of DTBasic + DonchianRegime + MVT0.30 + MVT0.29 + DonchianHybrid0.25."""
  194. def __init__(self):
  195. super().__init__()
  196. close = self.data.close
  197. returns = bt.indicators.PctChange(close, period=1)
  198. self.volatility = bt.indicators.StdDev(returns, period=30)
  199. self.atr = bt.indicators.ATR(self.data, period=20)
  200. self.roc_short = bt.indicators.ROC(close, period=20)
  201. self.roc_long = bt.indicators.ROC(close, period=120)
  202. self.sma150 = bt.indicators.SMA(close, period=150)
  203. self.highest_high = bt.indicators.Highest(self.data.high, period=55)
  204. self.lowest_low = bt.indicators.Lowest(self.data.low, period=30)
  205. self.dt_active = False
  206. self.don_active = False
  207. self.hybrid_active = False
  208. self.hybrid_highest_close = None
  209. def next(self):
  210. super().next()
  211. if self.order:
  212. return
  213. weights = []
  214. # DTBasic
  215. dt_w = 0.0
  216. if len(self) > 20:
  217. closes = [float(self.data.close[-offset]) for offset in range(1, 21)]
  218. thrust_range = max(closes) - min(closes)
  219. reference_price = float(self.data.close[-1])
  220. upper = reference_price + 0.3 * thrust_range
  221. lower = reference_price - 0.3 * thrust_range
  222. if not self.dt_active and self.data.close[0] > upper:
  223. self.dt_active = True
  224. elif self.dt_active and self.data.close[0] < lower:
  225. self.dt_active = False
  226. dt_w = 1.0 if self.dt_active else 0.0
  227. weights.append(dt_w)
  228. # DonchianRegime
  229. don_w = 0.0
  230. if len(self) > 150 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.sma150[0]]):
  231. breakout_signal = self.data.close[0] > self.highest_high[-1] and self.data.close[0] > self.sma150[0]
  232. exit_signal = self.data.close[0] < self.lowest_low[-1] or self.data.close[0] < self.sma150[0]
  233. if not self.don_active and breakout_signal:
  234. self.don_active = True
  235. elif self.don_active and exit_signal:
  236. self.don_active = False
  237. don_w = 1.0 if self.don_active else 0.0
  238. weights.append(don_w)
  239. # MVT 0.30 and 0.29
  240. for tv in (0.30, 0.29):
  241. mvt_w = 0.0
  242. if not any(math.isnan(x) for x in [self.roc_short[0], self.roc_long[0], self.sma150[0], self.volatility[0]]) and self.volatility[0] > 0:
  243. signal = self.roc_short[0] > 0 and self.roc_long[0] > 0 and self.data.close[0] > self.sma150[0]
  244. if signal:
  245. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  246. mvt_w = min(1.0, tv / annualized_vol)
  247. weights.append(mvt_w)
  248. # DonchianHybrid 0.25
  249. hy_w = 0.0
  250. if len(self) > 55 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.volatility[0], self.atr[0]]) and self.volatility[0] > 0:
  251. breakout_signal = self.data.close[0] > self.highest_high[-1]
  252. channel_exit = self.data.close[0] < self.lowest_low[-1]
  253. if not self.hybrid_active:
  254. if breakout_signal:
  255. self.hybrid_active = True
  256. self.hybrid_highest_close = float(self.data.close[0])
  257. else:
  258. self.hybrid_highest_close = max(self.hybrid_highest_close or float(self.data.close[0]), float(self.data.close[0]))
  259. trailing_stop = self.hybrid_highest_close - 4.0 * self.atr[0]
  260. if channel_exit or self.data.close[0] < trailing_stop:
  261. self.hybrid_active = False
  262. self.hybrid_highest_close = None
  263. if self.hybrid_active:
  264. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  265. hy_w = min(1.0, 0.25 / annualized_vol)
  266. weights.append(hy_w)
  267. target_weight = sum(weights) / len(weights)
  268. portfolio_value = self.broker.getvalue()
  269. current_weight = 0.0
  270. if portfolio_value > 0:
  271. current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
  272. if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
  273. self._rebalance_to_weight(target_weight)
  274. def load_dataframe() -> pd.DataFrame:
  275. df = pd.read_csv(DATA_FILE, parse_dates=['datetime'], index_col='datetime')
  276. df = df.sort_index()
  277. return df[['open', 'high', 'low', 'close', 'volume']].copy()
  278. def run_strategy(strategy_cls, df: pd.DataFrame) -> dict:
  279. cerebro = bt.Cerebro(stdstats=False)
  280. cerebro.adddata(Chinext50Data(dataname=df))
  281. cerebro.addstrategy(strategy_cls)
  282. cerebro.broker.setcash(INITIAL_CASH)
  283. cerebro.broker.setcommission(commission=COMMISSION)
  284. cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
  285. cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
  286. cerebro.addanalyzer(bt.analyzers.SharpeRatio_A, _name='sharpe', riskfreerate=0.02)
  287. cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
  288. strategy = cerebro.run()[0]
  289. final_value = cerebro.broker.getvalue()
  290. returns = strategy.analyzers.returns.get_analysis()
  291. drawdown = strategy.analyzers.drawdown.get_analysis()
  292. sharpe = strategy.analyzers.sharpe.get_analysis()
  293. trades = strategy.analyzers.trades.get_analysis()
  294. closed_trades = trades.get('total', {}).get('closed', 0)
  295. won_trades = trades.get('won', {}).get('total', 0)
  296. total_bars = len(df)
  297. return {
  298. 'final_value': round(final_value, 2),
  299. 'total_return_pct': round((final_value / INITIAL_CASH - 1.0) * 100.0, 2),
  300. 'annual_return_pct': round(returns.get('rnorm100', 0.0), 2),
  301. 'max_drawdown_pct': round(drawdown.get('max', {}).get('drawdown', 0.0), 2),
  302. 'sharpe': round(sharpe['sharperatio'], 3) if sharpe.get('sharperatio') is not None else None,
  303. 'entries': strategy.entry_count,
  304. 'closed_trades': closed_trades,
  305. 'win_rate_pct': round((won_trades / closed_trades) * 100.0, 2) if closed_trades else 0.0,
  306. 'exposure_pct': round((strategy.exposure_sum / total_bars) * 100.0, 2),
  307. }
  308. def main():
  309. df = load_dataframe()
  310. combos = [
  311. ('Core3ComboStrategy', Core3ComboStrategy),
  312. ('Balanced3ComboStrategy', Balanced3ComboStrategy),
  313. ('Top5BlendStrategy', Top5BlendStrategy),
  314. ]
  315. results = []
  316. for name, cls in combos:
  317. metrics = run_strategy(cls, df)
  318. results.append({'name': name, 'metrics': metrics})
  319. print(f"{name}: final={metrics['final_value']}, total_return={metrics['total_return_pct']}%, annual_return={metrics['annual_return_pct']}%, sharpe={metrics['sharpe']}, max_dd={metrics['max_drawdown_pct']}%, closed_trades={metrics['closed_trades']}, win_rate={metrics['win_rate_pct']}%, avg_exposure={metrics['exposure_pct']}%")
  320. stamp = datetime.now().strftime('%Y%m%d-%H%M%S')
  321. json_path = ROOT / f'shortlist_combo_trials_{stamp}.json'
  322. md_path = ROOT / f'shortlist_combo_trials_{stamp}.md'
  323. json_path.write_text(json.dumps({'results': results}, ensure_ascii=False, indent=2))
  324. lines = [
  325. '# Shortlist Combo Trials',
  326. '',
  327. '| Combo | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure |',
  328. '| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
  329. ]
  330. for item in results:
  331. m = item['metrics']
  332. lines.append(f"| {item['name']} | {m['annual_return_pct']:.2f}% | {m['sharpe']} | {m['max_drawdown_pct']:.2f}% | {m['closed_trades']} | {m['win_rate_pct']:.2f}% | {m['exposure_pct']:.2f}% |")
  333. md_path.write_text('\n'.join(lines))
  334. print(f'JSON_RESULT={json_path}')
  335. print(f'MD_RESULT={md_path}')
  336. if __name__ == '__main__':
  337. main()