Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

STARC

Systematic Trading and Risk Control.

A backtesting and paper-trading system built around a claim I wanted to test properly: that predicting returns is mostly noise, but risk is measurable and controllable, and the second one is where a retail-scale system can actually do something useful.

It pulls adjusted price data, runs strategies through a backtest engine with a hard train/test boundary, executes against a paper broker, and wraps the whole thing in a volatility-targeting and drawdown-control layer.

Architecture

Four layers, each usable on its own.

Layer What it does Module
Analytics Sharpe, Sortino, drawdown, beta, VaR/CVaR over adjusted prices src/metrics.py, src/report.py
Backtesting No-lookahead engine turning weights into cost-aware returns; three strategies plus a baseline; chronological split with parameters fit on train only src/backtest/
Execution Paper trading via Alpaca on a reconciliation pattern: read positions, compute deltas, trade the difference src/execution/
Risk Volatility targeting, a drawdown kill-switch, fractional Kelly, position limits, tail measures src/risk/

The risk layer never touches the engine. It takes the weights a strategy produced and hands back different weights:

strategy -> target weights -> [ risk layer ] -> adjusted weights -> engine / broker

So it composes with any strategy, and the identical risk_manage() call runs in both the backtest and live execution. src/execution/run_once.py calls it, takes the last row as today's target, and passes that to the reconciler. Set use_risk_layer: false in config/execution.yaml to trade the raw base weights instead.

Result: risk management is regime-dependent

I tested the same risk configuration out-of-sample on two market regimes.

Regime Metric Unmanaged Risk-managed Change
Calm (test 2021-06 to 2023-12) Sharpe 0.88 0.85 -3.0%
Max drawdown -13.7% -12.3% -10.0%
COVID crash (test 2019-06 to 2023-12) Sharpe 0.91 1.08 +18.2%
Max drawdown -32.5% -13.3% -59.2%

In calm markets the layer barely does anything, because there is no volatility spike to react to. It gives up a little Sharpe and buys a modest drawdown improvement, which is close to a wash. Through the COVID crash the same settings cut maximum drawdown by 59% and raised Sharpe from 0.91 to 1.08. That cost 1.4 points of annualized return, 17.9% down to 16.5%, which strikes me as a trade worth making.

Both windows use identical settings from config/risk.yaml. Retuning the risk parameters per regime would produce better-looking numbers and tell you nothing, so I didn't.

Risk-managed vs unmanaged buy-and-hold through the COVID crash

Two results I would rather report than bury.

Average drawdown gets slightly worse under management, -3.2% to -3.7% in the COVID window. The de-risked book climbs out of small dips more slowly even as it avoids the large one. Given it halved the peak drawdown, I'll take that.

The position limit never binds in these results. Ten equal-weighted names at max_leverage: 1.5 can reach 0.150 at most, against a max_position cap of 0.25, and the measured maximum across the COVID run is exactly 0.1500. There is a test showing it bind at 0.25 on a 70/20/10 book, so the guardrail works; it just has nothing to do on this universe. The run prints whether it bound, so you don't have to take my word for it.

Whether the backtest means anything

This is the part I actually cared about. It is very easy to build a backtest that lies to you.

No lookahead

Every signal earns the next period's return, never the current one. The one-day shift lives in exactly one place, in engine.py, so no strategy can route around it. Tests append future data and assert that past P&L does not move: at the engine, the strategies, the volatility scale, the Kelly scale, the drawdown control, and the assembled pipeline.

Out-of-sample discipline

The split is chronological, never shuffled, and the two sides do not share a single row. Strategy parameters are grid-searched on the training slice only (src/backtest/tuning.py) and evaluated once on the untouched later window. A test asserts that extending the data past the cutoff cannot change which parameters get selected.

Frictions

Transaction costs and turnover are modeled in, including the initial 0% to 100% build and the daily trading a constant-weight book needs to fight drift. That second one is easy to miss and it matters. An equal-weight book turns over roughly 250% a year just holding its weights steady. Skip it and you hand buy-and-hold free rebalancing, which quietly flatters the baseline every signal strategy is measured against.

What the numbers actually support

MA crossover, with parameters selected on train (fast=20, slow=200), edges buy-and-hold out-of-sample on Sharpe: 0.99 against 0.91.

I don't believe it, and I would rather say so than let the table imply otherwise. It was the best of 9 candidate pairs, and the 0.075 gap sits well inside the sampling error of an annualized Sharpe measured over 4.58 years. Lo's IID estimator, sqrt((1 + SR^2 / 2) / T), puts that standard error near 0.56, so the edge is about an eighth of one. Mean reversion and momentum both lose outright.

Tests

