chinext50_experiments.py 72 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094
  1. #!/usr/bin/env python3
  2. """Run Chinext50 Backtrader experiments and save a short report."""
  3. from __future__ import annotations
  4. import argparse
  5. import itertools
  6. import math
  7. from pathlib import Path
  8. import backtrader as bt
  9. import pandas as pd
  10. ROOT = Path(__file__).resolve().parent
  11. DATA_FILE = ROOT / "chinext50.csv"
  12. REPORT_FILE = ROOT / "chinext50_experiment_summary.md"
  13. DUALTHRUST_OPT_REPORT_FILE = ROOT / "chinext50_dualthrust_optimization.md"
  14. INITIAL_CASH = 100000.0
  15. COMMISSION = 0.001
  16. TRADING_DAYS = 252
  17. class Chinext50Data(bt.feeds.PandasData):
  18. """Explicit column mapping for the local Chinext50 CSV."""
  19. params = (
  20. ("datetime", None),
  21. ("open", "open"),
  22. ("high", "high"),
  23. ("low", "low"),
  24. ("close", "close"),
  25. ("volume", "volume"),
  26. ("openinterest", None),
  27. )
  28. class BaseIndexStrategy(bt.Strategy):
  29. """Common helpers for long-only index timing strategies."""
  30. def __init__(self):
  31. self.order = None
  32. self.entry_count = 0
  33. self.bars_in_market = 0
  34. self.exposure_sum = 0.0
  35. def notify_order(self, order):
  36. if order.status in [order.Submitted, order.Accepted]:
  37. return
  38. if order.status == order.Completed and order.isbuy():
  39. self.entry_count += 1
  40. self.order = None
  41. def next(self):
  42. portfolio_value = self.broker.getvalue()
  43. if portfolio_value > 0:
  44. position_value = abs(self.position.size) * self.data.close[0]
  45. exposure = position_value / portfolio_value
  46. self.exposure_sum += exposure
  47. if exposure > 0:
  48. self.bars_in_market += 1
  49. def _target_size_for_weight(self, target_weight: float) -> int:
  50. target_weight = max(0.0, target_weight)
  51. portfolio_value = self.broker.getvalue()
  52. price = self.data.close[0]
  53. if portfolio_value <= 0 or price <= 0:
  54. return 0
  55. target_value = portfolio_value * target_weight
  56. return max(int(target_value / price), 0)
  57. def _rebalance_to_weight(self, target_weight: float):
  58. target_size = self._target_size_for_weight(target_weight)
  59. current_size = self.position.size
  60. size_delta = target_size - current_size
  61. if size_delta > 0:
  62. self.order = self.buy(size=size_delta)
  63. elif size_delta < 0:
  64. self.order = self.sell(size=abs(size_delta))
  65. def _buy_full(self):
  66. self._rebalance_to_weight(1.0)
  67. def _go_flat(self):
  68. if self.position:
  69. self.order = self.close()
  70. class SuperTrendIndicator(bt.Indicator):
  71. """Minimal SuperTrend implementation built from Backtrader ATR."""
  72. lines = ("supertrend",)
  73. params = (
  74. ("period", 10),
  75. ("multiplier", 3.0),
  76. )
  77. plotinfo = {"subplot": False}
  78. def __init__(self):
  79. hl2 = (self.data.high + self.data.low) / 2.0
  80. self.atr = bt.indicators.ATR(self.data, period=self.p.period)
  81. self.basic_upper = hl2 + self.p.multiplier * self.atr
  82. self.basic_lower = hl2 - self.p.multiplier * self.atr
  83. self._final_upper = None
  84. self._final_lower = None
  85. def next(self):
  86. if math.isnan(self.atr[0]):
  87. return
  88. basic_upper = float(self.basic_upper[0])
  89. basic_lower = float(self.basic_lower[0])
  90. close = float(self.data.close[0])
  91. if self._final_upper is None or self._final_lower is None:
  92. self._final_upper = basic_upper
  93. self._final_lower = basic_lower
  94. self.lines.supertrend[0] = basic_lower
  95. return
  96. prev_final_upper = self._final_upper
  97. prev_final_lower = self._final_lower
  98. prev_close = float(self.data.close[-1])
  99. prev_supertrend = float(self.lines.supertrend[-1])
  100. final_upper = basic_upper if basic_upper < prev_final_upper or prev_close > prev_final_upper else prev_final_upper
  101. final_lower = basic_lower if basic_lower > prev_final_lower or prev_close < prev_final_lower else prev_final_lower
  102. if prev_supertrend == prev_final_upper:
  103. supertrend = final_upper if close <= final_upper else final_lower
  104. else:
  105. supertrend = final_lower if close >= final_lower else final_upper
  106. self._final_upper = final_upper
  107. self._final_lower = final_lower
  108. self.lines.supertrend[0] = supertrend
  109. class TrendRegimeFlatStrategy(BaseIndexStrategy):
  110. """
  111. Trend following with flat/cash regime control.
  112. Rules:
  113. - Long only when short trend > medium trend and price stays above a regime MA
  114. - Exit fully when the trend breaks or short-term volatility spikes above a cap
  115. """
  116. params = (
  117. ("fast", 20),
  118. ("slow", 60),
  119. ("regime", 120),
  120. ("vol_fast", 20),
  121. ("vol_slow", 60),
  122. ("vol_cap", 1.10),
  123. )
  124. def __init__(self):
  125. super().__init__()
  126. close = self.data.close
  127. self.sma_fast = bt.indicators.SMA(close, period=self.p.fast)
  128. self.sma_slow = bt.indicators.SMA(close, period=self.p.slow)
  129. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  130. returns = bt.indicators.PctChange(close, period=1)
  131. self.vol_fast = bt.indicators.StdDev(returns, period=self.p.vol_fast)
  132. self.vol_slow = bt.indicators.StdDev(returns, period=self.p.vol_slow)
  133. def next(self):
  134. super().next()
  135. if self.order:
  136. return
  137. if (
  138. math.isnan(self.sma_fast[0])
  139. or math.isnan(self.sma_slow[0])
  140. or math.isnan(self.sma_regime[0])
  141. or math.isnan(self.vol_fast[0])
  142. or math.isnan(self.vol_slow[0])
  143. or self.vol_slow[0] == 0
  144. ):
  145. return
  146. bullish_trend = self.sma_fast[0] > self.sma_slow[0] and self.data.close[0] > self.sma_regime[0]
  147. calm_enough = self.vol_fast[0] <= self.vol_slow[0] * self.p.vol_cap
  148. if bullish_trend and calm_enough and not self.position:
  149. self._buy_full()
  150. elif self.position and (not bullish_trend or not calm_enough):
  151. self._go_flat()
  152. class TrendTightVolStrategy(BaseIndexStrategy):
  153. """Trend following with the same regime logic but a tighter volatility cap."""
  154. params = (
  155. ("fast", 20),
  156. ("slow", 60),
  157. ("regime", 120),
  158. ("vol_fast", 20),
  159. ("vol_slow", 60),
  160. ("vol_cap", 0.95),
  161. )
  162. def __init__(self):
  163. super().__init__()
  164. close = self.data.close
  165. self.sma_fast = bt.indicators.SMA(close, period=self.p.fast)
  166. self.sma_slow = bt.indicators.SMA(close, period=self.p.slow)
  167. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  168. returns = bt.indicators.PctChange(close, period=1)
  169. self.vol_fast = bt.indicators.StdDev(returns, period=self.p.vol_fast)
  170. self.vol_slow = bt.indicators.StdDev(returns, period=self.p.vol_slow)
  171. def next(self):
  172. super().next()
  173. if self.order:
  174. return
  175. if (
  176. math.isnan(self.sma_fast[0])
  177. or math.isnan(self.sma_slow[0])
  178. or math.isnan(self.sma_regime[0])
  179. or math.isnan(self.vol_fast[0])
  180. or math.isnan(self.vol_slow[0])
  181. or self.vol_slow[0] <= 0
  182. ):
  183. return
  184. bullish_trend = self.sma_fast[0] > self.sma_slow[0] and self.data.close[0] > self.sma_regime[0]
  185. calm_enough = self.vol_fast[0] <= self.vol_slow[0] * self.p.vol_cap
  186. if bullish_trend and calm_enough and not self.position:
  187. self._buy_full()
  188. elif self.position and (not bullish_trend or not calm_enough):
  189. self._go_flat()
  190. class TrendLooseVolStrategy(BaseIndexStrategy):
  191. """Trend following with the same regime logic but a looser volatility cap."""
  192. params = (
  193. ("fast", 20),
  194. ("slow", 60),
  195. ("regime", 120),
  196. ("vol_fast", 20),
  197. ("vol_slow", 60),
  198. ("vol_cap", 1.25),
  199. )
  200. def __init__(self):
  201. super().__init__()
  202. close = self.data.close
  203. self.sma_fast = bt.indicators.SMA(close, period=self.p.fast)
  204. self.sma_slow = bt.indicators.SMA(close, period=self.p.slow)
  205. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  206. returns = bt.indicators.PctChange(close, period=1)
  207. self.vol_fast = bt.indicators.StdDev(returns, period=self.p.vol_fast)
  208. self.vol_slow = bt.indicators.StdDev(returns, period=self.p.vol_slow)
  209. def next(self):
  210. super().next()
  211. if self.order:
  212. return
  213. if (
  214. math.isnan(self.sma_fast[0])
  215. or math.isnan(self.sma_slow[0])
  216. or math.isnan(self.sma_regime[0])
  217. or math.isnan(self.vol_fast[0])
  218. or math.isnan(self.vol_slow[0])
  219. or self.vol_slow[0] <= 0
  220. ):
  221. return
  222. bullish_trend = self.sma_fast[0] > self.sma_slow[0] and self.data.close[0] > self.sma_regime[0]
  223. calm_enough = self.vol_fast[0] <= self.vol_slow[0] * self.p.vol_cap
  224. if bullish_trend and calm_enough and not self.position:
  225. self._buy_full()
  226. elif self.position and (not bullish_trend or not calm_enough):
  227. self._go_flat()
  228. class SmaLongFilterTrendStrategy(BaseIndexStrategy):
  229. """Simple SMA trend following with a long-MA regime filter."""
  230. params = (
  231. ("fast", 20),
  232. ("slow", 60),
  233. ("regime", 120),
  234. )
  235. def __init__(self):
  236. super().__init__()
  237. close = self.data.close
  238. self.sma_fast = bt.indicators.SMA(close, period=self.p.fast)
  239. self.sma_slow = bt.indicators.SMA(close, period=self.p.slow)
  240. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  241. def next(self):
  242. super().next()
  243. if self.order:
  244. return
  245. if math.isnan(self.sma_fast[0]) or math.isnan(self.sma_slow[0]) or math.isnan(self.sma_regime[0]):
  246. return
  247. signal = self.sma_fast[0] > self.sma_slow[0] and self.data.close[0] > self.sma_regime[0]
  248. if signal and not self.position:
  249. self._buy_full()
  250. elif self.position and not signal:
  251. self._go_flat()
  252. class MomentumRegimeStrategy(BaseIndexStrategy):
  253. """
  254. Single-asset momentum timing proxy for an index.
  255. Rules:
  256. - Long only when both short- and medium-term momentum are positive
  257. - Require price above a longer regime MA to avoid deep bear phases
  258. - Otherwise stay flat
  259. """
  260. params = (
  261. ("mom_short", 20),
  262. ("mom_long", 120),
  263. ("regime", 150),
  264. )
  265. def __init__(self):
  266. super().__init__()
  267. close = self.data.close
  268. self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
  269. self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
  270. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  271. def next(self):
  272. super().next()
  273. if self.order:
  274. return
  275. if math.isnan(self.roc_short[0]) or math.isnan(self.roc_long[0]) or math.isnan(self.sma_regime[0]):
  276. return
  277. signal = (
  278. self.roc_short[0] > 0
  279. and self.roc_long[0] > 0
  280. and self.data.close[0] > self.sma_regime[0]
  281. )
  282. if signal and not self.position:
  283. self._buy_full()
  284. elif self.position and not signal:
  285. self._go_flat()
  286. class MomentumBasicStrategy(BaseIndexStrategy):
  287. """Dual-window momentum without the long regime filter."""
  288. params = (
  289. ("mom_short", 20),
  290. ("mom_long", 120),
  291. )
  292. def __init__(self):
  293. super().__init__()
  294. close = self.data.close
  295. self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
  296. self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
  297. def next(self):
  298. super().next()
  299. if self.order:
  300. return
  301. if math.isnan(self.roc_short[0]) or math.isnan(self.roc_long[0]):
  302. return
  303. signal = self.roc_short[0] > 0 and self.roc_long[0] > 0
  304. if signal and not self.position:
  305. self._buy_full()
  306. elif self.position and not signal:
  307. self._go_flat()
  308. class MomentumMaFilterStrategy(BaseIndexStrategy):
  309. """Momentum timing with a medium-term moving-average confirmation filter."""
  310. params = (
  311. ("mom_short", 20),
  312. ("mom_long", 120),
  313. ("ma_filter", 60),
  314. )
  315. def __init__(self):
  316. super().__init__()
  317. close = self.data.close
  318. self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
  319. self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
  320. self.sma_filter = bt.indicators.SMA(close, period=self.p.ma_filter)
  321. def next(self):
  322. super().next()
  323. if self.order:
  324. return
  325. if math.isnan(self.roc_short[0]) or math.isnan(self.roc_long[0]) or math.isnan(self.sma_filter[0]):
  326. return
  327. signal = (
  328. self.roc_short[0] > 0
  329. and self.roc_long[0] > 0
  330. and self.data.close[0] > self.sma_filter[0]
  331. )
  332. if signal and not self.position:
  333. self._buy_full()
  334. elif self.position and not signal:
  335. self._go_flat()
  336. class MomentumDefensiveFilterStrategy(BaseIndexStrategy):
  337. """Momentum timing with a regime filter and simple fast/slow volatility cap."""
  338. params = (
  339. ("mom_short", 20),
  340. ("mom_long", 120),
  341. ("regime", 150),
  342. ("vol_fast", 20),
  343. ("vol_slow", 60),
  344. ("vol_cap", 1.05),
  345. )
  346. def __init__(self):
  347. super().__init__()
  348. close = self.data.close
  349. self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
  350. self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
  351. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  352. returns = bt.indicators.PctChange(close, period=1)
  353. self.vol_fast = bt.indicators.StdDev(returns, period=self.p.vol_fast)
  354. self.vol_slow = bt.indicators.StdDev(returns, period=self.p.vol_slow)
  355. def next(self):
  356. super().next()
  357. if self.order:
  358. return
  359. if (
  360. math.isnan(self.roc_short[0])
  361. or math.isnan(self.roc_long[0])
  362. or math.isnan(self.sma_regime[0])
  363. or math.isnan(self.vol_fast[0])
  364. or math.isnan(self.vol_slow[0])
  365. or self.vol_slow[0] <= 0
  366. ):
  367. return
  368. signal = (
  369. self.roc_short[0] > 0
  370. and self.roc_long[0] > 0
  371. and self.data.close[0] > self.sma_regime[0]
  372. and self.vol_fast[0] <= self.vol_slow[0] * self.p.vol_cap
  373. )
  374. if signal and not self.position:
  375. self._buy_full()
  376. elif self.position and not signal:
  377. self._go_flat()
  378. class MomentumAtrTrailBasicStrategy(BaseIndexStrategy):
  379. """Momentum entry with ATR trailing exit but no regime filter."""
  380. params = (
  381. ("mom_short", 20),
  382. ("mom_long", 120),
  383. ("atr_period", 20),
  384. ("atr_mult", 4.0),
  385. )
  386. def __init__(self):
  387. super().__init__()
  388. close = self.data.close
  389. self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
  390. self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
  391. self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
  392. self.highest_close = None
  393. def notify_order(self, order):
  394. super().notify_order(order)
  395. if order.status != order.Completed:
  396. return
  397. if order.isbuy():
  398. self.highest_close = order.executed.price
  399. elif not self.position:
  400. self.highest_close = None
  401. def next(self):
  402. super().next()
  403. if self.order:
  404. return
  405. if math.isnan(self.roc_short[0]) or math.isnan(self.roc_long[0]) or math.isnan(self.atr[0]):
  406. return
  407. entry_signal = self.roc_short[0] > 0 and self.roc_long[0] > 0
  408. if not self.position:
  409. if entry_signal:
  410. self._buy_full()
  411. return
  412. if self.highest_close is None:
  413. self.highest_close = self.data.close[0]
  414. self.highest_close = max(self.highest_close, self.data.close[0])
  415. trailing_stop = self.highest_close - self.p.atr_mult * self.atr[0]
  416. if self.data.close[0] < trailing_stop or not entry_signal:
  417. self._go_flat()
  418. class MomentumAtrTrailStrategy(BaseIndexStrategy):
  419. """Momentum entry with a regime filter and ATR trailing exit."""
  420. params = (
  421. ("mom_short", 20),
  422. ("mom_long", 120),
  423. ("regime", 150),
  424. ("atr_period", 20),
  425. ("atr_mult", 4.0),
  426. )
  427. def __init__(self):
  428. super().__init__()
  429. close = self.data.close
  430. self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
  431. self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
  432. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  433. self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
  434. self.highest_close = None
  435. def notify_order(self, order):
  436. super().notify_order(order)
  437. if order.status != order.Completed:
  438. return
  439. if order.isbuy():
  440. self.highest_close = order.executed.price
  441. elif not self.position:
  442. self.highest_close = None
  443. def next(self):
  444. super().next()
  445. if self.order:
  446. return
  447. if (
  448. math.isnan(self.roc_short[0])
  449. or math.isnan(self.roc_long[0])
  450. or math.isnan(self.sma_regime[0])
  451. or math.isnan(self.atr[0])
  452. ):
  453. return
  454. entry_signal = (
  455. self.roc_short[0] > 0
  456. and self.roc_long[0] > 0
  457. and self.data.close[0] > self.sma_regime[0]
  458. )
  459. if not self.position:
  460. if entry_signal:
  461. self._buy_full()
  462. return
  463. if self.highest_close is None:
  464. self.highest_close = self.data.close[0]
  465. self.highest_close = max(self.highest_close, self.data.close[0])
  466. trailing_stop = self.highest_close - self.p.atr_mult * self.atr[0]
  467. if self.data.close[0] < trailing_stop or not entry_signal:
  468. self._go_flat()
  469. class DonchianRegimeStrategy(BaseIndexStrategy):
  470. """Donchian breakout gated by a long moving-average regime filter."""
  471. params = (
  472. ("breakout", 55),
  473. ("exit_period", 30),
  474. ("regime", 150),
  475. )
  476. def __init__(self):
  477. super().__init__()
  478. self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
  479. self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
  480. self.sma_regime = bt.indicators.SMA(self.data.close, period=self.p.regime)
  481. def next(self):
  482. super().next()
  483. if self.order:
  484. return
  485. if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.regime):
  486. return
  487. if math.isnan(self.sma_regime[0]) or math.isnan(self.highest_high[-1]) or math.isnan(self.lowest_low[-1]):
  488. return
  489. breakout_signal = self.data.close[0] > self.highest_high[-1] and self.data.close[0] > self.sma_regime[0]
  490. exit_signal = self.data.close[0] < self.lowest_low[-1] or self.data.close[0] < self.sma_regime[0]
  491. if breakout_signal and not self.position:
  492. self._buy_full()
  493. elif self.position and exit_signal:
  494. self._go_flat()
  495. class DonchianBasicStrategy(BaseIndexStrategy):
  496. """Pure Donchian breakout without regime or ADX filtering."""
  497. params = (
  498. ("breakout", 55),
  499. ("exit_period", 30),
  500. )
  501. def __init__(self):
  502. super().__init__()
  503. self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
  504. self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
  505. def next(self):
  506. super().next()
  507. if self.order:
  508. return
  509. if len(self) <= max(self.p.breakout, self.p.exit_period):
  510. return
  511. if math.isnan(self.highest_high[-1]) or math.isnan(self.lowest_low[-1]):
  512. return
  513. breakout_signal = self.data.close[0] > self.highest_high[-1]
  514. exit_signal = self.data.close[0] < self.lowest_low[-1]
  515. if breakout_signal and not self.position:
  516. self._buy_full()
  517. elif self.position and exit_signal:
  518. self._go_flat()
  519. class DonchianAdxStrategy(BaseIndexStrategy):
  520. """Donchian breakout with an ADX trend-strength filter."""
  521. params = (
  522. ("breakout", 55),
  523. ("exit_period", 30),
  524. ("adx_period", 14),
  525. ("adx_threshold", 20.0),
  526. )
  527. def __init__(self):
  528. super().__init__()
  529. self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
  530. self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
  531. self.adx = bt.indicators.ADX(self.data, period=self.p.adx_period)
  532. def next(self):
  533. super().next()
  534. if self.order:
  535. return
  536. if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.adx_period):
  537. return
  538. if math.isnan(self.highest_high[-1]) or math.isnan(self.lowest_low[-1]) or math.isnan(self.adx[0]):
  539. return
  540. breakout_signal = self.data.close[0] > self.highest_high[-1] and self.adx[0] >= self.p.adx_threshold
  541. exit_signal = self.data.close[0] < self.lowest_low[-1]
  542. if breakout_signal and not self.position:
  543. self._buy_full()
  544. elif self.position and exit_signal:
  545. self._go_flat()
  546. class DonchianAtrTrailStrategy(BaseIndexStrategy):
  547. """Donchian breakout with ATR trailing protection but no vol-target overlay."""
  548. params = (
  549. ("breakout", 55),
  550. ("exit_period", 30),
  551. ("atr_period", 20),
  552. ("atr_mult", 4.0),
  553. )
  554. def __init__(self):
  555. super().__init__()
  556. self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
  557. self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
  558. self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
  559. self.highest_close = None
  560. def notify_order(self, order):
  561. super().notify_order(order)
  562. if order.status != order.Completed:
  563. return
  564. if order.isbuy():
  565. self.highest_close = order.executed.price
  566. elif not self.position:
  567. self.highest_close = None
  568. def next(self):
  569. super().next()
  570. if self.order:
  571. return
  572. if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.atr_period):
  573. return
  574. if math.isnan(self.highest_high[-1]) or math.isnan(self.lowest_low[-1]) or math.isnan(self.atr[0]):
  575. return
  576. breakout_signal = self.data.close[0] > self.highest_high[-1]
  577. channel_exit = self.data.close[0] < self.lowest_low[-1]
  578. if not self.position:
  579. if breakout_signal:
  580. self._buy_full()
  581. return
  582. self.highest_close = max(self.highest_close or float(self.data.close[0]), float(self.data.close[0]))
  583. atr_exit = self.data.close[0] < self.highest_close - self.p.atr_mult * self.atr[0]
  584. if channel_exit or atr_exit:
  585. self._go_flat()
  586. class DonchianVolTargetStrategy(BaseIndexStrategy):
  587. """Donchian breakout with volatility-target sizing but no ATR trailing overlay."""
  588. params = (
  589. ("breakout", 55),
  590. ("exit_period", 30),
  591. ("vol_period", 30),
  592. ("target_vol", 0.30),
  593. ("max_weight", 1.0),
  594. ("rebalance_band", 0.15),
  595. )
  596. def __init__(self):
  597. super().__init__()
  598. self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
  599. self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
  600. returns = bt.indicators.PctChange(self.data.close, period=1)
  601. self.volatility = bt.indicators.StdDev(returns, period=self.p.vol_period)
  602. def next(self):
  603. super().next()
  604. if self.order:
  605. return
  606. if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.vol_period):
  607. return
  608. if (
  609. math.isnan(self.highest_high[-1])
  610. or math.isnan(self.lowest_low[-1])
  611. or math.isnan(self.volatility[0])
  612. or self.volatility[0] <= 0
  613. ):
  614. return
  615. breakout_signal = self.data.close[0] > self.highest_high[-1]
  616. exit_signal = self.data.close[0] < self.lowest_low[-1]
  617. if not self.position:
  618. if not breakout_signal:
  619. return
  620. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  621. target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
  622. self._rebalance_to_weight(max(0.0, target_weight))
  623. return
  624. if exit_signal:
  625. self._go_flat()
  626. return
  627. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  628. target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
  629. portfolio_value = self.broker.getvalue()
  630. current_weight = 0.0
  631. if portfolio_value > 0:
  632. current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
  633. if abs(current_weight - target_weight) >= self.p.rebalance_band:
  634. self._rebalance_to_weight(max(0.0, target_weight))
  635. class DonchianHybridVolAtrStrategy(BaseIndexStrategy):
  636. """Donchian breakout with vol-target sizing and ATR trailing protection."""
  637. params = (
  638. ("breakout", 55),
  639. ("exit_period", 30),
  640. ("vol_period", 30),
  641. ("target_vol", 0.30),
  642. ("max_weight", 1.0),
  643. ("rebalance_band", 0.15),
  644. ("atr_period", 20),
  645. ("atr_mult", 4.0),
  646. )
  647. def __init__(self):
  648. super().__init__()
  649. self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
  650. self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
  651. returns = bt.indicators.PctChange(self.data.close, period=1)
  652. self.volatility = bt.indicators.StdDev(returns, period=self.p.vol_period)
  653. self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
  654. self.highest_close = None
  655. def notify_order(self, order):
  656. super().notify_order(order)
  657. if order.status != order.Completed:
  658. return
  659. if self.position:
  660. self.highest_close = max(self.highest_close or order.executed.price, order.executed.price)
  661. else:
  662. self.highest_close = None
  663. def next(self):
  664. super().next()
  665. if self.order:
  666. return
  667. if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.vol_period, self.p.atr_period):
  668. return
  669. if (
  670. math.isnan(self.highest_high[-1])
  671. or math.isnan(self.lowest_low[-1])
  672. or math.isnan(self.volatility[0])
  673. or math.isnan(self.atr[0])
  674. or self.volatility[0] <= 0
  675. ):
  676. return
  677. breakout_signal = self.data.close[0] > self.highest_high[-1]
  678. channel_exit = self.data.close[0] < self.lowest_low[-1]
  679. if not self.position:
  680. if not breakout_signal:
  681. return
  682. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  683. target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
  684. self._rebalance_to_weight(max(0.0, target_weight))
  685. return
  686. if self.highest_close is None:
  687. self.highest_close = self.data.close[0]
  688. self.highest_close = max(self.highest_close, self.data.close[0])
  689. trailing_stop = self.highest_close - self.p.atr_mult * self.atr[0]
  690. if channel_exit or self.data.close[0] < trailing_stop:
  691. self._go_flat()
  692. return
  693. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  694. target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
  695. target_weight = max(0.0, target_weight)
  696. portfolio_value = self.broker.getvalue()
  697. current_weight = 0.0
  698. if portfolio_value > 0:
  699. current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
  700. if abs(current_weight - target_weight) >= self.p.rebalance_band:
  701. self._rebalance_to_weight(target_weight)
  702. class MomentumVolTargetBasicStrategy(BaseIndexStrategy):
  703. """Dual-window momentum with volatility-target sizing but no regime filter."""
  704. params = (
  705. ("mom_short", 20),
  706. ("mom_long", 120),
  707. ("vol_period", 30),
  708. ("target_vol", 0.30),
  709. ("max_weight", 1.0),
  710. ("rebalance_band", 0.15),
  711. )
  712. def __init__(self):
  713. super().__init__()
  714. close = self.data.close
  715. self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
  716. self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
  717. returns = bt.indicators.PctChange(close, period=1)
  718. self.volatility = bt.indicators.StdDev(returns, period=self.p.vol_period)
  719. def next(self):
  720. super().next()
  721. if self.order:
  722. return
  723. if (
  724. math.isnan(self.roc_short[0])
  725. or math.isnan(self.roc_long[0])
  726. or math.isnan(self.volatility[0])
  727. or self.volatility[0] <= 0
  728. ):
  729. return
  730. signal = self.roc_short[0] > 0 and self.roc_long[0] > 0
  731. if not signal:
  732. self._go_flat()
  733. return
  734. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  735. target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
  736. target_weight = max(0.0, target_weight)
  737. portfolio_value = self.broker.getvalue()
  738. current_weight = 0.0
  739. if portfolio_value > 0:
  740. current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
  741. if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
  742. self._rebalance_to_weight(target_weight)
  743. class MomentumVolTargetStrategy(BaseIndexStrategy):
  744. """Momentum regime filter with volatility-capped position scaling."""
  745. params = (
  746. ("mom_short", 20),
  747. ("mom_long", 120),
  748. ("regime", 150),
  749. ("vol_period", 30),
  750. ("target_vol", 0.30),
  751. ("max_weight", 1.0),
  752. ("rebalance_band", 0.15),
  753. )
  754. def __init__(self):
  755. super().__init__()
  756. close = self.data.close
  757. self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
  758. self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
  759. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  760. returns = bt.indicators.PctChange(close, period=1)
  761. self.volatility = bt.indicators.StdDev(returns, period=self.p.vol_period)
  762. def next(self):
  763. super().next()
  764. if self.order:
  765. return
  766. if (
  767. math.isnan(self.roc_short[0])
  768. or math.isnan(self.roc_long[0])
  769. or math.isnan(self.sma_regime[0])
  770. or math.isnan(self.volatility[0])
  771. or self.volatility[0] <= 0
  772. ):
  773. return
  774. signal = (
  775. self.roc_short[0] > 0
  776. and self.roc_long[0] > 0
  777. and self.data.close[0] > self.sma_regime[0]
  778. )
  779. if not signal:
  780. self._go_flat()
  781. return
  782. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  783. target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
  784. target_weight = max(0.0, target_weight)
  785. portfolio_value = self.broker.getvalue()
  786. current_weight = 0.0
  787. if portfolio_value > 0:
  788. current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
  789. if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
  790. self._rebalance_to_weight(target_weight)
  791. class SuperTrendLongMaStrategy(BaseIndexStrategy):
  792. """SuperTrend breakout gated by a longer moving-average regime."""
  793. params = (
  794. ("supertrend_period", 10),
  795. ("supertrend_mult", 3.0),
  796. ("regime", 200),
  797. )
  798. def __init__(self):
  799. super().__init__()
  800. self.supertrend = SuperTrendIndicator(
  801. self.data,
  802. period=self.p.supertrend_period,
  803. multiplier=self.p.supertrend_mult,
  804. )
  805. self.sma_regime = bt.indicators.SMA(self.data.close, period=self.p.regime)
  806. def next(self):
  807. super().next()
  808. if self.order:
  809. return
  810. if math.isnan(self.supertrend[0]) or math.isnan(self.sma_regime[0]):
  811. return
  812. signal = self.data.close[0] > self.supertrend[0] and self.data.close[0] > self.sma_regime[0]
  813. if signal and not self.position:
  814. self._buy_full()
  815. elif self.position and not signal:
  816. self._go_flat()
  817. class SuperTrendBasicStrategy(BaseIndexStrategy):
  818. """Pure SuperTrend breakout without the long-MA regime filter."""
  819. params = (
  820. ("supertrend_period", 10),
  821. ("supertrend_mult", 3.0),
  822. )
  823. def __init__(self):
  824. super().__init__()
  825. self.supertrend = SuperTrendIndicator(
  826. self.data,
  827. period=self.p.supertrend_period,
  828. multiplier=self.p.supertrend_mult,
  829. )
  830. def next(self):
  831. super().next()
  832. if self.order:
  833. return
  834. if math.isnan(self.supertrend[0]):
  835. return
  836. signal = self.data.close[0] > self.supertrend[0]
  837. if signal and not self.position:
  838. self._buy_full()
  839. elif self.position and not signal:
  840. self._go_flat()
  841. class TsmomLooseThresholdStrategy(BaseIndexStrategy):
  842. """Multi-window time-series momentum with regime filter but a looser positivity threshold."""
  843. params = (
  844. ("mom_windows", (60, 120, 240)),
  845. ("positive_threshold", 1),
  846. ("regime", 200),
  847. )
  848. def __init__(self):
  849. super().__init__()
  850. close = self.data.close
  851. self.momentum = [bt.indicators.ROC(close, period=window) for window in self.p.mom_windows]
  852. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  853. def next(self):
  854. super().next()
  855. if self.order:
  856. return
  857. if math.isnan(self.sma_regime[0]) or any(math.isnan(indicator[0]) for indicator in self.momentum):
  858. return
  859. positive_count = sum(indicator[0] > 0 for indicator in self.momentum)
  860. signal = positive_count >= self.p.positive_threshold and self.data.close[0] > self.sma_regime[0]
  861. if signal and not self.position:
  862. self._buy_full()
  863. elif self.position and not signal:
  864. self._go_flat()
  865. class TsmomBasicStrategy(BaseIndexStrategy):
  866. """Multi-window time-series momentum without the long-MA regime filter."""
  867. params = (
  868. ("mom_windows", (60, 120, 240)),
  869. ("positive_threshold", 2),
  870. )
  871. def __init__(self):
  872. super().__init__()
  873. close = self.data.close
  874. self.momentum = [bt.indicators.ROC(close, period=window) for window in self.p.mom_windows]
  875. def next(self):
  876. super().next()
  877. if self.order:
  878. return
  879. if any(math.isnan(indicator[0]) for indicator in self.momentum):
  880. return
  881. positive_count = sum(indicator[0] > 0 for indicator in self.momentum)
  882. signal = positive_count >= self.p.positive_threshold
  883. if signal and not self.position:
  884. self._buy_full()
  885. elif self.position and not signal:
  886. self._go_flat()
  887. class TsmomRegimeStrategy(BaseIndexStrategy):
  888. """Multi-window time-series momentum with a long MA regime filter."""
  889. params = (
  890. ("mom_windows", (60, 120, 240)),
  891. ("positive_threshold", 2),
  892. ("regime", 200),
  893. )
  894. def __init__(self):
  895. super().__init__()
  896. close = self.data.close
  897. self.momentum = [bt.indicators.ROC(close, period=window) for window in self.p.mom_windows]
  898. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  899. def next(self):
  900. super().next()
  901. if self.order:
  902. return
  903. if math.isnan(self.sma_regime[0]) or any(math.isnan(indicator[0]) for indicator in self.momentum):
  904. return
  905. positive_count = sum(indicator[0] > 0 for indicator in self.momentum)
  906. signal = positive_count >= self.p.positive_threshold and self.data.close[0] > self.sma_regime[0]
  907. if signal and not self.position:
  908. self._buy_full()
  909. elif self.position and not signal:
  910. self._go_flat()
  911. class KamaBasicStrategy(BaseIndexStrategy):
  912. """KAMA trend following without the long-MA regime filter."""
  913. params = (
  914. ("kama_period", 30),
  915. ("kama_fast", 2),
  916. ("kama_slow", 30),
  917. )
  918. def __init__(self):
  919. super().__init__()
  920. close = self.data.close
  921. self.kama = bt.indicators.KAMA(
  922. close,
  923. period=self.p.kama_period,
  924. fast=self.p.kama_fast,
  925. slow=self.p.kama_slow,
  926. )
  927. def next(self):
  928. super().next()
  929. if self.order:
  930. return
  931. if len(self) <= self.p.kama_period:
  932. return
  933. if math.isnan(self.kama[0]) or math.isnan(self.kama[-1]):
  934. return
  935. signal = self.data.close[0] > self.kama[0] and self.kama[0] > self.kama[-1]
  936. if signal and not self.position:
  937. self._buy_full()
  938. elif self.position and not signal:
  939. self._go_flat()
  940. class KamaTrendStrategy(BaseIndexStrategy):
  941. """KAMA trend following with slope confirmation and long MA regime."""
  942. params = (
  943. ("kama_period", 30),
  944. ("kama_fast", 2),
  945. ("kama_slow", 30),
  946. ("regime", 200),
  947. )
  948. def __init__(self):
  949. super().__init__()
  950. close = self.data.close
  951. self.kama = bt.indicators.KAMA(
  952. close,
  953. period=self.p.kama_period,
  954. fast=self.p.kama_fast,
  955. slow=self.p.kama_slow,
  956. )
  957. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  958. def next(self):
  959. super().next()
  960. if self.order:
  961. return
  962. if len(self) <= max(self.p.kama_period, self.p.regime):
  963. return
  964. if math.isnan(self.kama[0]) or math.isnan(self.kama[-1]) or math.isnan(self.sma_regime[0]):
  965. return
  966. signal = (
  967. self.data.close[0] > self.kama[0]
  968. and self.kama[0] > self.kama[-1]
  969. and self.data.close[0] > self.sma_regime[0]
  970. )
  971. if signal and not self.position:
  972. self._buy_full()
  973. elif self.position and not signal:
  974. self._go_flat()
  975. class DualThrustRegimeStrategy(BaseIndexStrategy):
  976. """Close-only Dual Thrust proxy for daily index data with no intraday range."""
  977. params = (
  978. ("range_period", 20),
  979. ("k1", 0.5),
  980. ("k2", 0.5),
  981. ("regime", 200),
  982. )
  983. def __init__(self):
  984. super().__init__()
  985. self.sma_regime = bt.indicators.SMA(self.data.close, period=self.p.regime)
  986. def next(self):
  987. super().next()
  988. if self.order:
  989. return
  990. if len(self) <= max(self.p.range_period, self.p.regime):
  991. return
  992. if math.isnan(self.sma_regime[0]):
  993. return
  994. closes = [float(self.data.close[-offset]) for offset in range(1, self.p.range_period + 1)]
  995. thrust_range = max(closes) - min(closes)
  996. reference_price = float(self.data.close[-1])
  997. upper = reference_price + self.p.k1 * thrust_range
  998. lower = reference_price - self.p.k2 * thrust_range
  999. entry_signal = self.data.close[0] > upper and self.data.close[0] > self.sma_regime[0]
  1000. exit_signal = self.data.close[0] < lower or self.data.close[0] < self.sma_regime[0]
  1001. if entry_signal and not self.position:
  1002. self._buy_full()
  1003. elif self.position and exit_signal:
  1004. self._go_flat()
  1005. class DualThrustBasicStrategy(BaseIndexStrategy):
  1006. """Close-only Dual Thrust proxy without regime filter."""
  1007. params = (
  1008. ("range_period", 20),
  1009. ("k1", 0.5),
  1010. ("k2", 0.5),
  1011. )
  1012. def __init__(self):
  1013. super().__init__()
  1014. def next(self):
  1015. super().next()
  1016. if self.order:
  1017. return
  1018. if len(self) <= self.p.range_period:
  1019. return
  1020. closes = [float(self.data.close[-offset]) for offset in range(1, self.p.range_period + 1)]
  1021. thrust_range = max(closes) - min(closes)
  1022. reference_price = float(self.data.close[-1])
  1023. upper = reference_price + self.p.k1 * thrust_range
  1024. lower = reference_price - self.p.k2 * thrust_range
  1025. entry_signal = self.data.close[0] > upper
  1026. exit_signal = self.data.close[0] < lower
  1027. if entry_signal and not self.position:
  1028. self._buy_full()
  1029. elif self.position and exit_signal:
  1030. self._go_flat()
  1031. class MacdBasicStrategy(BaseIndexStrategy):
  1032. """MACD trend timing without the long moving-average regime filter."""
  1033. params = (
  1034. ("macd_fast", 12),
  1035. ("macd_slow", 26),
  1036. ("macd_signal", 9),
  1037. )
  1038. def __init__(self):
  1039. super().__init__()
  1040. close = self.data.close
  1041. self.macd = bt.indicators.MACD(
  1042. close,
  1043. period_me1=self.p.macd_fast,
  1044. period_me2=self.p.macd_slow,
  1045. period_signal=self.p.macd_signal,
  1046. )
  1047. def next(self):
  1048. super().next()
  1049. if self.order:
  1050. return
  1051. if math.isnan(self.macd.macd[0]) or math.isnan(self.macd.signal[0]):
  1052. return
  1053. signal = self.macd.macd[0] > self.macd.signal[0]
  1054. if signal and not self.position:
  1055. self._buy_full()
  1056. elif self.position and not signal:
  1057. self._go_flat()
  1058. class MacdLongMaStrategy(BaseIndexStrategy):
  1059. """MACD trend timing with a longer moving-average regime filter."""
  1060. params = (
  1061. ("macd_fast", 12),
  1062. ("macd_slow", 26),
  1063. ("macd_signal", 9),
  1064. ("regime", 200),
  1065. )
  1066. def __init__(self):
  1067. super().__init__()
  1068. close = self.data.close
  1069. self.macd = bt.indicators.MACD(
  1070. close,
  1071. period_me1=self.p.macd_fast,
  1072. period_me2=self.p.macd_slow,
  1073. period_signal=self.p.macd_signal,
  1074. )
  1075. self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
  1076. def next(self):
  1077. super().next()
  1078. if self.order:
  1079. return
  1080. if math.isnan(self.macd.macd[0]) or math.isnan(self.macd.signal[0]) or math.isnan(self.sma_regime[0]):
  1081. return
  1082. signal = self.macd.macd[0] > self.macd.signal[0] and self.data.close[0] > self.sma_regime[0]
  1083. if signal and not self.position:
  1084. self._buy_full()
  1085. elif self.position and not signal:
  1086. self._go_flat()
  1087. def load_dataframe() -> pd.DataFrame:
  1088. df = pd.read_csv(DATA_FILE, parse_dates=["datetime"], index_col="datetime")
  1089. df = df.sort_index()
  1090. return df[["open", "high", "low", "close", "volume"]].copy()
  1091. def run_strategy(strategy_cls, config: dict, df: pd.DataFrame | None = None) -> dict:
  1092. if df is None:
  1093. df = load_dataframe()
  1094. cerebro = bt.Cerebro(stdstats=False)
  1095. cerebro.adddata(Chinext50Data(dataname=df))
  1096. cerebro.addstrategy(strategy_cls, **config)
  1097. cerebro.broker.setcash(INITIAL_CASH)
  1098. cerebro.broker.setcommission(commission=COMMISSION)
  1099. cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
  1100. cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
  1101. cerebro.addanalyzer(bt.analyzers.SharpeRatio_A, _name="sharpe", riskfreerate=0.02)
  1102. cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
  1103. strategy = cerebro.run()[0]
  1104. final_value = cerebro.broker.getvalue()
  1105. returns = strategy.analyzers.returns.get_analysis()
  1106. drawdown = strategy.analyzers.drawdown.get_analysis()
  1107. sharpe = strategy.analyzers.sharpe.get_analysis()
  1108. trades = strategy.analyzers.trades.get_analysis()
  1109. closed_trades = trades.get("total", {}).get("closed", 0)
  1110. won_trades = trades.get("won", {}).get("total", 0)
  1111. total_bars = len(df)
  1112. return {
  1113. "final_value": round(final_value, 2),
  1114. "total_return_pct": round((final_value / INITIAL_CASH - 1.0) * 100.0, 2),
  1115. "annual_return_pct": round(returns.get("rnorm100", 0.0), 2),
  1116. "max_drawdown_pct": round(drawdown.get("max", {}).get("drawdown", 0.0), 2),
  1117. "sharpe": round(sharpe["sharperatio"], 3) if sharpe.get("sharperatio") is not None else None,
  1118. "entries": strategy.entry_count,
  1119. "closed_trades": closed_trades,
  1120. "win_rate_pct": round((won_trades / closed_trades) * 100.0, 2) if closed_trades else 0.0,
  1121. "exposure_pct": round((strategy.exposure_sum / total_bars) * 100.0, 2),
  1122. }
  1123. def format_config(config: dict) -> str:
  1124. return ", ".join(f"`{key}={value}`" for key, value in config.items())
  1125. def format_config_plain(config: dict) -> str:
  1126. return ", ".join(f"{key}={value}" for key, value in config.items())
  1127. def metric_text(value):
  1128. return "N/A" if value is None else value
  1129. def sharpe_sort_value(item: dict) -> float:
  1130. sharpe = item["metrics"]["sharpe"]
  1131. return float("-inf") if sharpe is None else sharpe
  1132. def choose_tradeoff_winner(results: list[dict]) -> dict:
  1133. return max(
  1134. results,
  1135. key=lambda item: (
  1136. sharpe_sort_value(item),
  1137. item["metrics"]["annual_return_pct"],
  1138. -item["metrics"]["max_drawdown_pct"],
  1139. ),
  1140. )
  1141. def choose_capped_drawdown_winner(results: list[dict]) -> tuple[dict, float | None]:
  1142. for drawdown_cap in (35.0, 40.0):
  1143. eligible = [
  1144. item
  1145. for item in results
  1146. if item["metrics"]["sharpe"] is not None and item["metrics"]["max_drawdown_pct"] <= drawdown_cap
  1147. ]
  1148. if eligible:
  1149. return choose_tradeoff_winner(eligible), drawdown_cap
  1150. eligible = [item for item in results if item["metrics"]["sharpe"] is not None]
  1151. if eligible:
  1152. return choose_tradeoff_winner(eligible), None
  1153. return min(results, key=lambda item: item["metrics"]["max_drawdown_pct"]), None
  1154. def choose_best_by_drawdown_cap(results: list[dict], drawdown_cap: float) -> dict | None:
  1155. eligible = [item for item in results if item["metrics"]["max_drawdown_pct"] <= drawdown_cap]
  1156. if not eligible:
  1157. return None
  1158. return choose_tradeoff_winner(eligible)
  1159. def dualthrust_search_configs() -> list[dict]:
  1160. range_periods = [10, 15, 20, 25, 30]
  1161. k_values = [0.2, 0.3, 0.4, 0.5]
  1162. regimes = [60, 90, 120, 150, 200]
  1163. configs = []
  1164. for range_period, k1, k2, regime in itertools.product(range_periods, k_values, k_values, regimes):
  1165. configs.append(
  1166. {
  1167. "range_period": range_period,
  1168. "k1": round(k1, 1),
  1169. "k2": round(k2, 1),
  1170. "regime": regime,
  1171. }
  1172. )
  1173. return configs
  1174. def summarize_dualthrust_candidate(label: str, item: dict | None) -> list[str]:
  1175. if item is None:
  1176. return [f"- {label}: no configuration met the filter."]
  1177. metrics = item["metrics"]
  1178. return [
  1179. (
  1180. f"- {label}: `{format_config_plain(item['config'])}` "
  1181. f"| annual return `{metrics['annual_return_pct']:.2f}%` "
  1182. f"| Sharpe `{metric_text(metrics['sharpe'])}` "
  1183. f"| max DD `{metrics['max_drawdown_pct']:.2f}%` "
  1184. f"| trades `{metrics['closed_trades']}`"
  1185. )
  1186. ]
  1187. def build_dualthrust_optimization_report(results: list[dict], default_result: dict, df: pd.DataFrame) -> str:
  1188. best_return = max(results, key=lambda item: item["metrics"]["annual_return_pct"])
  1189. best_sharpe = choose_tradeoff_winner(results)
  1190. best_dd30 = choose_best_by_drawdown_cap(results, 30.0)
  1191. best_dd35 = choose_best_by_drawdown_cap(results, 35.0)
  1192. top_by_return = sorted(
  1193. results,
  1194. key=lambda item: (
  1195. item["metrics"]["annual_return_pct"],
  1196. sharpe_sort_value(item),
  1197. -item["metrics"]["max_drawdown_pct"],
  1198. ),
  1199. reverse=True,
  1200. )[:8]
  1201. top_by_sharpe = sorted(
  1202. results,
  1203. key=lambda item: (
  1204. sharpe_sort_value(item),
  1205. item["metrics"]["annual_return_pct"],
  1206. -item["metrics"]["max_drawdown_pct"],
  1207. ),
  1208. reverse=True,
  1209. )[:8]
  1210. recommended = best_dd35 or best_sharpe
  1211. default_metrics = default_result["metrics"]
  1212. recommended_metrics = recommended["metrics"]
  1213. beats_default_return = best_return["metrics"]["annual_return_pct"] > default_metrics["annual_return_pct"]
  1214. beats_default_sharpe = (
  1215. best_sharpe["metrics"]["sharpe"] is not None
  1216. and (
  1217. default_metrics["sharpe"] is None
  1218. or best_sharpe["metrics"]["sharpe"] > default_metrics["sharpe"]
  1219. )
  1220. )
  1221. lines = [
  1222. "# DualThrust Chinext50 Optimization",
  1223. "",
  1224. f"- Data: `chinext50.csv` ({df.index.min().date()} to {df.index.max().date()}, {len(df)} bars)",
  1225. f"- Initial cash: `{INITIAL_CASH:.0f}`",
  1226. f"- Commission: `{COMMISSION:.4f}`",
  1227. "- Strategy: `DualThrustRegimeStrategy`",
  1228. "- Search grid: `range_period in [10, 15, 20, 25, 30]`, `k1/k2 in [0.2, 0.3, 0.4, 0.5]`, `regime in [60, 90, 120, 150, 200]`",
  1229. f"- Evaluated configs: `{len(results)}`",
  1230. "",
  1231. "## Command",
  1232. "",
  1233. "- `python3 chinext50_experiments.py --optimize-dualthrust`",
  1234. "",
  1235. "## Default Benchmark",
  1236. "",
  1237. (
  1238. f"- Current default: `{format_config_plain(default_result['config'])}` "
  1239. f"| annual return `{default_metrics['annual_return_pct']:.2f}%` "
  1240. f"| Sharpe `{metric_text(default_metrics['sharpe'])}` "
  1241. f"| max DD `{default_metrics['max_drawdown_pct']:.2f}%`"
  1242. ),
  1243. "",
  1244. "## Required Winners",
  1245. "",
  1246. ]
  1247. lines.extend(summarize_dualthrust_candidate("Best by annual return", best_return))
  1248. lines.extend(summarize_dualthrust_candidate("Best by Sharpe", best_sharpe))
  1249. lines.extend(summarize_dualthrust_candidate("Best with max DD <= 30% (Sharpe-ranked)", best_dd30))
  1250. lines.extend(summarize_dualthrust_candidate("Best with max DD <= 35% (Sharpe-ranked)", best_dd35))
  1251. lines.extend(
  1252. [
  1253. "",
  1254. "## Top Candidates By Annual Return",
  1255. "",
  1256. "| Params | Annual Return | Sharpe | Max DD | Trades | Exposure |",
  1257. "| --- | ---: | ---: | ---: | ---: | ---: |",
  1258. ]
  1259. )
  1260. for item in top_by_return:
  1261. metrics = item["metrics"]
  1262. lines.append(
  1263. "| {params} | {annual_return_pct:.2f}% | {sharpe} | {max_drawdown_pct:.2f}% | {closed_trades} | {exposure_pct:.2f}% |".format(
  1264. params=format_config(item["config"]),
  1265. annual_return_pct=metrics["annual_return_pct"],
  1266. sharpe=metric_text(metrics["sharpe"]),
  1267. max_drawdown_pct=metrics["max_drawdown_pct"],
  1268. closed_trades=metrics["closed_trades"],
  1269. exposure_pct=metrics["exposure_pct"],
  1270. )
  1271. )
  1272. lines.extend(
  1273. [
  1274. "",
  1275. "## Top Candidates By Sharpe",
  1276. "",
  1277. "| Params | Sharpe | Annual Return | Max DD | Trades | Exposure |",
  1278. "| --- | ---: | ---: | ---: | ---: | ---: |",
  1279. ]
  1280. )
  1281. for item in top_by_sharpe:
  1282. metrics = item["metrics"]
  1283. lines.append(
  1284. "| {params} | {sharpe} | {annual_return_pct:.2f}% | {max_drawdown_pct:.2f}% | {closed_trades} | {exposure_pct:.2f}% |".format(
  1285. params=format_config(item["config"]),
  1286. sharpe=metric_text(metrics["sharpe"]),
  1287. annual_return_pct=metrics["annual_return_pct"],
  1288. max_drawdown_pct=metrics["max_drawdown_pct"],
  1289. closed_trades=metrics["closed_trades"],
  1290. exposure_pct=metrics["exposure_pct"],
  1291. )
  1292. )
  1293. lines.extend(
  1294. [
  1295. "",
  1296. "## Recommendation",
  1297. "",
  1298. (
  1299. f"- Recommended next parameter set: `{format_config_plain(recommended['config'])}` "
  1300. f"because it keeps max DD at `{recommended_metrics['max_drawdown_pct']:.2f}%` while delivering "
  1301. f"`{recommended_metrics['annual_return_pct']:.2f}%` annual return and Sharpe `{metric_text(recommended_metrics['sharpe'])}`."
  1302. ),
  1303. (
  1304. f"- Optimized DualThrust beat default on annual return: `{'yes' if beats_default_return else 'no'}` "
  1305. f"({best_return['metrics']['annual_return_pct']:.2f}% vs {default_metrics['annual_return_pct']:.2f}%)."
  1306. ),
  1307. (
  1308. f"- Optimized DualThrust beat default on Sharpe: `{'yes' if beats_default_sharpe else 'no'}` "
  1309. f"({metric_text(best_sharpe['metrics']['sharpe'])} vs {metric_text(default_metrics['sharpe'])})."
  1310. ),
  1311. ]
  1312. )
  1313. return "\n".join(lines) + "\n"
  1314. def build_report(results: list[dict], df: pd.DataFrame) -> str:
  1315. best_return = max(results, key=lambda item: item["metrics"]["annual_return_pct"])
  1316. best_sharpe = choose_tradeoff_winner(results)
  1317. best_capped_drawdown, drawdown_cap = choose_capped_drawdown_winner(results)
  1318. previous_results = [item for item in results if not item.get("is_new")]
  1319. new_results = [item for item in results if item.get("is_new")]
  1320. previous_best_return = max(previous_results, key=lambda item: item["metrics"]["annual_return_pct"]) if previous_results else None
  1321. previous_best_sharpe = choose_tradeoff_winner(previous_results) if previous_results else None
  1322. new_best_return = max(new_results, key=lambda item: item["metrics"]["annual_return_pct"]) if new_results else None
  1323. new_best_sharpe = choose_tradeoff_winner(new_results) if new_results else None
  1324. lines = [
  1325. "# Chinext50 Backtrader Experiments",
  1326. "",
  1327. f"- Data: `chinext50.csv` ({df.index.min().date()} to {df.index.max().date()}, {len(df)} bars)",
  1328. f"- Initial cash: `{INITIAL_CASH:.0f}`",
  1329. f"- Commission: `{COMMISSION:.4f}`",
  1330. "",
  1331. "## Commands",
  1332. "",
  1333. "- Run all experiments: `python3 chinext50_experiments.py`",
  1334. "",
  1335. "## Configs",
  1336. "",
  1337. ]
  1338. for item in results:
  1339. lines.append(f"- **{item['name']}**: {format_config(item['config'])}")
  1340. lines.extend(
  1341. [
  1342. "",
  1343. "## Metrics",
  1344. "",
  1345. "| Strategy | Final Value | Total Return | Annual Return | Sharpe | Max DD | Entries | Closed Trades | Win Rate | Avg Exposure |",
  1346. "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
  1347. ]
  1348. )
  1349. for item in results:
  1350. metrics = item["metrics"]
  1351. lines.append(
  1352. "| {name} | {final_value:.2f} | {total_return_pct:.2f}% | {annual_return_pct:.2f}% | {sharpe} | {max_drawdown_pct:.2f}% | {entries} | {closed_trades} | {win_rate_pct:.2f}% | {exposure_pct:.2f}% |".format(
  1353. name=item["name"],
  1354. final_value=metrics["final_value"],
  1355. total_return_pct=metrics["total_return_pct"],
  1356. annual_return_pct=metrics["annual_return_pct"],
  1357. sharpe=metric_text(metrics["sharpe"]),
  1358. max_drawdown_pct=metrics["max_drawdown_pct"],
  1359. entries=metrics["entries"],
  1360. closed_trades=metrics["closed_trades"],
  1361. win_rate_pct=metrics["win_rate_pct"],
  1362. exposure_pct=metrics["exposure_pct"],
  1363. )
  1364. )
  1365. lines.extend(
  1366. [
  1367. "",
  1368. "## Verdict",
  1369. "",
  1370. (
  1371. f"- Highest annual return: **{best_return['name']}** "
  1372. f"({best_return['metrics']['annual_return_pct']:.2f}% annual return, "
  1373. f"{best_return['metrics']['max_drawdown_pct']:.2f}% max DD)"
  1374. ),
  1375. (
  1376. f"- Best risk-adjusted balance by Sharpe: **{best_sharpe['name']}** "
  1377. f"(Sharpe {metric_text(best_sharpe['metrics']['sharpe'])}, "
  1378. f"{best_sharpe['metrics']['annual_return_pct']:.2f}% annual return, "
  1379. f"{best_sharpe['metrics']['max_drawdown_pct']:.2f}% max DD)"
  1380. ),
  1381. ]
  1382. )
  1383. if drawdown_cap is not None:
  1384. lines.append(
  1385. f"- Best Sharpe with max DD <= {drawdown_cap:.0f}%: **{best_capped_drawdown['name']}** "
  1386. f"(Sharpe {metric_text(best_capped_drawdown['metrics']['sharpe'])}, "
  1387. f"{best_capped_drawdown['metrics']['annual_return_pct']:.2f}% annual return, "
  1388. f"{best_capped_drawdown['metrics']['max_drawdown_pct']:.2f}% max DD)"
  1389. )
  1390. else:
  1391. lines.append(
  1392. f"- Best drawdown-aware fallback: **{best_capped_drawdown['name']}** "
  1393. f"(Sharpe {metric_text(best_capped_drawdown['metrics']['sharpe'])}, "
  1394. f"{best_capped_drawdown['metrics']['annual_return_pct']:.2f}% annual return, "
  1395. f"{best_capped_drawdown['metrics']['max_drawdown_pct']:.2f}% max DD)"
  1396. )
  1397. if previous_best_return and new_best_return:
  1398. if new_best_return["metrics"]["annual_return_pct"] > previous_best_return["metrics"]["annual_return_pct"]:
  1399. lines.append(
  1400. f"- New-strategy return leader: **{new_best_return['name']}** beat the prior return leader "
  1401. f"**{previous_best_return['name']}** "
  1402. f"({new_best_return['metrics']['annual_return_pct']:.2f}% vs {previous_best_return['metrics']['annual_return_pct']:.2f}% annual return)"
  1403. )
  1404. else:
  1405. lines.append(
  1406. f"- New strategies did not beat the prior return leader **{previous_best_return['name']}** "
  1407. f"({previous_best_return['metrics']['annual_return_pct']:.2f}% annual return). "
  1408. f"Best new result was **{new_best_return['name']}** at {new_best_return['metrics']['annual_return_pct']:.2f}%."
  1409. )
  1410. if previous_best_sharpe and new_best_sharpe:
  1411. previous_sharpe = previous_best_sharpe["metrics"]["sharpe"]
  1412. new_sharpe = new_best_sharpe["metrics"]["sharpe"]
  1413. if new_sharpe is not None and (previous_sharpe is None or new_sharpe > previous_sharpe):
  1414. lines.append(
  1415. f"- New-strategy Sharpe leader: **{new_best_sharpe['name']}** beat the prior Sharpe leader "
  1416. f"**{previous_best_sharpe['name']}** "
  1417. f"(Sharpe {metric_text(new_sharpe)} vs {metric_text(previous_sharpe)})"
  1418. )
  1419. else:
  1420. lines.append(
  1421. f"- New strategies did not beat the prior Sharpe leader **{previous_best_sharpe['name']}** "
  1422. f"(Sharpe {metric_text(previous_sharpe)}). "
  1423. f"Best new Sharpe was **{new_best_sharpe['name']}** at {metric_text(new_sharpe)}."
  1424. )
  1425. if new_results:
  1426. best_new_drawdown = min(new_results, key=lambda item: item["metrics"]["max_drawdown_pct"])
  1427. lines.append(
  1428. f"- Most defensive new addition: **{best_new_drawdown['name']}** "
  1429. f"delivered the lowest max DD among the new strategies "
  1430. f"with {best_new_drawdown['metrics']['max_drawdown_pct']:.2f}% max DD, "
  1431. f"{best_new_drawdown['metrics']['annual_return_pct']:.2f}% annual return, "
  1432. f"and {best_new_drawdown['metrics']['win_rate_pct']:.2f}% win rate."
  1433. )
  1434. return "\n".join(lines) + "\n"
  1435. def run_dualthrust_optimization() -> list[dict]:
  1436. df = load_dataframe()
  1437. configs = dualthrust_search_configs()
  1438. results = []
  1439. for config in configs:
  1440. metrics = run_strategy(DualThrustRegimeStrategy, config, df=df)
  1441. results.append(
  1442. {
  1443. "name": "DualThrustRegimeStrategy",
  1444. "config": config,
  1445. "metrics": metrics,
  1446. }
  1447. )
  1448. default_config = {"range_period": 20, "k1": 0.3, "k2": 0.3, "regime": 120}
  1449. default_result = next(item for item in results if item["config"] == default_config)
  1450. report = build_dualthrust_optimization_report(results, default_result, df)
  1451. DUALTHRUST_OPT_REPORT_FILE.write_text(report, encoding="utf-8")
  1452. best_return = max(results, key=lambda item: item["metrics"]["annual_return_pct"])
  1453. best_sharpe = choose_tradeoff_winner(results)
  1454. best_dd30 = choose_best_by_drawdown_cap(results, 30.0)
  1455. best_dd35 = choose_best_by_drawdown_cap(results, 35.0)
  1456. print(f"Evaluated {len(results)} DualThrust configurations")
  1457. for label, item in (
  1458. ("Default", default_result),
  1459. ("Best annual return", best_return),
  1460. ("Best Sharpe", best_sharpe),
  1461. ("Best max DD <= 30%", best_dd30),
  1462. ("Best max DD <= 35%", best_dd35),
  1463. ):
  1464. if item is None:
  1465. print(f"{label}: none")
  1466. continue
  1467. metrics = item["metrics"]
  1468. print(
  1469. f"{label}: config={item['config']}, "
  1470. f"annual_return={metrics['annual_return_pct']:.2f}%, "
  1471. f"sharpe={metric_text(metrics['sharpe'])}, "
  1472. f"max_dd={metrics['max_drawdown_pct']:.2f}%"
  1473. )
  1474. print(f"Report written to {DUALTHRUST_OPT_REPORT_FILE.name}")
  1475. return results
  1476. def run_experiments() -> list[dict]:
  1477. df = load_dataframe()
  1478. experiments = [
  1479. {
  1480. "name": "TrendRegimeFlatStrategy",
  1481. "strategy": TrendRegimeFlatStrategy,
  1482. "config": {"fast": 20, "slow": 60, "regime": 120, "vol_fast": 20, "vol_slow": 60, "vol_cap": 1.10},
  1483. },
  1484. {
  1485. "name": "TrendTightVolStrategy",
  1486. "strategy": TrendTightVolStrategy,
  1487. "config": {"fast": 20, "slow": 60, "regime": 120, "vol_fast": 20, "vol_slow": 60, "vol_cap": 0.95},
  1488. "is_new": True,
  1489. },
  1490. {
  1491. "name": "TrendLooseVolStrategy",
  1492. "strategy": TrendLooseVolStrategy,
  1493. "config": {"fast": 20, "slow": 60, "regime": 120, "vol_fast": 20, "vol_slow": 60, "vol_cap": 1.25},
  1494. "is_new": True,
  1495. },
  1496. {
  1497. "name": "SmaLongFilterTrendStrategy",
  1498. "strategy": SmaLongFilterTrendStrategy,
  1499. "config": {"fast": 20, "slow": 60, "regime": 120},
  1500. "is_new": True,
  1501. },
  1502. {
  1503. "name": "MomentumBasicStrategy",
  1504. "strategy": MomentumBasicStrategy,
  1505. "config": {"mom_short": 20, "mom_long": 120},
  1506. "is_new": True,
  1507. },
  1508. {
  1509. "name": "MomentumVolTargetBasicStrategy",
  1510. "strategy": MomentumVolTargetBasicStrategy,
  1511. "config": {"mom_short": 20, "mom_long": 120, "vol_period": 30, "target_vol": 0.30, "max_weight": 1.0, "rebalance_band": 0.15},
  1512. "is_new": True,
  1513. },
  1514. {
  1515. "name": "MomentumVolTargetLowerTargetStrategy",
  1516. "strategy": MomentumVolTargetStrategy,
  1517. "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "vol_period": 30, "target_vol": 0.25, "max_weight": 1.0, "rebalance_band": 0.15},
  1518. "is_new": True,
  1519. },
  1520. {
  1521. "name": "MomentumVolTargetHigherTargetStrategy",
  1522. "strategy": MomentumVolTargetStrategy,
  1523. "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "vol_period": 30, "target_vol": 0.35, "max_weight": 1.0, "rebalance_band": 0.15},
  1524. "is_new": True,
  1525. },
  1526. {
  1527. "name": "MomentumRegimeStrategy",
  1528. "strategy": MomentumRegimeStrategy,
  1529. "config": {"mom_short": 20, "mom_long": 120, "regime": 150},
  1530. },
  1531. {
  1532. "name": "MomentumMaFilterStrategy",
  1533. "strategy": MomentumMaFilterStrategy,
  1534. "config": {"mom_short": 20, "mom_long": 120, "ma_filter": 60},
  1535. "is_new": True,
  1536. },
  1537. {
  1538. "name": "MomentumAtrTrailBasicStrategy",
  1539. "strategy": MomentumAtrTrailBasicStrategy,
  1540. "config": {"mom_short": 20, "mom_long": 120, "atr_period": 20, "atr_mult": 4.0},
  1541. "is_new": True,
  1542. },
  1543. {
  1544. "name": "MomentumAtrTrailTighterStrategy",
  1545. "strategy": MomentumAtrTrailStrategy,
  1546. "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "atr_period": 20, "atr_mult": 3.0},
  1547. "is_new": True,
  1548. },
  1549. {
  1550. "name": "MomentumAtrTrailLooserStrategy",
  1551. "strategy": MomentumAtrTrailStrategy,
  1552. "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "atr_period": 20, "atr_mult": 5.0},
  1553. "is_new": True,
  1554. },
  1555. {
  1556. "name": "MomentumAtrTrailStrategy",
  1557. "strategy": MomentumAtrTrailStrategy,
  1558. "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "atr_period": 20, "atr_mult": 4.0},
  1559. },
  1560. {
  1561. "name": "MomentumDefensiveFilterStrategy",
  1562. "strategy": MomentumDefensiveFilterStrategy,
  1563. "config": {
  1564. "mom_short": 20,
  1565. "mom_long": 120,
  1566. "regime": 150,
  1567. "vol_fast": 20,
  1568. "vol_slow": 60,
  1569. "vol_cap": 1.05,
  1570. },
  1571. "is_new": True,
  1572. },
  1573. {
  1574. "name": "DonchianRegimeStrategy",
  1575. "strategy": DonchianRegimeStrategy,
  1576. "config": {"breakout": 55, "exit_period": 30, "regime": 150},
  1577. },
  1578. {
  1579. "name": "DonchianRegimeFastStrategy",
  1580. "strategy": DonchianRegimeStrategy,
  1581. "config": {"breakout": 40, "exit_period": 20, "regime": 150},
  1582. "is_new": True,
  1583. },
  1584. {
  1585. "name": "DonchianRegimeSlowStrategy",
  1586. "strategy": DonchianRegimeStrategy,
  1587. "config": {"breakout": 70, "exit_period": 35, "regime": 150},
  1588. "is_new": True,
  1589. },
  1590. {
  1591. "name": "MomentumVolTargetStrategy",
  1592. "strategy": MomentumVolTargetStrategy,
  1593. "config": {
  1594. "mom_short": 20,
  1595. "mom_long": 120,
  1596. "regime": 150,
  1597. "vol_period": 30,
  1598. "target_vol": 0.30,
  1599. "max_weight": 1.0,
  1600. "rebalance_band": 0.15,
  1601. },
  1602. },
  1603. {
  1604. "name": "DonchianBasicStrategy",
  1605. "strategy": DonchianBasicStrategy,
  1606. "config": {"breakout": 55, "exit_period": 30},
  1607. "is_new": True,
  1608. },
  1609. {
  1610. "name": "DonchianAdxStrategy",
  1611. "strategy": DonchianAdxStrategy,
  1612. "config": {"breakout": 55, "exit_period": 30, "adx_period": 14, "adx_threshold": 20.0},
  1613. "is_new": True,
  1614. },
  1615. {
  1616. "name": "DonchianAtrTrailStrategy",
  1617. "strategy": DonchianAtrTrailStrategy,
  1618. "config": {"breakout": 55, "exit_period": 30, "atr_period": 20, "atr_mult": 4.0},
  1619. "is_new": True,
  1620. },
  1621. {
  1622. "name": "DonchianVolTargetStrategy",
  1623. "strategy": DonchianVolTargetStrategy,
  1624. "config": {"breakout": 55, "exit_period": 30, "vol_period": 30, "target_vol": 0.30, "max_weight": 1.0, "rebalance_band": 0.15},
  1625. "is_new": True,
  1626. },
  1627. {
  1628. "name": "DonchianHybridVolAtrStrategy",
  1629. "strategy": DonchianHybridVolAtrStrategy,
  1630. "config": {
  1631. "breakout": 55,
  1632. "exit_period": 30,
  1633. "vol_period": 30,
  1634. "target_vol": 0.30,
  1635. "max_weight": 1.0,
  1636. "rebalance_band": 0.15,
  1637. "atr_period": 20,
  1638. "atr_mult": 4.0,
  1639. },
  1640. "is_new": True,
  1641. },
  1642. {
  1643. "name": "SuperTrendLongMaStrategy",
  1644. "strategy": SuperTrendLongMaStrategy,
  1645. "config": {"supertrend_period": 14, "supertrend_mult": 2.0, "regime": 200},
  1646. "is_new": True,
  1647. },
  1648. {
  1649. "name": "SuperTrendLongMaFastRegimeStrategy",
  1650. "strategy": SuperTrendLongMaStrategy,
  1651. "config": {"supertrend_period": 14, "supertrend_mult": 2.0, "regime": 150},
  1652. "is_new": True,
  1653. },
  1654. {
  1655. "name": "SuperTrendBasicStrategy",
  1656. "strategy": SuperTrendBasicStrategy,
  1657. "config": {"supertrend_period": 14, "supertrend_mult": 2.0},
  1658. "is_new": True,
  1659. },
  1660. {
  1661. "name": "TsmomLooseThresholdStrategy",
  1662. "strategy": TsmomLooseThresholdStrategy,
  1663. "config": {"mom_windows": (60, 120, 240), "positive_threshold": 1, "regime": 200},
  1664. "is_new": True,
  1665. },
  1666. {
  1667. "name": "TsmomBasicStrategy",
  1668. "strategy": TsmomBasicStrategy,
  1669. "config": {"mom_windows": (60, 120), "positive_threshold": 1},
  1670. "is_new": True,
  1671. },
  1672. {
  1673. "name": "TsmomRegimeStrategy",
  1674. "strategy": TsmomRegimeStrategy,
  1675. "config": {"mom_windows": (60, 120), "positive_threshold": 1, "regime": 200},
  1676. "is_new": True,
  1677. },
  1678. {
  1679. "name": "KamaBasicStrategy",
  1680. "strategy": KamaBasicStrategy,
  1681. "config": {"kama_period": 30, "kama_fast": 2, "kama_slow": 30},
  1682. "is_new": True,
  1683. },
  1684. {
  1685. "name": "KamaTrendStrategy",
  1686. "strategy": KamaTrendStrategy,
  1687. "config": {"kama_period": 30, "kama_fast": 2, "kama_slow": 30, "regime": 120},
  1688. "is_new": True,
  1689. },
  1690. {
  1691. "name": "DualThrustBasicStrategy",
  1692. "strategy": DualThrustBasicStrategy,
  1693. "config": {"range_period": 20, "k1": 0.3, "k2": 0.3},
  1694. "is_new": True,
  1695. },
  1696. {
  1697. "name": "DualThrustFastStrategy",
  1698. "strategy": DualThrustBasicStrategy,
  1699. "config": {"range_period": 15, "k1": 0.3, "k2": 0.3},
  1700. "is_new": True,
  1701. },
  1702. {
  1703. "name": "DualThrustSlowStrategy",
  1704. "strategy": DualThrustBasicStrategy,
  1705. "config": {"range_period": 30, "k1": 0.3, "k2": 0.3},
  1706. "is_new": True,
  1707. },
  1708. {
  1709. "name": "DualThrustRegimeStrategy",
  1710. "strategy": DualThrustRegimeStrategy,
  1711. "config": {"range_period": 20, "k1": 0.3, "k2": 0.3, "regime": 120},
  1712. "is_new": True,
  1713. },
  1714. {
  1715. "name": "MacdBasicStrategy",
  1716. "strategy": MacdBasicStrategy,
  1717. "config": {"macd_fast": 12, "macd_slow": 26, "macd_signal": 9},
  1718. "is_new": True,
  1719. },
  1720. {
  1721. "name": "MacdLongMaFastRegimeStrategy",
  1722. "strategy": MacdLongMaStrategy,
  1723. "config": {"macd_fast": 12, "macd_slow": 26, "macd_signal": 9, "regime": 150},
  1724. "is_new": True,
  1725. },
  1726. {
  1727. "name": "MacdLongMaStrategy",
  1728. "strategy": MacdLongMaStrategy,
  1729. "config": {"macd_fast": 12, "macd_slow": 26, "macd_signal": 9, "regime": 120},
  1730. "is_new": True,
  1731. },
  1732. ]
  1733. results = []
  1734. for item in experiments:
  1735. metrics = run_strategy(item["strategy"], item["config"], df=df)
  1736. results.append(
  1737. {
  1738. "name": item["name"],
  1739. "config": item["config"],
  1740. "metrics": metrics,
  1741. "is_new": item.get("is_new", False),
  1742. }
  1743. )
  1744. report = build_report(results, df)
  1745. REPORT_FILE.write_text(report, encoding="utf-8")
  1746. for item in results:
  1747. metrics = item["metrics"]
  1748. print(
  1749. f"{item['name']}: final={metrics['final_value']:.2f}, "
  1750. f"total_return={metrics['total_return_pct']:.2f}%, "
  1751. f"annual_return={metrics['annual_return_pct']:.2f}%, "
  1752. f"sharpe={metric_text(metrics['sharpe'])}, "
  1753. f"max_dd={metrics['max_drawdown_pct']:.2f}%, "
  1754. f"closed_trades={metrics['closed_trades']}, "
  1755. f"win_rate={metrics['win_rate_pct']:.2f}%, "
  1756. f"avg_exposure={metrics['exposure_pct']:.2f}%"
  1757. )
  1758. print(f"Report written to {REPORT_FILE.name}")
  1759. return results
  1760. def parse_args() -> argparse.Namespace:
  1761. parser = argparse.ArgumentParser(description=__doc__)
  1762. parser.add_argument(
  1763. "--optimize-dualthrust",
  1764. action="store_true",
  1765. help="Run a focused parameter search for DualThrustRegimeStrategy and write a markdown summary.",
  1766. )
  1767. return parser.parse_args()
  1768. def main():
  1769. args = parse_args()
  1770. if args.optimize_dualthrust:
  1771. run_dualthrust_optimization()
  1772. return
  1773. run_experiments()
  1774. if __name__ == "__main__":
  1775. main()