Skip to content

Commit 405faab

Browse files
committed
Multi-strategy book: ETF sleeves traded next to the stock book
- qtrade/portfolio/sleeves.py: the eight ETF strategies that held up in both periods (vol-managed SPY, cross-asset momentum, 200-day average, risk parity, 12-month time-series momentum, 30-ETF trend, turn of the month, multi-asset trend) as target weights; tests check each earns exactly what the research strategy earned and that no weight uses later prices - combined by 1 / trailing sleeve volatility, scaled to 10% vol, gross <= 1.5x - live: with multi.enabled, the ETF book gets etf_fraction of the capital and the stock book the rest; orders go out together as market-on-open - config/multi_small_account.yaml: $10k, half and half, 7 sleeves - research task 'multi': the book on real data, alone and blended - macro: FRED's JSON API when a FRED_API_KEY secret exists (Yahoo stand-ins otherwise); paper-trade can be dispatched with a config file [research: multi,strategies] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsPrq53XXinQsUZsWqeKht
1 parent f4a9f71 commit 405faab

11 files changed

Lines changed: 449 additions & 8 deletions

File tree

‎.github/workflows/paper-trade.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ on:
2020
description: "Send orders to the paper account (false = dry run)"
2121
type: boolean
2222
default: false
23+
config:
24+
description: "Config file for this run (empty = the QTRADE_CONFIG variable or the small account)"
25+
type: string
26+
default: ""
2327

2428
concurrency:
2529
group: paper-trade
@@ -36,7 +40,7 @@ jobs:
3640
ALPACA_SECRET_KEY: ${{ secrets.ALPACA_SECRET_KEY }}
3741
# $10k small-account settings by default; override with a repository
3842
# variable QTRADE_CONFIG (e.g. config/default.yaml for the full 1000-name book)
39-
QTRADE_CONFIG: ${{ vars.QTRADE_CONFIG || 'config/small_account.yaml' }}
43+
QTRADE_CONFIG: ${{ inputs.config || vars.QTRADE_CONFIG || 'config/small_account.yaml' }}
4044
steps:
4145
- name: Check for Alpaca keys
4246
id: keys

‎.github/workflows/research.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ jobs:
3333
ALPACA_SECRET_KEY: ${{ secrets.ALPACA_SECRET_KEY }}
3434
GH_TOKEN: ${{ github.token }}
3535
DATA_KEY: ${{ secrets.DATA_KEY }}
36+
FRED_API_KEY: ${{ secrets.FRED_API_KEY }}
3637
steps:
3738
- uses: actions/checkout@v4
3839
- uses: actions/setup-python@v5
@@ -96,7 +97,7 @@ jobs:
9697
# a partial run (some tasks only) adds its files to latest instead of replacing it
9798
mkdir -p research/latest && cp -r "$GITHUB_WORKSPACE/reports/research/." research/latest/
9899
{ echo "# Research results"; echo
99-
for f in data_quality spreads edge alt poisson conditional combos garch glm strategies events survivorship intraday hmm; do
100+
for f in data_quality spreads edge alt poisson conditional combos garch glm strategies multi events survivorship intraday hmm; do
100101
[ -f "research/latest/$f.md" ] && { cat "research/latest/$f.md"; echo; }
101102
done; } > research/latest/README.md
102103
git add -A && git commit -q -m "Research run ${GITHUB_SHA::7} (run ${{ github.run_id }})"

‎.github/workflows/zoo.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ concurrency:
2424

2525
env:
2626
DATA_KEY: ${{ secrets.DATA_KEY }}
27+
FRED_API_KEY: ${{ secrets.FRED_API_KEY }}
2728
GH_TOKEN: ${{ github.token }}
2829

2930
jobs:

‎config/multi_small_account.yaml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Multi-strategy $10,000 account: the stock book (as config/small_account.yaml)
2+
# plus ETF sleeves (qtrade/portfolio/sleeves.py), half the capital each.
3+
# python -m qtrade --config config/multi_small_account.yaml paper # dry run
4+
# python -m qtrade --config config/multi_small_account.yaml paper --send # paper orders
5+
# To make the scheduled paper trader use it, set the repository variable
6+
# QTRADE_CONFIG = config/multi_small_account.yaml.
7+
portfolio:
8+
max_names: 60
9+
n_neutral_factors: 3
10+
whole_shares: true
11+
max_gross: 2.0 # stock book gross, on its half of the capital
12+
costs:
13+
aum: 5000 # the stock book trades $5k of the $10k
14+
slippage_bps: 1.5
15+
spread_tiers:
16+
- [0.0, 0.0]
17+
live:
18+
max_capital: 10000
19+
min_order_value: 25
20+
multi:
21+
enabled: true
22+
etf_fraction: 0.5
23+
# the 30-ETF trend sleeve needs more capital than $5k for whole shares
24+
sleeves: [spy_vol_managed, cross_asset_mom, spy_ma200, risk_parity, spy_tsmom_12m, spy_turn_of_month,
25+
multi_asset_trend]
26+
target_vol: 0.10
27+
max_gross: 1.5

‎qtrade/config.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,20 @@ class LiveConfig:
120120
log_dir: str = "logs"
121121

122122

123+
@dataclass
124+
class MultiConfig:
125+
"""ETF sleeves (``qtrade.portfolio.sleeves``) traded alongside the stock book."""
126+
enabled: bool = False
127+
etf_fraction: float = 0.5 # share of deployed capital for the ETF sleeves; the rest is the stock book
128+
sleeves: list = field(default_factory=lambda: ["spy_vol_managed", "cross_asset_mom", "spy_ma200",
129+
"risk_parity", "spy_tsmom_12m", "broad_trend_12m",
130+
"spy_turn_of_month", "multi_asset_trend"])
131+
target_vol: float = 0.10 # annualised volatility target for the ETF book
132+
max_gross: float = 1.5 # ETF book gross exposure cap (multiple of its capital)
133+
vol_window: int = 756 # days of sleeve returns behind the 1 / volatility weights
134+
history_start: str = "2004-01-01"
135+
136+
123137
@dataclass
124138
class IntradayConfig:
125139
"""Intraday reversal strategy on N-minute bars (``qtrade.intraday``)."""
@@ -160,6 +174,7 @@ class Config:
160174
costs: CostConfig = field(default_factory=CostConfig)
161175
live: LiveConfig = field(default_factory=LiveConfig)
162176
intraday: IntradayConfig = field(default_factory=IntradayConfig)
177+
multi: MultiConfig = field(default_factory=MultiConfig)
163178
report_dir: str = "reports"
164179
diagnostics: bool = True # also backtest portfolio-construction variants
165180

‎qtrade/data/public.py‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,17 @@ def _cache(root: str | Path) -> Path:
6262
# -----------------------------------------------------------------------------
6363
def load_fred(root: str | Path = "data", refresh: bool = False) -> pd.DataFrame:
6464
"""Daily frame of macro series, each shifted by its publication lag."""
65+
import os
66+
6567
path = _cache(root) / "fred.parquet"
6668
if path.exists() and not refresh:
67-
return pd.read_parquet(path)
69+
cached = pd.read_parquet(path)
70+
# a cache built from the Yahoo stand-ins is replaced once a FRED key is available
71+
if "jobless_claims" in cached.columns or not os.environ.get("FRED_API_KEY"):
72+
return cached
6873
s = _session()
69-
cols = {}
70-
fails = 0
74+
cols = _fred_api(s) # with a free key: reliable from CI
75+
fails = 0 if not cols else 99
7176
for name, (sid, lag) in FRED_SERIES.items():
7277
if fails >= 2: # FRED often times out from cloud runners
7378
break
@@ -94,6 +99,31 @@ def load_fred(root: str | Path = "data", refresh: bool = False) -> pd.DataFrame:
9499
return out
95100

96101