121 of them. Metrics against hand-computed values, the no-lookahead guarantees, the reconciliation logic, the risk layer's hysteresis and composition, config validation, and the tuning loop. They run offline in about three seconds.

Known limitations

The drawdown kill-switch watches the unmanaged base equity curve rather than the de-risked book's own curve. This is deliberate. It is a regime detector, so it should see the market's drawdown and not the softened one it already produced. The consequence is that the switch fires while the managed book sits in a shallower drawdown than the threshold implies.

The throttle is binary, 1.0 or 0.5. A drawdown_flatten tier exists and is tested. It reads the same base curve, which bottoms at -32.5% through the COVID crash, so a threshold anywhere between -0.20 and -0.30 would genuinely fire. It ships disabled (drawdown_flatten: null) anyway, since switching it on now would mean retuning the risk layer to change a published result rather than measure one.

That tier had a real bug worth recording. An earlier version of drawdown_exposure built its internal equity curve from exposure-scaled returns instead of raw base returns. Once exposure hit zero, 0 * return is always zero, so the curve froze and could never recover enough to re-enter. Flattening was a one-way door. The internal curve now tracks raw base returns regardless of the exposure decision, which fixes the lockout and makes the "watches the base curve" claim above literally true instead of approximately true. Two tests pin it: test_flatten_tier_is_not_an_absorbing_state and test_reentry_timing_is_independent_of_the_throttle_value.

Kelly sizing (src/risk/kelly.py) is implemented and tested but off by default (kelly_window: null). Enabled, it acts as a cap: exposure becomes min(vol_target_scale, kelly_scale). Full Kelly is deliberately not on offer. It is brutally sensitive to error in the estimated mean, and a mean estimated from a few hundred noisy daily returns will size you into the ground. fraction defaults to 0.5.

Paper trading only. There is no live-money path and I have no plans to add one.

Tech

Python, pandas, numpy, matplotlib, yfinance for data, alpaca-py for paper execution, pytest. Configuration is YAML under config/. Secrets live in a gitignored .env.

Setup

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install -r requirements.txt

For paper execution, copy .env.example to .env and add Alpaca paper keys.

Run

python -m src.report                                    # portfolio analytics + plots
python -m src.backtest.run                              # COVID window (default)
python -m src.backtest.run config/backtest_calm.yaml    # calm window
python -m src.execution.run_once                        # one paper rebalance (market hours)
python -m src.execution.service                         # scheduled daily rebalance
python -m pytest -q                                     # the test suite

Run it on your own portfolio

Everything you need to change lives in one file. Copy config/backtest.yaml, edit it, pass it as an argument.

universe: [SPY, BND, GLD]        # any tickers yfinance knows

weights:                          # must sum to 1.0, one entry per universe ticker
  SPY: 0.6
  BND: 0.3
  GLD: 0.1

start: "2015-01-01"               # YYYY-MM-DD
end: "2023-12-31"
split_date: "2020-01-01"          # train before, test strictly after
cost_rate: 0.0005                 # 5 bps per unit of turnover
report_suffix: "_mine"            # keeps your charts from overwriting the defaults
python -m src.backtest.run config/my_portfolio.yaml

You get the train and test date ranges, buy-and-hold with and without the risk layer, whether the position limit bound, the parameters each strategy selected on train, out-of-sample results per strategy, and two charts in reports/.

Bad input fails with a message that names the problem. Structural errors like bad weights or bad dates are caught before any data is downloaded. A bad ticker is caught right after the download is attempted, since that is the earliest point it is knowable.

weights sum to 1.05, expected 1.0
universe tickers have no weight: GLD: every ticker in the universe needs an entry in weights
split_date (2024-01-01) must fall strictly between start (2015-01-01) and end (2023-12-31)
no data returned for XYQZ: check the symbol(s) and the date range

A test window under a year runs but warns, since metrics from a short window are not stable.

To tune the risk layer rather than the portfolio, edit config/risk.yaml: target_vol, vol_window, max_leverage, the drawdown thresholds, max_position, and the optional kelly_window and drawdown_flatten tiers.

Layout

src/
  metrics.py  report.py  portfolio.py  data_loader.py  config.py   # analytics + validation
  backtest/   engine, strategies, costs, splits, tuning, run       # backtesting
  execution/  broker, rebalance, run_once, service                 # paper trading
  risk/       volatility_target, drawdown_control, kelly,          # risk layer
              limits, measures, pipeline
tests/        121 tests

Built as a study in systematic trading and quantitative risk control. Paper trading only, and nothing here is investment advice.

Licensed under the MIT License. See LICENSE.

About

Systematic backtesting and paper trading with a composable risk layer. Volatility targeting, drawdown control, and fractional Kelly sizing.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages