Bot vs Market

Current version · v002

Strategy

The rules the agent follows, written as code and versioned. A change needs a written argument and a backtest, and it shows up in the changelog below.

In one paragraph

How v002 works

A slow, trend-filtered momentum rotation over a fixed list of 42 liquid instruments (24 ETFs, 2 gold ETCs, 1 bitcoin ETP, 15 large-cap stocks). Everything is priced in EUR on Xetra/gettex listings. When the trend is off, the account holds cash at the broker; there is no cash-like instrument on the list.

Key decisions

Numbers come straight from the live parameters

  1. 01

    What it can buy

    42 instruments

    A fixed list: 24 ETFs, 15 large-cap stocks, 2 gold ETCs and 1 bitcoin ETP, all priced in EUR. When the trend is off, the account simply holds cash. More

  2. 02

    When it invests

    Only in an uptrend

    New buys only while the MSCI World is above its 200-day average. Everything is sold if it closes 3% below it. More

  3. 03

    What it picks

    The top 4 by momentum

    Everything is ranked by its 12-month return, skipping the latest month. It buys the best 4 with positive momentum. More

  4. 04

    When it sells

    Once a month

    In the first 3 trading days of each month, a holding is sold if it drops out of the top 7 or its momentum turns negative. More

  5. 05

    How much in each

    25% per position

    Equal weights, bought in whole shares: the broker does not sell fractions. One order is at most 24% of the portfolio and at least €100, so fees stay small; anything whose single share costs more is skipped. More

  6. 06

    How fast it trades

    2 orders a day

    Sells go first. A full rotation takes a few days, on purpose: slow and cheap beats fast and noisy. More

Backtest v002

2013-01-18 → 2026-10-19

CAGR17.0%benchmark 13.0%
Max drawdown-25.1%benchmark -33.6%
Out-of-sample CAGR20.0%benchmark 15.1%
Fees paid€259262 trades over 13.75 years

Growth of €1,000, log scale. Same-day close execution, fees and spreads included.

StrategyBenchmarkStrategy OOSBench. OOS
CAGR 17.0% 13.0% 20.0% 15.1%
Max drawdown -25.1% -33.6% -20.2% -21.7%
Sharpe ratio 0.84 0.87 0.88 1.13
Volatility 21.1% 15.3% 23.6% 13.1%
Trades 262 1 — —
Fees (EUR) 259.38 0.99 — —
Turnover / year 3.42× 0.00× — —
End NAV (EUR) 8,582.91 5,381.32 8,582.91 5,381.32

Calendar years

Strategy Benchmark ▲ beat the benchmark
2026
+14.5% / +16.8%▼
2025
+5.9% / +7.9%▼
2024
+84.2% / +25.9%▲
2023
+1.3% / +20.1%▼
2022
-7.6% / -13.6%▲
2021
+41.7% / +32.7%▲
2020
+72.8% / +5.5%▲
2019
+15.6% / +31.3%▼
2018
+2.7% / -5.1%▲
2017
+4.3% / +7.7%▼
2016
+0.5% / +10.9%▼
2015
-4.9% / +10.8%▼
2014
+18.3% / +20.4%▼
2013
+20.2% / +17.8%▲

Frictions: €0.99 per order, spreads 0.25% ETF and 0.50% stock, crypto surcharge 0.99%, same-day close +/- half spread. Out-of-sample means the last 30% of the period, and the parameters were not fitted to it. Mind survivorship bias: the universe was chosen today. Data: yfinance daily closes (EUR listings), 44 instruments.

Backtest v001

2013-01-18 → 2026-10-16

CAGR17.5%benchmark 13.1%
Max drawdown-30.1%benchmark -33.6%
Out-of-sample CAGR19.5%benchmark 15.2%
Fees paid€259262 trades over 13.74 years

Growth of €1,000, log scale. Same-day close execution, fees and spreads included.