102+
def _fred_api(s) -> dict:
103+
"""FRED's JSON API (needs FRED_API_KEY, free at fred.stlouisfed.org/docs/api/api_key.html).
104+
The keyless CSV endpoint times out from GitHub's runners."""
105+
import os
106+
107+
key = os.environ.get("FRED_API_KEY")
108+
if not key:
109+
return {}
110+
cols = {}
111+
for name, (sid, lag) in FRED_SERIES.items():
112+
try:
113+
r = s.get("https://api.stlouisfed.org/fred/series/observations",
114+
params={"series_id": sid, "api_key": key, "file_type": "json"}, timeout=30)
115+
r.raise_for_status()
116+
obs = pd.DataFrame(r.json()["observations"])
117+
x = pd.Series(pd.to_numeric(obs["value"], errors="coerce").to_numpy(),
118+
index=pd.to_datetime(obs["date"])).dropna()
119+
x.index = x.index + pd.Timedelta(days=lag)
120+
cols[name] = x
121+
LOG.info("FRED API %s: %d values", sid, len(x))
122+
except Exception as exc:
123+
LOG.warning("FRED API %s failed: %s", sid, exc.__class__.__name__) # never log the key
124+
return cols
125+
126+
97127
def _macro_from_yahoo() -> dict:
98128
"""Market-traded stand-ins for the FRED series, from Yahoo, each lagged a day."""
99129
import yfinance as yf

‎qtrade/live/alpaca_trader.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,39 @@ def run_paper(cfg: Config, dry_run: bool | None = None, out_dir: str | Path | No
196196
if cfg.live.max_capital:
197197
deploy = min(deploy, cfg.live.max_capital)
198198

199+
# ETF sleeves next to the stock book: each gets its share of the deployed capital
200+
etf_w, etf_px = {}, {}
201+
stock_deploy = deploy
202+
if cfg.multi.enabled:
203+
from ..portfolio.sleeves import etf_targets
204+
205+
etf_w, etf_px, etf_asof, sw = etf_targets(cfg, end=last_completed_session())
206+
if etf_asof != asof:
207+
LOG.warning("ETF data ends %s but stock data ends %s", etf_asof.date(), asof.date())
208+
if tradable is not None:
209+
etf_w = {k: v for k, v in etf_w.items() if k in tradable}
210+
stock_deploy = deploy * (1 - cfg.multi.etf_fraction)
211+
LOG.info("ETF sleeves (%.0f%% of capital): %s | sleeve weights %s", 100 * cfg.multi.etf_fraction,
212+
", ".join(f"{k} {v:+.2f}" for k, v in sorted(etf_w.items(), key=lambda x: -abs(x[1]))),
213+
sw.iloc[-1].round(2).to_dict())
214+
199215
cols = md.tickers
200-
w_cur = np.array([pos_val.get(t, 0.0) / deploy for t in cols]) if deploy > 0 else np.zeros(len(cols))
216+
w_cur = (np.array([pos_val.get(t, 0.0) / stock_deploy for t in cols]) if stock_deploy > 0
217+
else np.zeros(len(cols)))
201218
allowed = None if tradable is None else np.array([t in tradable for t in cols])
202219
no_short = None if shortable is None else np.array([t not in shortable for t in cols])
203220
dd = equity / peak - 1 if peak > 0 else 0.0
204221
risk_scale = cfg.portfolio.drawdown_scale if dd < -cfg.portfolio.drawdown_limit else 1.0
205222
t = len(md.dates) - 1
206-
d = dec.decide(t, w_cur, deploy, risk_scale, allowed=allowed, no_short=no_short)
223+
d = dec.decide(t, w_cur, stock_deploy, risk_scale, allowed=allowed, no_short=no_short)
207224

208225
prices = {tk: float(px) for tk, px in md.traded_close.iloc[-1].items() if np.isfinite(px)}
209-
tw = {tk: float(w) for tk, w in zip(cols, d.weights) if abs(w) > 1e-6}
226+
# weights below are fractions of the whole deployed capital
227+
share = stock_deploy / deploy if deploy > 0 else 0.0
228+
tw = {tk: float(w) * share for tk, w in zip(cols, d.weights) if abs(w) > 1e-6}
229+
for tk, w in etf_w.items():
230+
tw[tk] = tw.get(tk, 0.0) + w * cfg.multi.etf_fraction * risk_scale
231+
prices.setdefault(tk, etf_px.get(tk, np.nan))
210232
orders, targets = plan_orders(tw, prices, deploy, pos_qty, shortable, cfg.live.min_order_value)
211233

212234
stamp = pd.Timestamp.now(tz="America/New_York").strftime("%Y%m%d_%H%M")

‎qtrade/portfolio/sleeves.py‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""ETF sleeves: the strategies that held up in both 2006-2014 and 2015-2026.
2+
3+
Each sleeve turns ETF closes into target weights decided at the close of day t
4+
(traded at the next open). They are the same rules as the research strategy
5+
zoo (``qtrade.research.strategies``), rewritten to return positions instead of
6+
returns so they can be traded; ``tests/test_sleeves.py`` checks the two agree.
7+
8+
Sleeves (2015-2026 net Sharpe in the strategy zoo):
9+
10+
spy_vol_managed SPY scaled by 1 / GJR-GARCH volatility forecast 0.84
11+
cross_asset_mom top 3 of 8 asset classes by 6-month return 0.78
12+
spy_ma200 SPY above its 200-day average 0.79
13+
risk_parity 8 asset classes weighted by 1 / volatility 0.70
14+
spy_tsmom_12m SPY when its 12-month return is positive 0.69
15+
broad_trend_12m 12-month trend over ~30 ETFs, 10% vol each 0.54
16+
spy_turn_of_month SPY over the turn of the month 0.48
17+
multi_asset_trend 12-month trend over 8 asset classes 0.42
18+
19+
They are combined with weights 1 / trailing volatility of each sleeve's own
20+
returns (walk-forward), then scaled to a target volatility.
21+
"""
22+
from __future__ import annotations
23+
24+
import numpy as np
25+
import pandas as pd
26+
27+
ASSETS = ["SPY", "EFA", "EEM", "TLT", "IEF", "GLD", "DBC", "VNQ"]
28+
BROAD = ["USO", "UNG", "DBA", "DBB", "SLV", "GLD", "UUP", "FXE", "FXY", "FXA", "SHY", "IEF", "TLT", "TIP",
29+
"LQD", "HYG", "EWJ", "EWG", "EWU", "FXI", "EWZ", "EWC", "EWA", "EWY", "EWT", "SPY", "QQQ", "IWM",
30+
"VNQ", "EEM"]
31+
ETF_UNIVERSE = list(dict.fromkeys(ASSETS + BROAD))
32+
ETF_COST = 1e-4
33+
DEFAULT_SLEEVES = ["spy_vol_managed", "cross_asset_mom", "spy_ma200", "risk_parity", "spy_tsmom_12m",
34+
"broad_trend_12m", "spy_turn_of_month", "multi_asset_trend"]
35+
36+
37+
def _month_ends(idx: pd.DatetimeIndex) -> pd.Series:
38+
s = pd.Series(idx, index=idx)
39+
return s.groupby([idx.year, idx.month]).transform("max") == s
40+
41+
42+
def _spy(close: pd.DataFrame, w: pd.Series) -> pd.DataFrame:
43+
return pd.DataFrame({"SPY": w}, index=close.index)
44+
45+
46+
def spy_ma200(close: pd.DataFrame) -> pd.DataFrame:
47+
spy = close["SPY"]
48+
return _spy(close, (spy > spy.rolling(200).mean()).astype(float))
49+
50+
51+
def spy_tsmom_12m(close: pd.DataFrame) -> pd.DataFrame:
52+
spy = close["SPY"]
53+
return _spy(close, (spy / spy.shift(252) - 1 > 0).astype(float))
54+
55+
56+
def spy_vol_managed(close: pd.DataFrame, fit_end: str | None = None) -> pd.DataFrame:
57+
"""Leverage = median variance / forecast variance for the next day, square-rooted, capped at 2.
58+
59+
``fit_end``: estimate the GARCH parameters on returns before this date only
60+
(the research used 2015-01-01); by default all but the last year."""
61+
from ..research.garch import fit_garch, garch_filter
62+
63+
r = close["SPY"].pct_change(fill_method=None)
64+
x = r.to_numpy()
65+
end = pd.Timestamp(fit_end) if fit_end else r.index[-1] - pd.Timedelta(days=365)
66+
train = np.asarray(r.index < end) & np.isfinite(x)
67+
p = fit_garch(np.where(train, x, np.nan)[: np.flatnonzero(train)[-1] + 1], gjr=True)
68+
# one extra step: the variance forecast for the day after each close
69+
s2f = garch_filter(np.append(x, np.nan), p["w"], p["a"], p["b"], p["g"], p["s0"])
70+
lev = np.clip(np.sqrt(np.nanmedian(s2f[: len(x)][train])) / np.sqrt(s2f[1:]), 0, 2)
71+
return _spy(close, pd.Series(lev, index=r.index))
72+
73+
74+
def spy_turn_of_month(close: pd.DataFrame) -> pd.DataFrame:
75+
"""Hold SPY over the last trading day of a month and the first three of the next.
76+
77+
The calendar is known ahead, so the weight at close t is the holding for day
78+
t+2 (the convention every sleeve shares: decided at t, entered at the open of
79+
t+1, earning day t+2's close-to-close return in the backtest)."""
80+
from pandas.tseries.holiday import USFederalHolidayCalendar
81+
from pandas.tseries.offsets import CustomBusinessDay
82+
83+
cbd = CustomBusinessDay(calendar=USFederalHolidayCalendar())
84+
idx = close.index
85+
ext = idx.append(pd.DatetimeIndex([idx[-1] + k * cbd for k in (1, 2, 3)]))
86+
month = np.asarray(ext.month)
87+
is_last = np.append(month[1:] != month[:-1], False)
88+
nth = pd.Series(1, index=ext).groupby([ext.year, ext.month]).cumsum().to_numpy()
89+
tom = np.append(False, is_last[:-1]) | (nth <= 3)
90+
hold = pd.Series(tom.astype(float), index=ext).shift(-2).reindex(idx)
91+
return _spy(close, hold)
92+
93+
94+
def risk_parity(close: pd.DataFrame) -> pd.DataFrame:
95+
c = close[ASSETS]
96+
vol = c.pct_change(fill_method=None).rolling(63, min_periods=40).std() * np.sqrt(252)
97+
return (1 / vol).div((1 / vol).sum(axis=1), axis=0)
98+
99+
100+
def cross_asset_mom(close: pd.DataFrame) -> pd.DataFrame:
101+
c = close[ASSETS]
102+
top = (c / c.shift(126) - 1).rank(axis=1, ascending=False) <= 3
103+
return (top.astype(float) / 3).where(_month_ends(c.index)).ffill()
104+
105+
106+
def multi_asset_trend(close: pd.DataFrame) -> pd.DataFrame:
107+
c = close[ASSETS]
108+
vol = c.pct_change(fill_method=None).rolling(63, min_periods=40).std() * np.sqrt(252)
109+
return (np.sign(c / c.shift(252) - 1) * 0.10 / vol).clip(-2, 2) / len(ASSETS)
110+
111+
112+
def broad_trend_12m(close: pd.DataFrame) -> pd.DataFrame:
113+
c = close[[t for t in BROAD if t in close.columns]]
114+
vol = c.pct_change(fill_method=None).rolling(63, min_periods=40).std() * np.sqrt(252)
115+
sign = np.sign(c / c.shift(252) - 1)
116+
return (sign * 0.10 / vol).clip(-2, 2).div(c.notna().sum(axis=1).clip(lower=1), axis=0)
117+
118+
119+
SLEEVES = {f.__name__: f for f in (spy_vol_managed, cross_asset_mom, spy_ma200, risk_parity, spy_tsmom_12m,
120+
broad_trend_12m, spy_turn_of_month, multi_asset_trend)}
121+
122+
123+
def sleeve_returns(w: pd.DataFrame, close: pd.DataFrame, cost: float = ETF_COST) -> pd.Series:
124+
"""Daily net return of holding ``w`` (decided at the close of t-2) over day t, as in the research."""
125+
r = close[w.columns].pct_change(fill_method=None).fillna(0)
126+
p = w.shift(2)
127+
return ((p * r).sum(axis=1) - p.diff().abs().sum(axis=1).fillna(0) * cost).fillna(0.0)
128+
129+
130+
def combine(close: pd.DataFrame, sleeves: list[str] | None = None, target_vol: float = 0.10,
131+
vol_window: int = 756, max_gross: float = 1.5, fit_end: str | None = None) -> dict:
132+
"""Sleeves weighted by 1 / trailing volatility of their own returns, scaled to ``target_vol``
133+
with the trailing year's realised volatility of the blend. Everything uses data up to the
134+
close the weights are decided at."""
135+
names = sleeves or DEFAULT_SLEEVES
136+
W = {}
137+
for n in names:
138+
W[n] = SLEEVES[n](close, fit_end) if n == "spy_vol_managed" else SLEEVES[n](close)
139+
R = pd.DataFrame({n: sleeve_returns(w, close) for n, w in W.items()})
140+
vol = R.rolling(vol_window, min_periods=126).std()
141+
sw = (1 / vol).div((1 / vol).sum(axis=1), axis=0) # sleeve weights at each close
142+
blend = (sw.shift(2) * R).sum(axis=1) # the blend as it would have been held
143+
real = blend.rolling(252, min_periods=126).std() * np.sqrt(252)
144+
lev = (target_vol / real).clip(0, 3).fillna(0)
145+
cols = sorted({c for w in W.values() for c in w.columns})
146+
book = pd.DataFrame(0.0, index=close.index, columns=cols)
147+
for n, w in W.items():
148+
book = book.add(w.reindex(columns=cols).fillna(0).mul(sw[n].fillna(0), axis=0), fill_value=0)
149+
book = book.mul(lev, axis=0)
150+
gross = book.abs().sum(axis=1)
151+
book = book.mul((max_gross / gross).clip(upper=1).fillna(0), axis=0)
152+
return {"weights": book, "sleeve_weights": sw, "sleeve_returns": R, "returns": sleeve_returns(book, close)}
153+
154+
155+
def needed_tickers(sleeves: list[str]) -> list[str]:
156+
need = []
157+
for n in sleeves:
158+
need += BROAD if n == "broad_trend_12m" else ASSETS if n in ("cross_asset_mom", "risk_parity",
159+
"multi_asset_trend") else ["SPY"]
160+
return list(dict.fromkeys(need))
161+
162+
163+
def etf_targets(cfg, end=None) -> tuple[dict[str, float], dict[str, float], pd.Timestamp, pd.DataFrame]:
164+
"""Today's ETF book: weights (as a fraction of the ETF capital), as-traded prices, the
165+
date of the last close used, and the sleeve weights over the last days (for the log)."""
166+
from ..data import get_market_data
167+
168+
m = cfg.multi
169+
md = get_market_data(needed_tickers(m.sleeves), m.history_start, None, cfg.data.cache_dir + "/live_etf",
170+
cfg.data.batch_size)
171+
if end is not None:
172+
md = md.subset(end=end)
173+
out = combine(md.close, m.sleeves, m.target_vol, m.vol_window, m.max_gross)
174+
w = out["weights"].iloc[-1]
175+
prices = {t: float(p) for t, p in md.traded_close.iloc[-1].items() if np.isfinite(p)}
176+
return ({t: float(v) for t, v in w.items() if abs(v) > 1e-6}, prices, md.dates[-1],
177+
out["sleeve_weights"].tail(5))

‎qtrade/research/__main__.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ def main(argv=None):
5757
if task == "quality":
5858
from .data_quality import run
5959
summary[task] = run(md, ctx, out, cfg.data.cache_dir)
60+
elif task == "multi":
61+
import pandas as pd
62+
63+
from .multi import run
64+
rpath = Path(cfg.data.cache_dir) / "reversal_daily.csv"
65+
rev = pd.read_csv(rpath, index_col=0, parse_dates=True)["ret"] if rpath.exists() else None
66+
summary[task] = run(md, ctx, out, cfg, rev)
6067
elif task == "intraday":
6168
from .intraday_strats import run
6269
summary[task] = run(md, ctx, out, cfg)

0 commit comments

Comments
 (0)