Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

MA Divergence

A trend-following system with a mean-reversion escape hatch, built on StratOS.

Write-up: Stepping aside when the trend gets ahead of itself

Hold a trending instrument. When price stretches unusually far above its moving average, step aside: that stretch is the part that gives back. Then scale back in on the way down, in chunks, rather than guessing the bottom in one trade.

Instrument TQQQ (3× leveraged NASDAQ-100)
Period 2020-01-02 → 2026-09-11 · 6.7 years
Bars Daily
Starting capital $100,000
Data Yahoo Finance, split/dividend adjusted

Results

Equity curve, $100,000 growing to $6.22M on a log scale

MA Divergence Buy and hold TQQQ
Total return +5,974.4% +555.9%
CAGR 84.7% 32.5%
Sharpe ratio 2.22 0.73
Max drawdown −33.5% −81.7%
End value $6,220,203 $671,690
Trades 36 1

The flat stretches in that curve are the point of the whole exercise. They are the periods the strategy spent in cash, out of the market, while a 3× leveraged ETF did whatever it was going to do.

Against simply holding it

MA Divergence versus buy-and-hold TQQQ, both from $100,000, log scale

Both lines start with the same cash, trade the same bars, and pay the same execution rules. For the first two years they are nearly the same line: in a straight uptrend, a strategy whose baseline is "hold the instrument" has nothing to add. The separation is 2022: buy-and-hold falls 81.7% peak to trough and spends three years recovering; the strategy sat out most of it and compounded from a base it never gave back.

That is the whole thesis in one picture. The edge is not in the entries. It is in the quarters spent holding cash.

Beating an 81.7% drawdown with a 33.5% one is a more meaningful result than beating 32.5% CAGR with 84.7%, because the drawdown is what determines whether a real person is still holding the position when the recovery arrives.

Drawdown

Underwater plot showing depth below the running peak, worst −33.5%

The worst peak-to-trough loss was 33.5%, in early 2026. Over the same period holding TQQQ outright drew down 81.7%. A one-third drawdown is still a bad year by any normal standard. It is only respectable relative to the instrument.

Year by year

Bar chart of annual returns 2020 through 2026

Year Return Start End
2020 +108.15% $102,401 $213,151
2021 +179.62% $213,151 $596,007
2022 −13.33% $596,007 $516,543
2023 +138.22% $510,416 $1,215,899
2024 +101.32% $1,154,265 $2,323,787
2025 +73.32% $2,309,104 $4,002,156
2026¹ +56.52% $3,974,067 $6,220,203

¹ Partial year, through 11 September 2026.

Six up years and one down year is a flattering record, and it is also a six-and-a-half-year sample containing exactly one bear market. Treat the 2022 line (the only year the system had to defend rather than compound) as the most informative row in the table, not the worst one.

Trades

36 fills across 16 completed round trips.

Win rate 81.2% (13W / 3L) Profit factor 21.94
Longest win streak 5 Longest losing streak 1
Median holding period 47.5 days Longest hold 401 days

Trade Stats

Average Median Worst Best
Return per round trip +43.09% +39.96% −18.63% +194.20%
Winners +55.70% +43.75% +8.87% +194.20%
Losers −11.54% −11.43% −4.56% −18.63%

A profit factor near 22 is not a sign of a miraculous edge; it is what happens when a strategy takes small, capped losses and occasionally rides a leveraged instrument through a long trend. The shape matters more than the magnitude: losses cluster tightly between −4.6% and −18.6%, while the winners are open-ended.

Return distribution

Period Count Worst Median Average Best % positive
Daily 1,052 −20.31% +0.48% +0.44% +13.52% 58%
Weekly 350 −12.85% 0.00% +1.02% +28.86% 40%
Monthly 81 −16.01% +3.15% +6.04% +46.98% 58%
Yearly 7 −13.33% +101.32% +91.97% +179.62% 86%

The weekly row is the honest one: only 40% of weeks are positive. The median week is exactly zero, because the strategy is often in cash. Returns arrive in concentrated bursts, which is uncomfortable to hold in real time regardless of what the annual figures say.


The idea

The engine calls step() once per bar with candles through the last closed bar plus the current bar's open, and fills whatever orders come back at that open. Every rule below is therefore evaluated on information that was genuinely available. Lookahead is impossible by construction, not by care.

Baseline. Hold the instrument while price is above its moving average and the average itself is rising.

Stretch exit. Leave when price closes far enough above the moving average. A leveraged ETF that has run a long way above trend is carrying a premium that historically comes back; the exit is a bet on that reversion, not on a top. "Far enough" is either a multiple of the average or a number of standard deviations above it: two profiles of the same idea.

Cross exit, confirmed. Leave when price closes below the average. A single close through a flat average is mostly noise, so the exit requires several consecutive closes beyond it and the next bar to open beyond it as well.