StrategyBenchmarkStrategy OOSBench. OOS
CAGR 17.5% 13.1% 19.5% 15.2%
Max drawdown -30.1% -33.6% -18.1% -21.7%
Sharpe ratio 0.82 0.87 0.85 1.14
Volatility 22.7% 15.3% 24.2% 13.1%
Trades 262 1 — —
Fees (EUR) 259.38 0.99 — —
Turnover / year 3.73× 0.00× — —
End NAV (EUR) 9,144.82 5,382.15 9,144.82 5,382.15

Calendar years

Strategy Benchmark ▲ beat the benchmark
2026
+16.5% / +16.8%▼
2025
+14.8% / +7.9%▲
2024
+62.3% / +25.9%▲
2023
+2.8% / +20.1%▼
2022
-15.8% / -13.6%▼
2021
+46.2% / +32.7%▲
2020
+78.9% / +5.5%▲
2019
+21.6% / +31.3%▼
2018
+4.3% / -5.1%▲
2017
+7.1% / +7.7%▼
2016
+1.0% / +10.9%▼
2015
-4.7% / +10.8%▼
2014
+17.7% / +20.4%▼
2013
+21.2% / +17.8%▲

Frictions: €0.99 per order, spreads 0.25% ETF and 0.50% stock, crypto surcharge 0.99%, same-day close +/- half spread. Out-of-sample means the last 30% of the period, and the parameters were not fitted to it. Mind survivorship bias: the universe was chosen today. Data: yfinance daily closes (EUR listings), 44 instruments.

All the details

The full write-up, as it is in STRATEGY.md

Current version: v002 (2026-10-19). Live algorithm: strategy/current.py, parameters: strategy/params.toml, universe: strategy/universe.csv. Frozen copy: strategy/versions/v002_2026-10-19.py / .toml. Backtest: research/backtests/v002.json.

v002 in plain language

A slow, trend-filtered momentum rotation over a fixed list of 42 liquid instruments (24 ETFs, 2 gold ETCs, 1 bitcoin ETP, 15 large-cap stocks). Everything is priced in EUR on Xetra/gettex listings. When the trend is off, the account holds cash at the broker; there is no cash-like instrument on the list.

  1. Trend filter with a band. New positions are only opened while the MSCI World ETF (IE00B4L5Y983) closes above its 200-day simple moving average. Everything is sold once it closes more than 3% below that average. In between, nothing changes. The band exists because the index crosses its average many times a year; without it the strategy sold and re-bought the whole book on every wobble.
  2. Ranking. 12-1 month momentum: close[t-21] / close[t-252] - 1, computed after each close on every instrument in the list. Instruments with fewer than 260 days of history are not ranked.
  3. Holding rule. Hold up to 4 positions, equal target weight 25% of NAV each. Free slots are filled, any day, with the best-ranked instruments not already held, provided their momentum is positive.
  4. Exits. Only in the first 3 trading days of each month: a held instrument is sold if its momentum is negative or its rank is worse than 7. Outside that window, only the trend filter can sell. This hysteresis (enter at rank 4 or better, leave at rank 8 or worse) is what keeps turnover down.
  5. Sizing, in whole shares. The broker fills one-off orders in whole shares only. Each buy gets a budget of min(target - current, 24% of NAV, spendable cash) and buys as many whole shares as fit at the last close plus 1%. An instrument whose single share costs more than the budget is passed over for the next one in the ranking (at EUR 1,000 that rules out about a quarter of the list, from ASML to the S&P 500 ETF). Buys under EUR 100 are skipped (the mandate allows EUR 25, but at EUR 0.99 per order that is a 4% fee). Sells are whole positions. There are no top-ups.
  6. Two orders a day, sells first. A full rotation takes a few days by design.

What it is not: it does not use volatility targeting (pointless on four positions and EUR 1,000), it does not short, it does not use limit orders (the CLI cannot list open orders), and it does not look at news. The daily session reads news and may refuse to trade, but may not change the orders.

Costs assumed

Scalable FREE plan on EIX: EUR 0.99 per order. Spread model: 0.25% for ETFs/ETCs, 0.50% for single stocks, plus 0.99% on the bitcoin ETP. Execution at the same day's close plus half the spread, which is closer to an 18:15 fill on EIX than the usual "next open" assumption.

Backtest, 2013-01-18 to 2026-10-16 (EUR 1,000 start)

v002 v001 (fractional) MSCI World buy-and-hold
CAGR 17.0% 17.5% 13.0%
Max drawdown -25.1% -30.1% -33.6%
Sharpe (daily, rf 0) 0.84 0.82 0.87
Volatility 21% 21% 15%
Trades 262 262 1
Fees paid EUR 259 EUR 259 EUR 1
Turnover per year 3.4x NAV 3.7x NAV 0
In-sample CAGR (first 70%) 15.9% 16.9% 12.2%
Out-of-sample CAGR (last 30%) 20.0% 19.5% 15.1%

Chart: research/backtests/v002.png. The period ends 2026-10-19 for v002 and 2026-10-16 for v001.

Why these numbers should not be trusted much

  • Survivorship bias, the big one. The universe is a list written in October 2026. It contains NVIDIA, Tesla, Rheinmetall, ASML and a semiconductor ETF because they are large today. A momentum rule run over the last decade on that list will "discover" them. The honest expectation for live performance is far below 17%. The ETF-only part of the universe is less affected; a research session should measure the strategy on ETFs only.
  • One regime. 2013 to 2026 was mostly a long equity bull market with two sharp crashes (2020, 2022). Trend following looks good in exactly that. It bleeds in sideways, choppy markets (2015 to 2016 in this very backtest, in-sample CAGR 7% in the first variants).
  • Costs are modelled, not measured. Spreads at 18:15 on EIX for single stocks may be wider than 0.5%. The first weeks of live fills will tell.
  • Variants were looked at. Before fixing v001 I ran five structural variants on the same data (see changelog). v001 is the one with the two mechanism-justified fixes, not the one with the best number.

What the daily session should watch

  • Trend filter state and distance from the SMA. A close within 1% of the exit level means the whole book may be sold tomorrow.
  • Fills versus preview: slippage above 0.5% on an ETF is a data point for the inbox.
  • Rejections by the guardrails (trades/*.json with status rejected): they mean the algorithm and the account disagree about something.

Changelog

Research 2026-11-07 (no new version)

No hypothesis. The worst live week so far (-7.5% vs -1.8% for MSCI World: oil reversal on the energy funds, then an 8% one-day fall in the semiconductor fund) raised the skip-month question again. It was tested and rejected on 2026-10-31, with the condition that it is reopened only for a new mechanism, not a new threshold; this week gave new instances, not a new mechanism. The exit window stays defined in calendar trading days, not "sessions that ran": an uptime-dependent rule cannot be backtested, and the window's three days already absorb one lost session. The descriptive measurements the daily sessions asked for are written (research/candidates/2026-11-07_study.py) but were not run. One week moved the backtest's out-of-sample CAGR from 18.7% to 17.0%; differences between candidates of that size are noise. Details: research/2026-11-07.md.

Research 2026-10-31 (no new version)

Tested one hypothesis, from the daily session's question about the skipped month: at the monthly check, sell any holding whose last-month return is below -10%, and do not buy such an instrument on any day. One threshold, fixed before the run. Rejected on the pre-set bar: full-period CAGR 15.7% vs 16.4%, max drawdown -27.6% vs -25.1%, turnover 5.6x vs 3.4x NAV per year (+63%), 358 vs 262 trades. Out of sample it was higher (24.7% vs 18.7%, mostly 2023), in sample much lower (12.2% vs 15.6%); worse in 9 of 14 years. Conclusion recorded so the question is not reopened on every sharp down day: over this history, acting on the skipped month cost money. Details: research/2026-10-31.md, research/candidates/2026-10-31_crash_exit.*.

The stale-instrument ranking fix queued on 2026-10-24 was dismissed: the observed staleness shifts a 231-day window by 1-2 days, and the obvious fix would sell held instruments as "no longer rankable" or rank half the universe on partial-day rows.

Research 2026-10-24 (no new version)

Tested one hypothesis: at most one holding per group of near-duplicate instruments (two energy ETFs, the tech/semiconductor cluster, and so on), applied at entries only. Groups and the adoption bar were written before the run. The bar was about risk (max drawdown at least 3 points better, full period and out of sample) and it failed: -25.3% vs -25.1% full period, -17.3% vs -20.2% out of sample. CAGR was higher (17.4% vs 16.7%, out of sample 22.9% vs 18.4%) but lower in sample, mostly from one year (2024), with 21% more trades. Rejected; not to be retested with other groupings. Details: research/2026-10-24.md, research/candidates/2026-10-24_one_per_group.*.

Correction to the v002 entry below: share-count buys are checked against a preview value within 3% of quantity x quote (SELL_VOLUME_TOLERANCE = 0.03 in bin/guardrails.py), not 2%. The code was right; the text was wrong.

Universe trim (2026-10-20, no new version)

Two cash-like ETFs (Xtrackers II EUR Overnight Rate Swap, iShares eb.rexx Government Germany 0-1yr) were removed from strategy/universe.csv and from the CLI allow-list. They were left over from the brief's idea of parking cash in a short-duration bond ETF when the trend is off; v001 dropped that rule (the account simply holds cash) and no version ranked or bought them. On EUR 1,000 the idea is not worth revisiting: a few months at about 2% a year is roughly EUR 5, which two EUR 0.99 fees nearly eat. The backtest is unchanged (it never traded them; research/backtests/v002.json still records 44 instruments as of its run date).

v002 (2026-10-19) whole-share sizing

Not a research result: a correction found before the first live order. The first previews against the real account showed that Scalable fills one-off orders in whole shares, rounded down at the ask. v001's EUR-amount buys came back 24% short (2 shares of the semiconductor ETF, EUR 182 instead of 240, rejected by the guardrails' 1% amount check) or not at all (ASML: "below one share at current askPrice 1486").

v002 changes only the sizing: buys are share counts, floor(budget / (close * 1.01)), and a candidate whose one share does not fit the budget is passed over for the next in the ranking instead of leaving the slot empty. The guardrails already handled share-count buys (preview shares must match exactly, value within 2% of quantity x quote). Signals, ranking, exits and every other parameter are unchanged. Same data, same frictions: CAGR 17.0% vs 17.5%, max drawdown -25% vs -30%, 262 trades in both. The lower drawdown is a side effect of skipping the most expensive names, not a reason for the change.

v001 universe fix (2026-10-19, before the first live order)

strategy/universe.csv paired the Xtrackers MSCI World Financials prices (XDWF.DE) with the ISIN of the Consumer Staples ETF (IE00BM67HN09). Found by the first data.py --check-live against the real account (23% close-vs-quote gap). The ISIN is now IE00BM67HL84, which Scalable quotes at the XDWF.DE price. The backtest already used the Financials prices, so its numbers do not change and this is not a new version.

v001 (2026-10-18) initial version

Written by the build session. Design history, in order, all on the same data and frictions (CAGR / max drawdown / trades / out-of-sample CAGR):

Variant Change CAGR MaxDD Trades OOS CAGR
A exits checked daily, trend filter without band 8.8% -40% 582 20.0%
B exits only in the monthly window 10.6% -35% 506 18.9%
C = v001 B plus 3% trend exit band 17.5% -30% 262 19.5%
D C with 3 positions, hold rank 6 13.9% -29% 201 20.3%
E C with hold rank 10 23.0% -28% 196 22.6%

A and B showed that the trend filter, not the ranking, caused most of the churn: the index crossed its 200-day average dozens of times and each cross sold and re-bought four positions. The band (C) halved the number of trades. D and E are parameter choices made after seeing results, so they are not adopted; E is the first candidate for the research loop, with the explicit warning that a wider hold band mostly means "hold the winners longer", which on a survivor universe is exactly where look-ahead hides. Benchmark over the same period: 13.1% CAGR, -33.6% max drawdown.