| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094 |
- #!/usr/bin/env python3
- """Run Chinext50 Backtrader experiments and save a short report."""
- from __future__ import annotations
- import argparse
- import itertools
- import math
- from pathlib import Path
- import backtrader as bt
- import pandas as pd
- ROOT = Path(__file__).resolve().parent
- DATA_FILE = ROOT / "chinext50.csv"
- REPORT_FILE = ROOT / "chinext50_experiment_summary.md"
- DUALTHRUST_OPT_REPORT_FILE = ROOT / "chinext50_dualthrust_optimization.md"
- INITIAL_CASH = 100000.0
- COMMISSION = 0.001
- TRADING_DAYS = 252
- class Chinext50Data(bt.feeds.PandasData):
- """Explicit column mapping for the local Chinext50 CSV."""
- params = (
- ("datetime", None),
- ("open", "open"),
- ("high", "high"),
- ("low", "low"),
- ("close", "close"),
- ("volume", "volume"),
- ("openinterest", None),
- )
- class BaseIndexStrategy(bt.Strategy):
- """Common helpers for long-only index timing strategies."""
- def __init__(self):
- self.order = None
- self.entry_count = 0
- self.bars_in_market = 0
- self.exposure_sum = 0.0
- def notify_order(self, order):
- if order.status in [order.Submitted, order.Accepted]:
- return
- if order.status == order.Completed and order.isbuy():
- self.entry_count += 1
- self.order = None
- def next(self):
- portfolio_value = self.broker.getvalue()
- if portfolio_value > 0:
- position_value = abs(self.position.size) * self.data.close[0]
- exposure = position_value / portfolio_value
- self.exposure_sum += exposure
- if exposure > 0:
- self.bars_in_market += 1
- def _target_size_for_weight(self, target_weight: float) -> int:
- target_weight = max(0.0, target_weight)
- portfolio_value = self.broker.getvalue()
- price = self.data.close[0]
- if portfolio_value <= 0 or price <= 0:
- return 0
- target_value = portfolio_value * target_weight
- return max(int(target_value / price), 0)
- def _rebalance_to_weight(self, target_weight: float):
- target_size = self._target_size_for_weight(target_weight)
- current_size = self.position.size
- size_delta = target_size - current_size
- if size_delta > 0:
- self.order = self.buy(size=size_delta)
- elif size_delta < 0:
- self.order = self.sell(size=abs(size_delta))
- def _buy_full(self):
- self._rebalance_to_weight(1.0)
- def _go_flat(self):
- if self.position:
- self.order = self.close()
- class SuperTrendIndicator(bt.Indicator):
- """Minimal SuperTrend implementation built from Backtrader ATR."""
- lines = ("supertrend",)
- params = (
- ("period", 10),
- ("multiplier", 3.0),
- )
- plotinfo = {"subplot": False}
- def __init__(self):
- hl2 = (self.data.high + self.data.low) / 2.0
- self.atr = bt.indicators.ATR(self.data, period=self.p.period)
- self.basic_upper = hl2 + self.p.multiplier * self.atr
- self.basic_lower = hl2 - self.p.multiplier * self.atr
- self._final_upper = None
- self._final_lower = None
- def next(self):
- if math.isnan(self.atr[0]):
- return
- basic_upper = float(self.basic_upper[0])
- basic_lower = float(self.basic_lower[0])
- close = float(self.data.close[0])
- if self._final_upper is None or self._final_lower is None:
- self._final_upper = basic_upper
- self._final_lower = basic_lower
- self.lines.supertrend[0] = basic_lower
- return
- prev_final_upper = self._final_upper
- prev_final_lower = self._final_lower
- prev_close = float(self.data.close[-1])
- prev_supertrend = float(self.lines.supertrend[-1])
- final_upper = basic_upper if basic_upper < prev_final_upper or prev_close > prev_final_upper else prev_final_upper
- final_lower = basic_lower if basic_lower > prev_final_lower or prev_close < prev_final_lower else prev_final_lower
- if prev_supertrend == prev_final_upper:
- supertrend = final_upper if close <= final_upper else final_lower
- else:
- supertrend = final_lower if close >= final_lower else final_upper
- self._final_upper = final_upper
- self._final_lower = final_lower
- self.lines.supertrend[0] = supertrend
- class TrendRegimeFlatStrategy(BaseIndexStrategy):
- """
- Trend following with flat/cash regime control.
- Rules:
- - Long only when short trend > medium trend and price stays above a regime MA
- - Exit fully when the trend breaks or short-term volatility spikes above a cap
- """
- params = (
- ("fast", 20),
- ("slow", 60),
- ("regime", 120),
- ("vol_fast", 20),
- ("vol_slow", 60),
- ("vol_cap", 1.10),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.sma_fast = bt.indicators.SMA(close, period=self.p.fast)
- self.sma_slow = bt.indicators.SMA(close, period=self.p.slow)
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- returns = bt.indicators.PctChange(close, period=1)
- self.vol_fast = bt.indicators.StdDev(returns, period=self.p.vol_fast)
- self.vol_slow = bt.indicators.StdDev(returns, period=self.p.vol_slow)
- def next(self):
- super().next()
- if self.order:
- return
- if (
- math.isnan(self.sma_fast[0])
- or math.isnan(self.sma_slow[0])
- or math.isnan(self.sma_regime[0])
- or math.isnan(self.vol_fast[0])
- or math.isnan(self.vol_slow[0])
- or self.vol_slow[0] == 0
- ):
- return
- bullish_trend = self.sma_fast[0] > self.sma_slow[0] and self.data.close[0] > self.sma_regime[0]
- calm_enough = self.vol_fast[0] <= self.vol_slow[0] * self.p.vol_cap
- if bullish_trend and calm_enough and not self.position:
- self._buy_full()
- elif self.position and (not bullish_trend or not calm_enough):
- self._go_flat()
- class TrendTightVolStrategy(BaseIndexStrategy):
- """Trend following with the same regime logic but a tighter volatility cap."""
- params = (
- ("fast", 20),
- ("slow", 60),
- ("regime", 120),
- ("vol_fast", 20),
- ("vol_slow", 60),
- ("vol_cap", 0.95),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.sma_fast = bt.indicators.SMA(close, period=self.p.fast)
- self.sma_slow = bt.indicators.SMA(close, period=self.p.slow)
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- returns = bt.indicators.PctChange(close, period=1)
- self.vol_fast = bt.indicators.StdDev(returns, period=self.p.vol_fast)
- self.vol_slow = bt.indicators.StdDev(returns, period=self.p.vol_slow)
- def next(self):
- super().next()
- if self.order:
- return
- if (
- math.isnan(self.sma_fast[0])
- or math.isnan(self.sma_slow[0])
- or math.isnan(self.sma_regime[0])
- or math.isnan(self.vol_fast[0])
- or math.isnan(self.vol_slow[0])
- or self.vol_slow[0] <= 0
- ):
- return
- bullish_trend = self.sma_fast[0] > self.sma_slow[0] and self.data.close[0] > self.sma_regime[0]
- calm_enough = self.vol_fast[0] <= self.vol_slow[0] * self.p.vol_cap
- if bullish_trend and calm_enough and not self.position:
- self._buy_full()
- elif self.position and (not bullish_trend or not calm_enough):
- self._go_flat()
- class TrendLooseVolStrategy(BaseIndexStrategy):
- """Trend following with the same regime logic but a looser volatility cap."""
- params = (
- ("fast", 20),
- ("slow", 60),
- ("regime", 120),
- ("vol_fast", 20),
- ("vol_slow", 60),
- ("vol_cap", 1.25),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.sma_fast = bt.indicators.SMA(close, period=self.p.fast)
- self.sma_slow = bt.indicators.SMA(close, period=self.p.slow)
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- returns = bt.indicators.PctChange(close, period=1)
- self.vol_fast = bt.indicators.StdDev(returns, period=self.p.vol_fast)
- self.vol_slow = bt.indicators.StdDev(returns, period=self.p.vol_slow)
- def next(self):
- super().next()
- if self.order:
- return
- if (
- math.isnan(self.sma_fast[0])
- or math.isnan(self.sma_slow[0])
- or math.isnan(self.sma_regime[0])
- or math.isnan(self.vol_fast[0])
- or math.isnan(self.vol_slow[0])
- or self.vol_slow[0] <= 0
- ):
- return
- bullish_trend = self.sma_fast[0] > self.sma_slow[0] and self.data.close[0] > self.sma_regime[0]
- calm_enough = self.vol_fast[0] <= self.vol_slow[0] * self.p.vol_cap
- if bullish_trend and calm_enough and not self.position:
- self._buy_full()
- elif self.position and (not bullish_trend or not calm_enough):
- self._go_flat()
- class SmaLongFilterTrendStrategy(BaseIndexStrategy):
- """Simple SMA trend following with a long-MA regime filter."""
- params = (
- ("fast", 20),
- ("slow", 60),
- ("regime", 120),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.sma_fast = bt.indicators.SMA(close, period=self.p.fast)
- self.sma_slow = bt.indicators.SMA(close, period=self.p.slow)
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.sma_fast[0]) or math.isnan(self.sma_slow[0]) or math.isnan(self.sma_regime[0]):
- return
- signal = self.sma_fast[0] > self.sma_slow[0] and self.data.close[0] > self.sma_regime[0]
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class MomentumRegimeStrategy(BaseIndexStrategy):
- """
- Single-asset momentum timing proxy for an index.
- Rules:
- - Long only when both short- and medium-term momentum are positive
- - Require price above a longer regime MA to avoid deep bear phases
- - Otherwise stay flat
- """
- params = (
- ("mom_short", 20),
- ("mom_long", 120),
- ("regime", 150),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
- self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.roc_short[0]) or math.isnan(self.roc_long[0]) or math.isnan(self.sma_regime[0]):
- return
- signal = (
- self.roc_short[0] > 0
- and self.roc_long[0] > 0
- and self.data.close[0] > self.sma_regime[0]
- )
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class MomentumBasicStrategy(BaseIndexStrategy):
- """Dual-window momentum without the long regime filter."""
- params = (
- ("mom_short", 20),
- ("mom_long", 120),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
- self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.roc_short[0]) or math.isnan(self.roc_long[0]):
- return
- signal = self.roc_short[0] > 0 and self.roc_long[0] > 0
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class MomentumMaFilterStrategy(BaseIndexStrategy):
- """Momentum timing with a medium-term moving-average confirmation filter."""
- params = (
- ("mom_short", 20),
- ("mom_long", 120),
- ("ma_filter", 60),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
- self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
- self.sma_filter = bt.indicators.SMA(close, period=self.p.ma_filter)
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.roc_short[0]) or math.isnan(self.roc_long[0]) or math.isnan(self.sma_filter[0]):
- return
- signal = (
- self.roc_short[0] > 0
- and self.roc_long[0] > 0
- and self.data.close[0] > self.sma_filter[0]
- )
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class MomentumDefensiveFilterStrategy(BaseIndexStrategy):
- """Momentum timing with a regime filter and simple fast/slow volatility cap."""
- params = (
- ("mom_short", 20),
- ("mom_long", 120),
- ("regime", 150),
- ("vol_fast", 20),
- ("vol_slow", 60),
- ("vol_cap", 1.05),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
- self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- returns = bt.indicators.PctChange(close, period=1)
- self.vol_fast = bt.indicators.StdDev(returns, period=self.p.vol_fast)
- self.vol_slow = bt.indicators.StdDev(returns, period=self.p.vol_slow)
- def next(self):
- super().next()
- if self.order:
- return
- if (
- math.isnan(self.roc_short[0])
- or math.isnan(self.roc_long[0])
- or math.isnan(self.sma_regime[0])
- or math.isnan(self.vol_fast[0])
- or math.isnan(self.vol_slow[0])
- or self.vol_slow[0] <= 0
- ):
- return
- signal = (
- self.roc_short[0] > 0
- and self.roc_long[0] > 0
- and self.data.close[0] > self.sma_regime[0]
- and self.vol_fast[0] <= self.vol_slow[0] * self.p.vol_cap
- )
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class MomentumAtrTrailBasicStrategy(BaseIndexStrategy):
- """Momentum entry with ATR trailing exit but no regime filter."""
- params = (
- ("mom_short", 20),
- ("mom_long", 120),
- ("atr_period", 20),
- ("atr_mult", 4.0),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
- self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
- self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
- self.highest_close = None
- def notify_order(self, order):
- super().notify_order(order)
- if order.status != order.Completed:
- return
- if order.isbuy():
- self.highest_close = order.executed.price
- elif not self.position:
- self.highest_close = None
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.roc_short[0]) or math.isnan(self.roc_long[0]) or math.isnan(self.atr[0]):
- return
- entry_signal = self.roc_short[0] > 0 and self.roc_long[0] > 0
- if not self.position:
- if entry_signal:
- self._buy_full()
- return
- if self.highest_close is None:
- self.highest_close = self.data.close[0]
- self.highest_close = max(self.highest_close, self.data.close[0])
- trailing_stop = self.highest_close - self.p.atr_mult * self.atr[0]
- if self.data.close[0] < trailing_stop or not entry_signal:
- self._go_flat()
- class MomentumAtrTrailStrategy(BaseIndexStrategy):
- """Momentum entry with a regime filter and ATR trailing exit."""
- params = (
- ("mom_short", 20),
- ("mom_long", 120),
- ("regime", 150),
- ("atr_period", 20),
- ("atr_mult", 4.0),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
- self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
- self.highest_close = None
- def notify_order(self, order):
- super().notify_order(order)
- if order.status != order.Completed:
- return
- if order.isbuy():
- self.highest_close = order.executed.price
- elif not self.position:
- self.highest_close = None
- def next(self):
- super().next()
- if self.order:
- return
- if (
- math.isnan(self.roc_short[0])
- or math.isnan(self.roc_long[0])
- or math.isnan(self.sma_regime[0])
- or math.isnan(self.atr[0])
- ):
- return
- entry_signal = (
- self.roc_short[0] > 0
- and self.roc_long[0] > 0
- and self.data.close[0] > self.sma_regime[0]
- )
- if not self.position:
- if entry_signal:
- self._buy_full()
- return
- if self.highest_close is None:
- self.highest_close = self.data.close[0]
- self.highest_close = max(self.highest_close, self.data.close[0])
- trailing_stop = self.highest_close - self.p.atr_mult * self.atr[0]
- if self.data.close[0] < trailing_stop or not entry_signal:
- self._go_flat()
- class DonchianRegimeStrategy(BaseIndexStrategy):
- """Donchian breakout gated by a long moving-average regime filter."""
- params = (
- ("breakout", 55),
- ("exit_period", 30),
- ("regime", 150),
- )
- def __init__(self):
- super().__init__()
- self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
- self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
- self.sma_regime = bt.indicators.SMA(self.data.close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.regime):
- return
- if math.isnan(self.sma_regime[0]) or math.isnan(self.highest_high[-1]) or math.isnan(self.lowest_low[-1]):
- return
- breakout_signal = self.data.close[0] > self.highest_high[-1] and self.data.close[0] > self.sma_regime[0]
- exit_signal = self.data.close[0] < self.lowest_low[-1] or self.data.close[0] < self.sma_regime[0]
- if breakout_signal and not self.position:
- self._buy_full()
- elif self.position and exit_signal:
- self._go_flat()
- class DonchianBasicStrategy(BaseIndexStrategy):
- """Pure Donchian breakout without regime or ADX filtering."""
- params = (
- ("breakout", 55),
- ("exit_period", 30),
- )
- def __init__(self):
- super().__init__()
- self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
- self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= max(self.p.breakout, self.p.exit_period):
- return
- if math.isnan(self.highest_high[-1]) or math.isnan(self.lowest_low[-1]):
- return
- breakout_signal = self.data.close[0] > self.highest_high[-1]
- exit_signal = self.data.close[0] < self.lowest_low[-1]
- if breakout_signal and not self.position:
- self._buy_full()
- elif self.position and exit_signal:
- self._go_flat()
- class DonchianAdxStrategy(BaseIndexStrategy):
- """Donchian breakout with an ADX trend-strength filter."""
- params = (
- ("breakout", 55),
- ("exit_period", 30),
- ("adx_period", 14),
- ("adx_threshold", 20.0),
- )
- def __init__(self):
- super().__init__()
- self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
- self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
- self.adx = bt.indicators.ADX(self.data, period=self.p.adx_period)
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.adx_period):
- return
- if math.isnan(self.highest_high[-1]) or math.isnan(self.lowest_low[-1]) or math.isnan(self.adx[0]):
- return
- breakout_signal = self.data.close[0] > self.highest_high[-1] and self.adx[0] >= self.p.adx_threshold
- exit_signal = self.data.close[0] < self.lowest_low[-1]
- if breakout_signal and not self.position:
- self._buy_full()
- elif self.position and exit_signal:
- self._go_flat()
- class DonchianAtrTrailStrategy(BaseIndexStrategy):
- """Donchian breakout with ATR trailing protection but no vol-target overlay."""
- params = (
- ("breakout", 55),
- ("exit_period", 30),
- ("atr_period", 20),
- ("atr_mult", 4.0),
- )
- def __init__(self):
- super().__init__()
- self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
- self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
- self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
- self.highest_close = None
- def notify_order(self, order):
- super().notify_order(order)
- if order.status != order.Completed:
- return
- if order.isbuy():
- self.highest_close = order.executed.price
- elif not self.position:
- self.highest_close = None
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.atr_period):
- return
- if math.isnan(self.highest_high[-1]) or math.isnan(self.lowest_low[-1]) or math.isnan(self.atr[0]):
- return
- breakout_signal = self.data.close[0] > self.highest_high[-1]
- channel_exit = self.data.close[0] < self.lowest_low[-1]
- if not self.position:
- if breakout_signal:
- self._buy_full()
- return
- self.highest_close = max(self.highest_close or float(self.data.close[0]), float(self.data.close[0]))
- atr_exit = self.data.close[0] < self.highest_close - self.p.atr_mult * self.atr[0]
- if channel_exit or atr_exit:
- self._go_flat()
- class DonchianVolTargetStrategy(BaseIndexStrategy):
- """Donchian breakout with volatility-target sizing but no ATR trailing overlay."""
- params = (
- ("breakout", 55),
- ("exit_period", 30),
- ("vol_period", 30),
- ("target_vol", 0.30),
- ("max_weight", 1.0),
- ("rebalance_band", 0.15),
- )
- def __init__(self):
- super().__init__()
- self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
- self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
- returns = bt.indicators.PctChange(self.data.close, period=1)
- self.volatility = bt.indicators.StdDev(returns, period=self.p.vol_period)
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.vol_period):
- return
- if (
- math.isnan(self.highest_high[-1])
- or math.isnan(self.lowest_low[-1])
- or math.isnan(self.volatility[0])
- or self.volatility[0] <= 0
- ):
- return
- breakout_signal = self.data.close[0] > self.highest_high[-1]
- exit_signal = self.data.close[0] < self.lowest_low[-1]
- if not self.position:
- if not breakout_signal:
- return
- annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
- target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
- self._rebalance_to_weight(max(0.0, target_weight))
- return
- if exit_signal:
- self._go_flat()
- return
- annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
- target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
- portfolio_value = self.broker.getvalue()
- current_weight = 0.0
- if portfolio_value > 0:
- current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
- if abs(current_weight - target_weight) >= self.p.rebalance_band:
- self._rebalance_to_weight(max(0.0, target_weight))
- class DonchianHybridVolAtrStrategy(BaseIndexStrategy):
- """Donchian breakout with vol-target sizing and ATR trailing protection."""
- params = (
- ("breakout", 55),
- ("exit_period", 30),
- ("vol_period", 30),
- ("target_vol", 0.30),
- ("max_weight", 1.0),
- ("rebalance_band", 0.15),
- ("atr_period", 20),
- ("atr_mult", 4.0),
- )
- def __init__(self):
- super().__init__()
- self.highest_high = bt.indicators.Highest(self.data.high, period=self.p.breakout)
- self.lowest_low = bt.indicators.Lowest(self.data.low, period=self.p.exit_period)
- returns = bt.indicators.PctChange(self.data.close, period=1)
- self.volatility = bt.indicators.StdDev(returns, period=self.p.vol_period)
- self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
- self.highest_close = None
- def notify_order(self, order):
- super().notify_order(order)
- if order.status != order.Completed:
- return
- if self.position:
- self.highest_close = max(self.highest_close or order.executed.price, order.executed.price)
- else:
- self.highest_close = None
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= max(self.p.breakout, self.p.exit_period, self.p.vol_period, self.p.atr_period):
- return
- if (
- math.isnan(self.highest_high[-1])
- or math.isnan(self.lowest_low[-1])
- or math.isnan(self.volatility[0])
- or math.isnan(self.atr[0])
- or self.volatility[0] <= 0
- ):
- return
- breakout_signal = self.data.close[0] > self.highest_high[-1]
- channel_exit = self.data.close[0] < self.lowest_low[-1]
- if not self.position:
- if not breakout_signal:
- return
- annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
- target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
- self._rebalance_to_weight(max(0.0, target_weight))
- return
- if self.highest_close is None:
- self.highest_close = self.data.close[0]
- self.highest_close = max(self.highest_close, self.data.close[0])
- trailing_stop = self.highest_close - self.p.atr_mult * self.atr[0]
- if channel_exit or self.data.close[0] < trailing_stop:
- self._go_flat()
- return
- annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
- target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
- target_weight = max(0.0, target_weight)
- portfolio_value = self.broker.getvalue()
- current_weight = 0.0
- if portfolio_value > 0:
- current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
- if abs(current_weight - target_weight) >= self.p.rebalance_band:
- self._rebalance_to_weight(target_weight)
- class MomentumVolTargetBasicStrategy(BaseIndexStrategy):
- """Dual-window momentum with volatility-target sizing but no regime filter."""
- params = (
- ("mom_short", 20),
- ("mom_long", 120),
- ("vol_period", 30),
- ("target_vol", 0.30),
- ("max_weight", 1.0),
- ("rebalance_band", 0.15),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
- self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
- returns = bt.indicators.PctChange(close, period=1)
- self.volatility = bt.indicators.StdDev(returns, period=self.p.vol_period)
- def next(self):
- super().next()
- if self.order:
- return
- if (
- math.isnan(self.roc_short[0])
- or math.isnan(self.roc_long[0])
- or math.isnan(self.volatility[0])
- or self.volatility[0] <= 0
- ):
- return
- signal = self.roc_short[0] > 0 and self.roc_long[0] > 0
- if not signal:
- self._go_flat()
- return
- annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
- target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
- target_weight = max(0.0, target_weight)
- portfolio_value = self.broker.getvalue()
- current_weight = 0.0
- if portfolio_value > 0:
- current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
- if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
- self._rebalance_to_weight(target_weight)
- class MomentumVolTargetStrategy(BaseIndexStrategy):
- """Momentum regime filter with volatility-capped position scaling."""
- params = (
- ("mom_short", 20),
- ("mom_long", 120),
- ("regime", 150),
- ("vol_period", 30),
- ("target_vol", 0.30),
- ("max_weight", 1.0),
- ("rebalance_band", 0.15),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.roc_short = bt.indicators.ROC(close, period=self.p.mom_short)
- self.roc_long = bt.indicators.ROC(close, period=self.p.mom_long)
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- returns = bt.indicators.PctChange(close, period=1)
- self.volatility = bt.indicators.StdDev(returns, period=self.p.vol_period)
- def next(self):
- super().next()
- if self.order:
- return
- if (
- math.isnan(self.roc_short[0])
- or math.isnan(self.roc_long[0])
- or math.isnan(self.sma_regime[0])
- or math.isnan(self.volatility[0])
- or self.volatility[0] <= 0
- ):
- return
- signal = (
- self.roc_short[0] > 0
- and self.roc_long[0] > 0
- and self.data.close[0] > self.sma_regime[0]
- )
- if not signal:
- self._go_flat()
- return
- annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
- target_weight = min(self.p.max_weight, self.p.target_vol / annualized_vol)
- target_weight = max(0.0, target_weight)
- portfolio_value = self.broker.getvalue()
- current_weight = 0.0
- if portfolio_value > 0:
- current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
- if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
- self._rebalance_to_weight(target_weight)
- class SuperTrendLongMaStrategy(BaseIndexStrategy):
- """SuperTrend breakout gated by a longer moving-average regime."""
- params = (
- ("supertrend_period", 10),
- ("supertrend_mult", 3.0),
- ("regime", 200),
- )
- def __init__(self):
- super().__init__()
- self.supertrend = SuperTrendIndicator(
- self.data,
- period=self.p.supertrend_period,
- multiplier=self.p.supertrend_mult,
- )
- self.sma_regime = bt.indicators.SMA(self.data.close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.supertrend[0]) or math.isnan(self.sma_regime[0]):
- return
- signal = self.data.close[0] > self.supertrend[0] and self.data.close[0] > self.sma_regime[0]
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class SuperTrendBasicStrategy(BaseIndexStrategy):
- """Pure SuperTrend breakout without the long-MA regime filter."""
- params = (
- ("supertrend_period", 10),
- ("supertrend_mult", 3.0),
- )
- def __init__(self):
- super().__init__()
- self.supertrend = SuperTrendIndicator(
- self.data,
- period=self.p.supertrend_period,
- multiplier=self.p.supertrend_mult,
- )
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.supertrend[0]):
- return
- signal = self.data.close[0] > self.supertrend[0]
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class TsmomLooseThresholdStrategy(BaseIndexStrategy):
- """Multi-window time-series momentum with regime filter but a looser positivity threshold."""
- params = (
- ("mom_windows", (60, 120, 240)),
- ("positive_threshold", 1),
- ("regime", 200),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.momentum = [bt.indicators.ROC(close, period=window) for window in self.p.mom_windows]
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.sma_regime[0]) or any(math.isnan(indicator[0]) for indicator in self.momentum):
- return
- positive_count = sum(indicator[0] > 0 for indicator in self.momentum)
- signal = positive_count >= self.p.positive_threshold and self.data.close[0] > self.sma_regime[0]
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class TsmomBasicStrategy(BaseIndexStrategy):
- """Multi-window time-series momentum without the long-MA regime filter."""
- params = (
- ("mom_windows", (60, 120, 240)),
- ("positive_threshold", 2),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.momentum = [bt.indicators.ROC(close, period=window) for window in self.p.mom_windows]
- def next(self):
- super().next()
- if self.order:
- return
- if any(math.isnan(indicator[0]) for indicator in self.momentum):
- return
- positive_count = sum(indicator[0] > 0 for indicator in self.momentum)
- signal = positive_count >= self.p.positive_threshold
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class TsmomRegimeStrategy(BaseIndexStrategy):
- """Multi-window time-series momentum with a long MA regime filter."""
- params = (
- ("mom_windows", (60, 120, 240)),
- ("positive_threshold", 2),
- ("regime", 200),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.momentum = [bt.indicators.ROC(close, period=window) for window in self.p.mom_windows]
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.sma_regime[0]) or any(math.isnan(indicator[0]) for indicator in self.momentum):
- return
- positive_count = sum(indicator[0] > 0 for indicator in self.momentum)
- signal = positive_count >= self.p.positive_threshold and self.data.close[0] > self.sma_regime[0]
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class KamaBasicStrategy(BaseIndexStrategy):
- """KAMA trend following without the long-MA regime filter."""
- params = (
- ("kama_period", 30),
- ("kama_fast", 2),
- ("kama_slow", 30),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.kama = bt.indicators.KAMA(
- close,
- period=self.p.kama_period,
- fast=self.p.kama_fast,
- slow=self.p.kama_slow,
- )
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= self.p.kama_period:
- return
- if math.isnan(self.kama[0]) or math.isnan(self.kama[-1]):
- return
- signal = self.data.close[0] > self.kama[0] and self.kama[0] > self.kama[-1]
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class KamaTrendStrategy(BaseIndexStrategy):
- """KAMA trend following with slope confirmation and long MA regime."""
- params = (
- ("kama_period", 30),
- ("kama_fast", 2),
- ("kama_slow", 30),
- ("regime", 200),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.kama = bt.indicators.KAMA(
- close,
- period=self.p.kama_period,
- fast=self.p.kama_fast,
- slow=self.p.kama_slow,
- )
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= max(self.p.kama_period, self.p.regime):
- return
- if math.isnan(self.kama[0]) or math.isnan(self.kama[-1]) or math.isnan(self.sma_regime[0]):
- return
- signal = (
- self.data.close[0] > self.kama[0]
- and self.kama[0] > self.kama[-1]
- and self.data.close[0] > self.sma_regime[0]
- )
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class DualThrustRegimeStrategy(BaseIndexStrategy):
- """Close-only Dual Thrust proxy for daily index data with no intraday range."""
- params = (
- ("range_period", 20),
- ("k1", 0.5),
- ("k2", 0.5),
- ("regime", 200),
- )
- def __init__(self):
- super().__init__()
- self.sma_regime = bt.indicators.SMA(self.data.close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= max(self.p.range_period, self.p.regime):
- return
- if math.isnan(self.sma_regime[0]):
- return
- closes = [float(self.data.close[-offset]) for offset in range(1, self.p.range_period + 1)]
- thrust_range = max(closes) - min(closes)
- reference_price = float(self.data.close[-1])
- upper = reference_price + self.p.k1 * thrust_range
- lower = reference_price - self.p.k2 * thrust_range
- entry_signal = self.data.close[0] > upper and self.data.close[0] > self.sma_regime[0]
- exit_signal = self.data.close[0] < lower or self.data.close[0] < self.sma_regime[0]
- if entry_signal and not self.position:
- self._buy_full()
- elif self.position and exit_signal:
- self._go_flat()
- class DualThrustBasicStrategy(BaseIndexStrategy):
- """Close-only Dual Thrust proxy without regime filter."""
- params = (
- ("range_period", 20),
- ("k1", 0.5),
- ("k2", 0.5),
- )
- def __init__(self):
- super().__init__()
- def next(self):
- super().next()
- if self.order:
- return
- if len(self) <= self.p.range_period:
- return
- closes = [float(self.data.close[-offset]) for offset in range(1, self.p.range_period + 1)]
- thrust_range = max(closes) - min(closes)
- reference_price = float(self.data.close[-1])
- upper = reference_price + self.p.k1 * thrust_range
- lower = reference_price - self.p.k2 * thrust_range
- entry_signal = self.data.close[0] > upper
- exit_signal = self.data.close[0] < lower
- if entry_signal and not self.position:
- self._buy_full()
- elif self.position and exit_signal:
- self._go_flat()
- class MacdBasicStrategy(BaseIndexStrategy):
- """MACD trend timing without the long moving-average regime filter."""
- params = (
- ("macd_fast", 12),
- ("macd_slow", 26),
- ("macd_signal", 9),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.macd = bt.indicators.MACD(
- close,
- period_me1=self.p.macd_fast,
- period_me2=self.p.macd_slow,
- period_signal=self.p.macd_signal,
- )
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.macd.macd[0]) or math.isnan(self.macd.signal[0]):
- return
- signal = self.macd.macd[0] > self.macd.signal[0]
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- class MacdLongMaStrategy(BaseIndexStrategy):
- """MACD trend timing with a longer moving-average regime filter."""
- params = (
- ("macd_fast", 12),
- ("macd_slow", 26),
- ("macd_signal", 9),
- ("regime", 200),
- )
- def __init__(self):
- super().__init__()
- close = self.data.close
- self.macd = bt.indicators.MACD(
- close,
- period_me1=self.p.macd_fast,
- period_me2=self.p.macd_slow,
- period_signal=self.p.macd_signal,
- )
- self.sma_regime = bt.indicators.SMA(close, period=self.p.regime)
- def next(self):
- super().next()
- if self.order:
- return
- if math.isnan(self.macd.macd[0]) or math.isnan(self.macd.signal[0]) or math.isnan(self.sma_regime[0]):
- return
- signal = self.macd.macd[0] > self.macd.signal[0] and self.data.close[0] > self.sma_regime[0]
- if signal and not self.position:
- self._buy_full()
- elif self.position and not signal:
- self._go_flat()
- def load_dataframe() -> pd.DataFrame:
- df = pd.read_csv(DATA_FILE, parse_dates=["datetime"], index_col="datetime")
- df = df.sort_index()
- return df[["open", "high", "low", "close", "volume"]].copy()
- def run_strategy(strategy_cls, config: dict, df: pd.DataFrame | None = None) -> dict:
- if df is None:
- df = load_dataframe()
- cerebro = bt.Cerebro(stdstats=False)
- cerebro.adddata(Chinext50Data(dataname=df))
- cerebro.addstrategy(strategy_cls, **config)
- cerebro.broker.setcash(INITIAL_CASH)
- cerebro.broker.setcommission(commission=COMMISSION)
- cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
- cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
- cerebro.addanalyzer(bt.analyzers.SharpeRatio_A, _name="sharpe", riskfreerate=0.02)
- cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
- strategy = cerebro.run()[0]
- final_value = cerebro.broker.getvalue()
- returns = strategy.analyzers.returns.get_analysis()
- drawdown = strategy.analyzers.drawdown.get_analysis()
- sharpe = strategy.analyzers.sharpe.get_analysis()
- trades = strategy.analyzers.trades.get_analysis()
- closed_trades = trades.get("total", {}).get("closed", 0)
- won_trades = trades.get("won", {}).get("total", 0)
- total_bars = len(df)
- return {
- "final_value": round(final_value, 2),
- "total_return_pct": round((final_value / INITIAL_CASH - 1.0) * 100.0, 2),
- "annual_return_pct": round(returns.get("rnorm100", 0.0), 2),
- "max_drawdown_pct": round(drawdown.get("max", {}).get("drawdown", 0.0), 2),
- "sharpe": round(sharpe["sharperatio"], 3) if sharpe.get("sharperatio") is not None else None,
- "entries": strategy.entry_count,
- "closed_trades": closed_trades,
- "win_rate_pct": round((won_trades / closed_trades) * 100.0, 2) if closed_trades else 0.0,
- "exposure_pct": round((strategy.exposure_sum / total_bars) * 100.0, 2),
- }
- def format_config(config: dict) -> str:
- return ", ".join(f"`{key}={value}`" for key, value in config.items())
- def format_config_plain(config: dict) -> str:
- return ", ".join(f"{key}={value}" for key, value in config.items())
- def metric_text(value):
- return "N/A" if value is None else value
- def sharpe_sort_value(item: dict) -> float:
- sharpe = item["metrics"]["sharpe"]
- return float("-inf") if sharpe is None else sharpe
- def choose_tradeoff_winner(results: list[dict]) -> dict:
- return max(
- results,
- key=lambda item: (
- sharpe_sort_value(item),
- item["metrics"]["annual_return_pct"],
- -item["metrics"]["max_drawdown_pct"],
- ),
- )
- def choose_capped_drawdown_winner(results: list[dict]) -> tuple[dict, float | None]:
- for drawdown_cap in (35.0, 40.0):
- eligible = [
- item
- for item in results
- if item["metrics"]["sharpe"] is not None and item["metrics"]["max_drawdown_pct"] <= drawdown_cap
- ]
- if eligible:
- return choose_tradeoff_winner(eligible), drawdown_cap
- eligible = [item for item in results if item["metrics"]["sharpe"] is not None]
- if eligible:
- return choose_tradeoff_winner(eligible), None
- return min(results, key=lambda item: item["metrics"]["max_drawdown_pct"]), None
- def choose_best_by_drawdown_cap(results: list[dict], drawdown_cap: float) -> dict | None:
- eligible = [item for item in results if item["metrics"]["max_drawdown_pct"] <= drawdown_cap]
- if not eligible:
- return None
- return choose_tradeoff_winner(eligible)
- def dualthrust_search_configs() -> list[dict]:
- range_periods = [10, 15, 20, 25, 30]
- k_values = [0.2, 0.3, 0.4, 0.5]
- regimes = [60, 90, 120, 150, 200]
- configs = []
- for range_period, k1, k2, regime in itertools.product(range_periods, k_values, k_values, regimes):
- configs.append(
- {
- "range_period": range_period,
- "k1": round(k1, 1),
- "k2": round(k2, 1),
- "regime": regime,
- }
- )
- return configs
- def summarize_dualthrust_candidate(label: str, item: dict | None) -> list[str]:
- if item is None:
- return [f"- {label}: no configuration met the filter."]
- metrics = item["metrics"]
- return [
- (
- f"- {label}: `{format_config_plain(item['config'])}` "
- f"| annual return `{metrics['annual_return_pct']:.2f}%` "
- f"| Sharpe `{metric_text(metrics['sharpe'])}` "
- f"| max DD `{metrics['max_drawdown_pct']:.2f}%` "
- f"| trades `{metrics['closed_trades']}`"
- )
- ]
- def build_dualthrust_optimization_report(results: list[dict], default_result: dict, df: pd.DataFrame) -> str:
- best_return = max(results, key=lambda item: item["metrics"]["annual_return_pct"])
- best_sharpe = choose_tradeoff_winner(results)
- best_dd30 = choose_best_by_drawdown_cap(results, 30.0)
- best_dd35 = choose_best_by_drawdown_cap(results, 35.0)
- top_by_return = sorted(
- results,
- key=lambda item: (
- item["metrics"]["annual_return_pct"],
- sharpe_sort_value(item),
- -item["metrics"]["max_drawdown_pct"],
- ),
- reverse=True,
- )[:8]
- top_by_sharpe = sorted(
- results,
- key=lambda item: (
- sharpe_sort_value(item),
- item["metrics"]["annual_return_pct"],
- -item["metrics"]["max_drawdown_pct"],
- ),
- reverse=True,
- )[:8]
- recommended = best_dd35 or best_sharpe
- default_metrics = default_result["metrics"]
- recommended_metrics = recommended["metrics"]
- beats_default_return = best_return["metrics"]["annual_return_pct"] > default_metrics["annual_return_pct"]
- beats_default_sharpe = (
- best_sharpe["metrics"]["sharpe"] is not None
- and (
- default_metrics["sharpe"] is None
- or best_sharpe["metrics"]["sharpe"] > default_metrics["sharpe"]
- )
- )
- lines = [
- "# DualThrust Chinext50 Optimization",
- "",
- f"- Data: `chinext50.csv` ({df.index.min().date()} to {df.index.max().date()}, {len(df)} bars)",
- f"- Initial cash: `{INITIAL_CASH:.0f}`",
- f"- Commission: `{COMMISSION:.4f}`",
- "- Strategy: `DualThrustRegimeStrategy`",
- "- 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]`",
- f"- Evaluated configs: `{len(results)}`",
- "",
- "## Command",
- "",
- "- `python3 chinext50_experiments.py --optimize-dualthrust`",
- "",
- "## Default Benchmark",
- "",
- (
- f"- Current default: `{format_config_plain(default_result['config'])}` "
- f"| annual return `{default_metrics['annual_return_pct']:.2f}%` "
- f"| Sharpe `{metric_text(default_metrics['sharpe'])}` "
- f"| max DD `{default_metrics['max_drawdown_pct']:.2f}%`"
- ),
- "",
- "## Required Winners",
- "",
- ]
- lines.extend(summarize_dualthrust_candidate("Best by annual return", best_return))
- lines.extend(summarize_dualthrust_candidate("Best by Sharpe", best_sharpe))
- lines.extend(summarize_dualthrust_candidate("Best with max DD <= 30% (Sharpe-ranked)", best_dd30))
- lines.extend(summarize_dualthrust_candidate("Best with max DD <= 35% (Sharpe-ranked)", best_dd35))
- lines.extend(
- [
- "",
- "## Top Candidates By Annual Return",
- "",
- "| Params | Annual Return | Sharpe | Max DD | Trades | Exposure |",
- "| --- | ---: | ---: | ---: | ---: | ---: |",
- ]
- )
- for item in top_by_return:
- metrics = item["metrics"]
- lines.append(
- "| {params} | {annual_return_pct:.2f}% | {sharpe} | {max_drawdown_pct:.2f}% | {closed_trades} | {exposure_pct:.2f}% |".format(
- params=format_config(item["config"]),
- annual_return_pct=metrics["annual_return_pct"],
- sharpe=metric_text(metrics["sharpe"]),
- max_drawdown_pct=metrics["max_drawdown_pct"],
- closed_trades=metrics["closed_trades"],
- exposure_pct=metrics["exposure_pct"],
- )
- )
- lines.extend(
- [
- "",
- "## Top Candidates By Sharpe",
- "",
- "| Params | Sharpe | Annual Return | Max DD | Trades | Exposure |",
- "| --- | ---: | ---: | ---: | ---: | ---: |",
- ]
- )
- for item in top_by_sharpe:
- metrics = item["metrics"]
- lines.append(
- "| {params} | {sharpe} | {annual_return_pct:.2f}% | {max_drawdown_pct:.2f}% | {closed_trades} | {exposure_pct:.2f}% |".format(
- params=format_config(item["config"]),
- sharpe=metric_text(metrics["sharpe"]),
- annual_return_pct=metrics["annual_return_pct"],
- max_drawdown_pct=metrics["max_drawdown_pct"],
- closed_trades=metrics["closed_trades"],
- exposure_pct=metrics["exposure_pct"],
- )
- )
- lines.extend(
- [
- "",
- "## Recommendation",
- "",
- (
- f"- Recommended next parameter set: `{format_config_plain(recommended['config'])}` "
- f"because it keeps max DD at `{recommended_metrics['max_drawdown_pct']:.2f}%` while delivering "
- f"`{recommended_metrics['annual_return_pct']:.2f}%` annual return and Sharpe `{metric_text(recommended_metrics['sharpe'])}`."
- ),
- (
- f"- Optimized DualThrust beat default on annual return: `{'yes' if beats_default_return else 'no'}` "
- f"({best_return['metrics']['annual_return_pct']:.2f}% vs {default_metrics['annual_return_pct']:.2f}%)."
- ),
- (
- f"- Optimized DualThrust beat default on Sharpe: `{'yes' if beats_default_sharpe else 'no'}` "
- f"({metric_text(best_sharpe['metrics']['sharpe'])} vs {metric_text(default_metrics['sharpe'])})."
- ),
- ]
- )
- return "\n".join(lines) + "\n"
- def build_report(results: list[dict], df: pd.DataFrame) -> str:
- best_return = max(results, key=lambda item: item["metrics"]["annual_return_pct"])
- best_sharpe = choose_tradeoff_winner(results)
- best_capped_drawdown, drawdown_cap = choose_capped_drawdown_winner(results)
- previous_results = [item for item in results if not item.get("is_new")]
- new_results = [item for item in results if item.get("is_new")]
- previous_best_return = max(previous_results, key=lambda item: item["metrics"]["annual_return_pct"]) if previous_results else None
- previous_best_sharpe = choose_tradeoff_winner(previous_results) if previous_results else None
- new_best_return = max(new_results, key=lambda item: item["metrics"]["annual_return_pct"]) if new_results else None
- new_best_sharpe = choose_tradeoff_winner(new_results) if new_results else None
- lines = [
- "# Chinext50 Backtrader Experiments",
- "",
- f"- Data: `chinext50.csv` ({df.index.min().date()} to {df.index.max().date()}, {len(df)} bars)",
- f"- Initial cash: `{INITIAL_CASH:.0f}`",
- f"- Commission: `{COMMISSION:.4f}`",
- "",
- "## Commands",
- "",
- "- Run all experiments: `python3 chinext50_experiments.py`",
- "",
- "## Configs",
- "",
- ]
- for item in results:
- lines.append(f"- **{item['name']}**: {format_config(item['config'])}")
- lines.extend(
- [
- "",
- "## Metrics",
- "",
- "| Strategy | Final Value | Total Return | Annual Return | Sharpe | Max DD | Entries | Closed Trades | Win Rate | Avg Exposure |",
- "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
- ]
- )
- for item in results:
- metrics = item["metrics"]
- lines.append(
- "| {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(
- name=item["name"],
- final_value=metrics["final_value"],
- total_return_pct=metrics["total_return_pct"],
- annual_return_pct=metrics["annual_return_pct"],
- sharpe=metric_text(metrics["sharpe"]),
- max_drawdown_pct=metrics["max_drawdown_pct"],
- entries=metrics["entries"],
- closed_trades=metrics["closed_trades"],
- win_rate_pct=metrics["win_rate_pct"],
- exposure_pct=metrics["exposure_pct"],
- )
- )
- lines.extend(
- [
- "",
- "## Verdict",
- "",
- (
- f"- Highest annual return: **{best_return['name']}** "
- f"({best_return['metrics']['annual_return_pct']:.2f}% annual return, "
- f"{best_return['metrics']['max_drawdown_pct']:.2f}% max DD)"
- ),
- (
- f"- Best risk-adjusted balance by Sharpe: **{best_sharpe['name']}** "
- f"(Sharpe {metric_text(best_sharpe['metrics']['sharpe'])}, "
- f"{best_sharpe['metrics']['annual_return_pct']:.2f}% annual return, "
- f"{best_sharpe['metrics']['max_drawdown_pct']:.2f}% max DD)"
- ),
- ]
- )
- if drawdown_cap is not None:
- lines.append(
- f"- Best Sharpe with max DD <= {drawdown_cap:.0f}%: **{best_capped_drawdown['name']}** "
- f"(Sharpe {metric_text(best_capped_drawdown['metrics']['sharpe'])}, "
- f"{best_capped_drawdown['metrics']['annual_return_pct']:.2f}% annual return, "
- f"{best_capped_drawdown['metrics']['max_drawdown_pct']:.2f}% max DD)"
- )
- else:
- lines.append(
- f"- Best drawdown-aware fallback: **{best_capped_drawdown['name']}** "
- f"(Sharpe {metric_text(best_capped_drawdown['metrics']['sharpe'])}, "
- f"{best_capped_drawdown['metrics']['annual_return_pct']:.2f}% annual return, "
- f"{best_capped_drawdown['metrics']['max_drawdown_pct']:.2f}% max DD)"
- )
- if previous_best_return and new_best_return:
- if new_best_return["metrics"]["annual_return_pct"] > previous_best_return["metrics"]["annual_return_pct"]:
- lines.append(
- f"- New-strategy return leader: **{new_best_return['name']}** beat the prior return leader "
- f"**{previous_best_return['name']}** "
- f"({new_best_return['metrics']['annual_return_pct']:.2f}% vs {previous_best_return['metrics']['annual_return_pct']:.2f}% annual return)"
- )
- else:
- lines.append(
- f"- New strategies did not beat the prior return leader **{previous_best_return['name']}** "
- f"({previous_best_return['metrics']['annual_return_pct']:.2f}% annual return). "
- f"Best new result was **{new_best_return['name']}** at {new_best_return['metrics']['annual_return_pct']:.2f}%."
- )
- if previous_best_sharpe and new_best_sharpe:
- previous_sharpe = previous_best_sharpe["metrics"]["sharpe"]
- new_sharpe = new_best_sharpe["metrics"]["sharpe"]
- if new_sharpe is not None and (previous_sharpe is None or new_sharpe > previous_sharpe):
- lines.append(
- f"- New-strategy Sharpe leader: **{new_best_sharpe['name']}** beat the prior Sharpe leader "
- f"**{previous_best_sharpe['name']}** "
- f"(Sharpe {metric_text(new_sharpe)} vs {metric_text(previous_sharpe)})"
- )
- else:
- lines.append(
- f"- New strategies did not beat the prior Sharpe leader **{previous_best_sharpe['name']}** "
- f"(Sharpe {metric_text(previous_sharpe)}). "
- f"Best new Sharpe was **{new_best_sharpe['name']}** at {metric_text(new_sharpe)}."
- )
- if new_results:
- best_new_drawdown = min(new_results, key=lambda item: item["metrics"]["max_drawdown_pct"])
- lines.append(
- f"- Most defensive new addition: **{best_new_drawdown['name']}** "
- f"delivered the lowest max DD among the new strategies "
- f"with {best_new_drawdown['metrics']['max_drawdown_pct']:.2f}% max DD, "
- f"{best_new_drawdown['metrics']['annual_return_pct']:.2f}% annual return, "
- f"and {best_new_drawdown['metrics']['win_rate_pct']:.2f}% win rate."
- )
- return "\n".join(lines) + "\n"
- def run_dualthrust_optimization() -> list[dict]:
- df = load_dataframe()
- configs = dualthrust_search_configs()
- results = []
- for config in configs:
- metrics = run_strategy(DualThrustRegimeStrategy, config, df=df)
- results.append(
- {
- "name": "DualThrustRegimeStrategy",
- "config": config,
- "metrics": metrics,
- }
- )
- default_config = {"range_period": 20, "k1": 0.3, "k2": 0.3, "regime": 120}
- default_result = next(item for item in results if item["config"] == default_config)
- report = build_dualthrust_optimization_report(results, default_result, df)
- DUALTHRUST_OPT_REPORT_FILE.write_text(report, encoding="utf-8")
- best_return = max(results, key=lambda item: item["metrics"]["annual_return_pct"])
- best_sharpe = choose_tradeoff_winner(results)
- best_dd30 = choose_best_by_drawdown_cap(results, 30.0)
- best_dd35 = choose_best_by_drawdown_cap(results, 35.0)
- print(f"Evaluated {len(results)} DualThrust configurations")
- for label, item in (
- ("Default", default_result),
- ("Best annual return", best_return),
- ("Best Sharpe", best_sharpe),
- ("Best max DD <= 30%", best_dd30),
- ("Best max DD <= 35%", best_dd35),
- ):
- if item is None:
- print(f"{label}: none")
- continue
- metrics = item["metrics"]
- print(
- f"{label}: config={item['config']}, "
- f"annual_return={metrics['annual_return_pct']:.2f}%, "
- f"sharpe={metric_text(metrics['sharpe'])}, "
- f"max_dd={metrics['max_drawdown_pct']:.2f}%"
- )
- print(f"Report written to {DUALTHRUST_OPT_REPORT_FILE.name}")
- return results
- def run_experiments() -> list[dict]:
- df = load_dataframe()
- experiments = [
- {
- "name": "TrendRegimeFlatStrategy",
- "strategy": TrendRegimeFlatStrategy,
- "config": {"fast": 20, "slow": 60, "regime": 120, "vol_fast": 20, "vol_slow": 60, "vol_cap": 1.10},
- },
- {
- "name": "TrendTightVolStrategy",
- "strategy": TrendTightVolStrategy,
- "config": {"fast": 20, "slow": 60, "regime": 120, "vol_fast": 20, "vol_slow": 60, "vol_cap": 0.95},
- "is_new": True,
- },
- {
- "name": "TrendLooseVolStrategy",
- "strategy": TrendLooseVolStrategy,
- "config": {"fast": 20, "slow": 60, "regime": 120, "vol_fast": 20, "vol_slow": 60, "vol_cap": 1.25},
- "is_new": True,
- },
- {
- "name": "SmaLongFilterTrendStrategy",
- "strategy": SmaLongFilterTrendStrategy,
- "config": {"fast": 20, "slow": 60, "regime": 120},
- "is_new": True,
- },
- {
- "name": "MomentumBasicStrategy",
- "strategy": MomentumBasicStrategy,
- "config": {"mom_short": 20, "mom_long": 120},
- "is_new": True,
- },
- {
- "name": "MomentumVolTargetBasicStrategy",
- "strategy": MomentumVolTargetBasicStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "vol_period": 30, "target_vol": 0.30, "max_weight": 1.0, "rebalance_band": 0.15},
- "is_new": True,
- },
- {
- "name": "MomentumVolTargetLowerTargetStrategy",
- "strategy": MomentumVolTargetStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "vol_period": 30, "target_vol": 0.25, "max_weight": 1.0, "rebalance_band": 0.15},
- "is_new": True,
- },
- {
- "name": "MomentumVolTargetHigherTargetStrategy",
- "strategy": MomentumVolTargetStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "vol_period": 30, "target_vol": 0.35, "max_weight": 1.0, "rebalance_band": 0.15},
- "is_new": True,
- },
- {
- "name": "MomentumRegimeStrategy",
- "strategy": MomentumRegimeStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "regime": 150},
- },
- {
- "name": "MomentumMaFilterStrategy",
- "strategy": MomentumMaFilterStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "ma_filter": 60},
- "is_new": True,
- },
- {
- "name": "MomentumAtrTrailBasicStrategy",
- "strategy": MomentumAtrTrailBasicStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "atr_period": 20, "atr_mult": 4.0},
- "is_new": True,
- },
- {
- "name": "MomentumAtrTrailTighterStrategy",
- "strategy": MomentumAtrTrailStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "atr_period": 20, "atr_mult": 3.0},
- "is_new": True,
- },
- {
- "name": "MomentumAtrTrailLooserStrategy",
- "strategy": MomentumAtrTrailStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "atr_period": 20, "atr_mult": 5.0},
- "is_new": True,
- },
- {
- "name": "MomentumAtrTrailStrategy",
- "strategy": MomentumAtrTrailStrategy,
- "config": {"mom_short": 20, "mom_long": 120, "regime": 150, "atr_period": 20, "atr_mult": 4.0},
- },
- {
- "name": "MomentumDefensiveFilterStrategy",
- "strategy": MomentumDefensiveFilterStrategy,
- "config": {
- "mom_short": 20,
- "mom_long": 120,
- "regime": 150,
- "vol_fast": 20,
- "vol_slow": 60,
- "vol_cap": 1.05,
- },
- "is_new": True,
- },
- {
- "name": "DonchianRegimeStrategy",
- "strategy": DonchianRegimeStrategy,
- "config": {"breakout": 55, "exit_period": 30, "regime": 150},
- },
- {
- "name": "DonchianRegimeFastStrategy",
- "strategy": DonchianRegimeStrategy,
- "config": {"breakout": 40, "exit_period": 20, "regime": 150},
- "is_new": True,
- },
- {
- "name": "DonchianRegimeSlowStrategy",
- "strategy": DonchianRegimeStrategy,
- "config": {"breakout": 70, "exit_period": 35, "regime": 150},
- "is_new": True,
- },
- {
- "name": "MomentumVolTargetStrategy",
- "strategy": MomentumVolTargetStrategy,
- "config": {
- "mom_short": 20,
- "mom_long": 120,
- "regime": 150,
- "vol_period": 30,
- "target_vol": 0.30,
- "max_weight": 1.0,
- "rebalance_band": 0.15,
- },
- },
- {
- "name": "DonchianBasicStrategy",
- "strategy": DonchianBasicStrategy,
- "config": {"breakout": 55, "exit_period": 30},
- "is_new": True,
- },
- {
- "name": "DonchianAdxStrategy",
- "strategy": DonchianAdxStrategy,
- "config": {"breakout": 55, "exit_period": 30, "adx_period": 14, "adx_threshold": 20.0},
- "is_new": True,
- },
- {
- "name": "DonchianAtrTrailStrategy",
- "strategy": DonchianAtrTrailStrategy,
- "config": {"breakout": 55, "exit_period": 30, "atr_period": 20, "atr_mult": 4.0},
- "is_new": True,
- },
- {
- "name": "DonchianVolTargetStrategy",
- "strategy": DonchianVolTargetStrategy,
- "config": {"breakout": 55, "exit_period": 30, "vol_period": 30, "target_vol": 0.30, "max_weight": 1.0, "rebalance_band": 0.15},
- "is_new": True,
- },
- {
- "name": "DonchianHybridVolAtrStrategy",
- "strategy": DonchianHybridVolAtrStrategy,
- "config": {
- "breakout": 55,
- "exit_period": 30,
- "vol_period": 30,
- "target_vol": 0.30,
- "max_weight": 1.0,
- "rebalance_band": 0.15,
- "atr_period": 20,
- "atr_mult": 4.0,
- },
- "is_new": True,
- },
- {
- "name": "SuperTrendLongMaStrategy",
- "strategy": SuperTrendLongMaStrategy,
- "config": {"supertrend_period": 14, "supertrend_mult": 2.0, "regime": 200},
- "is_new": True,
- },
- {
- "name": "SuperTrendLongMaFastRegimeStrategy",
- "strategy": SuperTrendLongMaStrategy,
- "config": {"supertrend_period": 14, "supertrend_mult": 2.0, "regime": 150},
- "is_new": True,
- },
- {
- "name": "SuperTrendBasicStrategy",
- "strategy": SuperTrendBasicStrategy,
- "config": {"supertrend_period": 14, "supertrend_mult": 2.0},
- "is_new": True,
- },
- {
- "name": "TsmomLooseThresholdStrategy",
- "strategy": TsmomLooseThresholdStrategy,
- "config": {"mom_windows": (60, 120, 240), "positive_threshold": 1, "regime": 200},
- "is_new": True,
- },
- {
- "name": "TsmomBasicStrategy",
- "strategy": TsmomBasicStrategy,
- "config": {"mom_windows": (60, 120), "positive_threshold": 1},
- "is_new": True,
- },
- {
- "name": "TsmomRegimeStrategy",
- "strategy": TsmomRegimeStrategy,
- "config": {"mom_windows": (60, 120), "positive_threshold": 1, "regime": 200},
- "is_new": True,
- },
- {
- "name": "KamaBasicStrategy",
- "strategy": KamaBasicStrategy,
- "config": {"kama_period": 30, "kama_fast": 2, "kama_slow": 30},
- "is_new": True,
- },
- {
- "name": "KamaTrendStrategy",
- "strategy": KamaTrendStrategy,
- "config": {"kama_period": 30, "kama_fast": 2, "kama_slow": 30, "regime": 120},
- "is_new": True,
- },
- {
- "name": "DualThrustBasicStrategy",
- "strategy": DualThrustBasicStrategy,
- "config": {"range_period": 20, "k1": 0.3, "k2": 0.3},
- "is_new": True,
- },
- {
- "name": "DualThrustFastStrategy",
- "strategy": DualThrustBasicStrategy,
- "config": {"range_period": 15, "k1": 0.3, "k2": 0.3},
- "is_new": True,
- },
- {
- "name": "DualThrustSlowStrategy",
- "strategy": DualThrustBasicStrategy,
- "config": {"range_period": 30, "k1": 0.3, "k2": 0.3},
- "is_new": True,
- },
- {
- "name": "DualThrustRegimeStrategy",
- "strategy": DualThrustRegimeStrategy,
- "config": {"range_period": 20, "k1": 0.3, "k2": 0.3, "regime": 120},
- "is_new": True,
- },
- {
- "name": "MacdBasicStrategy",
- "strategy": MacdBasicStrategy,
- "config": {"macd_fast": 12, "macd_slow": 26, "macd_signal": 9},
- "is_new": True,
- },
- {
- "name": "MacdLongMaFastRegimeStrategy",
- "strategy": MacdLongMaStrategy,
- "config": {"macd_fast": 12, "macd_slow": 26, "macd_signal": 9, "regime": 150},
- "is_new": True,
- },
- {
- "name": "MacdLongMaStrategy",
- "strategy": MacdLongMaStrategy,
- "config": {"macd_fast": 12, "macd_slow": 26, "macd_signal": 9, "regime": 120},
- "is_new": True,
- },
- ]
- results = []
- for item in experiments:
- metrics = run_strategy(item["strategy"], item["config"], df=df)
- results.append(
- {
- "name": item["name"],
- "config": item["config"],
- "metrics": metrics,
- "is_new": item.get("is_new", False),
- }
- )
- report = build_report(results, df)
- REPORT_FILE.write_text(report, encoding="utf-8")
- for item in results:
- metrics = item["metrics"]
- print(
- f"{item['name']}: final={metrics['final_value']:.2f}, "
- f"total_return={metrics['total_return_pct']:.2f}%, "
- f"annual_return={metrics['annual_return_pct']:.2f}%, "
- f"sharpe={metric_text(metrics['sharpe'])}, "
- f"max_dd={metrics['max_drawdown_pct']:.2f}%, "
- f"closed_trades={metrics['closed_trades']}, "
- f"win_rate={metrics['win_rate_pct']:.2f}%, "
- f"avg_exposure={metrics['exposure_pct']:.2f}%"
- )
- print(f"Report written to {REPORT_FILE.name}")
- return results
- def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument(
- "--optimize-dualthrust",
- action="store_true",
- help="Run a focused parameter search for DualThrustRegimeStrategy and write a markdown summary.",
- )
- return parser.parse_args()
- def main():
- args = parse_args()
- if args.optimize_dualthrust:
- run_dualthrust_optimization()
- return
- run_experiments()
- if __name__ == "__main__":
- main()
|