Slope filter. Ignore average-crossings while the average itself is flat (the regime where crossings mean least), and hold the current position instead. Band exits still fire: a violent stretch happens in a steep trend, and that is exactly when you want out.

Scale-in ladder. After an exit, buy back in steps as price falls, measured either from the average or from the running peak. Each rung deploys an equal dollar amount of the remaining cash, so a deeper fall buys more shares. Anything still uninvested when price breaks back above the average goes in at once.

Re-entry. Back in when price falls to the entry band, or, optionally, once the stretch has given back enough from its peak, whichever comes first.

The calibration is not published. The mechanism above is complete and the implementation is in src/ma_divergence/strategy.py, but the thresholds, windows and ladder that produced the results on this page are not in this repository. params.example.toml ships illustrative values so the code runs; it will not reproduce the numbers above.


Running it

git clone https://github.com/gocmenco/ma-divergence.git
cd ma-divergence
make init                    # venv + stratos from git
python main.py               # writes output/*.html
python main.py --show        # ...and opens it

Yahoo Finance data, no API key, a couple of minutes. The HTML report is far richer than this page: interactive equity and drawdown, monthly tables, the return distribution, round-trip statistics, the full configuration, and a journal of every trade.

python main.py --profile stddev              # standard-deviation bands
python main.py --ticker QQQ --start 2015-01-01
python main.py --capital 25000 --no-benchmark
python main.py --export-csv                  # series for tools/make_charts.py

main.py is the whole program, top to bottom: parameters in, strategy built, backtested, reported, and compared against simply holding the same ticker. Start there.

Parameters

All the numbers live in params.toml, which is git-ignored. A fresh clone falls back to params.example.toml and says so on startup. Copy it and put your own calibration in:

cp params.example.toml params.toml

Daily signal

strategies/ holds two runnable configurations (one per band type) found by the StratOS CLI:

stratos list
stratos run tqqq_ma_divergence --dry-run

stratos run finds them because StratOS searches ./strategies under the working directory. That is the entire integration: no registration, no plugin manifest. On cron:

15 18 * * 1-5 cd /path/to/ma-divergence && .venv/bin/stratos run tqqq_ma_divergence

The runner decides and prints. It does not place orders and does not notify anyone. If you want it to act, wire stratos.trader into run(); if you want the signal delivered somewhere, pipe the output or add your own notifier.

In your own code

from ma_divergence import MaDivergence, MaDivergenceConfig
from ma_divergence.params import load_params

params = load_params("fixed")
strategy = MaDivergence(ma_divergence_config=MaDivergenceConfig(...), ...)
strategy.backtest()

Reading these results honestly

Everything above is a backtest. Backtests are the most flattering way to look at a trading system, and this one has every advantage:

  • The parameters were chosen by looking at backtests. That is what makes them worth exactly what they do out of sample, and no more.
  • One backtest is a single draw from a jagged surface. Adjacent parameter values on a system like this can differ by tens of CAGR points. That spread is noise, not a peak, and picking the top of it is how you fool yourself.
  • Six and a half years is one regime and a bit. 2020–2026 was, with one interruption, an extraordinary run for leveraged NASDAQ exposure. A strategy whose baseline is "hold TQQQ" inherits that. The interesting question is not whether it beat cash; it is what happens in a decade that looks like 2000–2010.
  • The comparison that matters is buy-and-hold, not zero. It is above, and main.py prints both every run for exactly this reason.
  • Costs are modelled but light. Whole-share fills at the next open, no commission, no borrowing cost, no slippage assumption beyond the open price.
  • Leveraged ETFs decay. TQQQ's daily reset means its long-run behaviour depends on the path, not just the destination. A backtest captures this only as far as the price history does.

Before believing any number here, run the split: a parameter worth keeping sits near the top of both halves of the period, not at the full-period maximum. stratos.optimizer has the tools (grid sweeps, walk-forward efficiency, Deflated Sharpe, CSCV/PBO, a robustness gate), and its CLAUDE.md in the StratOS repo is the protocol.

Nothing here is investment advice, a recommendation, or a claim about the future. It is a worked example of building a strategy on StratOS.


Layout

main.py                backtest + report: start reading here
params.example.toml    every knob, with illustrative values
src/ma_divergence/     the strategy class and the parameter loader
strategies/            runnable configurations, found by `stratos run`
tools/make_charts.py   regenerates the charts on this page
tests/                 offline smoke tests
docs/                  the charts on this page
output/                generated reports (git-ignored)

Development

make test                      # pytest
make format                    # isort + autoflake + black
make install-local-stratos     # develop against a local ../stratos checkout

Regenerating the charts above:

python main.py --export-csv
python tools/make_charts.py

Licence

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages