06 — Your first strategy¶
For someone who can edit a text file but would not call themselves a Python programmer. You will write about twenty lines of code and run four commands. Everything else in this repo can wait.
What you are actually writing¶
One function. It receives the price history and returns one number per bar:
| Number | Meaning |
|---|---|
1.0 |
fully long |
0.0 |
flat, holding nothing |
-1.0 |
fully short |
0.5 |
half-sized long |
That is the whole contract. You do not write the buying, the selling, the fee accounting, the fold splitting, or the performance maths — the framework does all of it, identically for every strategy, which is the only reason two strategies can be compared at all.
You are handed ctx.bars: a table with one row per bar and the columns
open, high, low, close, volume, trades. A rule-based strategy needs
nothing else — no machine learning, no features, no labels. Ignore those parts
of the repo until a rule of yours actually survives.
Setup, once¶
fetch downloads the price history and caches it to disk. Every command after
this one runs offline.
Step 1 — write the strategy¶
Create a new file nullres/strategies/mine.py and paste this in:
from __future__ import annotations
import pandas as pd
from nullres.strategies.base import Context, mask_to_oos
class Momentum:
"""Long when the last `lookback` bars were up. Flat otherwise."""
name = "momentum"
def __init__(self, lookback: int = 120):
self.lookback = lookback
def positions(self, ctx: Context) -> pd.Series:
close = ctx.bars["close"]
past_return = close / close.shift(self.lookback) - 1.0
pos = (past_return > 0).astype("float64")
return mask_to_oos(pos, ctx)
Line by line:
name = "momentum"— what you will type in the config to switch it on.def __init__(self, lookback: int = 120)— your tunable knobs.120is the default, used when the config does not say otherwise.close.shift(120)— the closing price 120 bars ago.shiftlooks backwards, never forwards. This is the single most important function in the file; see the one bug everyone writes.(past_return > 0)— a column of true/false..astype("float64")turns those into1.0and0.0, which are already valid positions. The first 120 bars have no history, so the comparison is false there and you sit flat — exactly right.mask_to_oos(pos, ctx)— always finish with this. It blanks out the warm-up period so your strategy is judged on the same bars as everything else. Skip it and your numbers are not comparable to anything.
Step 2 — register it¶
Open nullres/strategies/__init__.py and add two lines. First, next to the
other imports at the top:
Then inside the REGISTRY = {...} block:
That is the entire wiring. The name on the left is what configs and the command line will recognise.
Step 3 — run it on fake data first¶
Before real prices, run against configs/null.toml — a synthetic random walk
with no edge in it by construction:
strategy total cagr sharpe max dd calmar t-stat trades cost expo gross
-----------------------------------------------------------------------------------------------
buy_hold 36.9% 9.0% 0.23 -40.0% 0.23 0.43 2 0.2% 100% 1.00x
momentum -64.5% -24.8% -1.04 -74.8% -0.33 -1.98 1,206 76.5% 52% 0.52x
Losing 64.5% here is the correct result, and the cost column says why:
1,206 trades burned 76.5% of the account in fees alone. There was never any
signal to pay for.
This step is not a formality. If a strategy makes money on random data, the bug is in your code — or in the harness — and no result you produce afterwards means anything.
Step 4 — run it on real prices¶
strategy total cagr sharpe max dd calmar t-stat trades cost expo gross
-----------------------------------------------------------------------------------------------
buy_hold 148.6% 22.1% 0.38 -77.0% 0.29 0.80 2 0.2% 100% 1.00x
momentum 82.1% 14.1% 0.36 -61.5% 0.23 0.78 408 38.7% 52% 0.52x
+82% and it is still a failure. Buying once and never trading again made 148.6% over the same bars, at a marginally better Sharpe, with two trades instead of 408. The 38.7% cost line is where the difference went.
This is the normal outcome, and noticing it is the skill. A strategy is not measured against zero — it is measured against the laziest thing you could have done instead.
Step 5 — the arithmetic you should have run first¶
round-trip cost 0.2400% (12bps/side)
Holding period needed to break even, by accuracy:
accuracy hold (bars) measured ~duration
51% 133 140 23.3 days
52% 33 42 7.0 days
55% 5 8 32.0 hours
Read it like this: if you are right 51% of the time — already an ambitious claim — you must hold each position for about 23 days just to cover the spread. If your idea trades more often than that, it is dead before you write it, and no amount of tweaking will fix arithmetic.
Run budget first on every new idea. It takes two seconds and kills most of
them.
The four numbers to read, in order¶
- The
buy_holdrow. Did you beat it onsharpe? If not, stop. cost. The fraction of the account paid to the exchange. Above ~20% and you are trading too often, whatever the returns say.t-stat. Below about 3, the result is indistinguishable from luck. Both strategies above score under 1.- Deflated Sharpe, printed underneath. It penalises you for every variant ever tried in this repo, because trying fifty things and reporting the best one is how false discoveries are made.
Trying different settings¶
Change a knob without editing any file:
To make a setting permanent, put it in the config file instead:
A warning that is easy to ignore and costly to learn: every run is recorded
in runs/, and the deflated Sharpe counts all of them. Trying thirty lookbacks
and keeping the winner does not give you a good strategy; it gives you a number
that is already accounted for. See python -m nullres log.
The one bug everyone writes¶
Using information that did not exist yet. It produces beautiful results and they are always fake.
# WRONG — a negative shift reaches into the future
pos = (close.shift(-1) > close).astype("float64")
# WRONG — this bar's high is already inside the window it is compared against
pos = (close > high.rolling(50).max()).astype("float64")
# RIGHT — shift(1) ends the window at the PREVIOUS bar
pos = (close > high.rolling(50).max().shift(1)).astype("float64")
The rule: at bar t you may read bar t and everything before it. Anything
with a negative shift, and any rolling window that includes the current bar
in a comparison against the current bar, is a leak.
If a result looks spectacular, assume this happened. The leakage catalogue lists all seven ways it has happened here.
Cheat sheet¶
close is a column of prices. These phrases cover most simple ideas:
| Idea | Code |
|---|---|
| average of the last 50 bars | close.rolling(50).mean() |
| highest high of the last 50 bars, ending at the previous one | high.rolling(50).max().shift(1) |
| the price 20 bars ago | close.shift(20) |
| return since 20 bars ago | close / close.shift(20) - 1.0 |
| recent volatility | close.pct_change().rolling(50).std() |
| how stretched the price is | (close - close.rolling(50).mean()) / close.rolling(50).std() |
| both conditions must hold | (a > b) & (c > d) |
| either condition | (a > b) \| (c > d) |
| turn true/false into a position | .astype("float64") |
| long/short instead of long/flat | cond.astype("float64") * 2 - 1 |
If it survives¶
Then try to kill it properly:
audit checks the harness itself — that features are point-in-time, that the
labels do not leak, that random data earns nothing. robust attacks a
surviving strategy from three directions: neighbouring parameters, stability
across time, and transfer to other symbols. Almost nothing gets through, which
is the point.
Run python -m nullres --help, or python -m nullres run --help, for the rest.
Where to look next¶
- The three rules — read this before you get attached to a result.
- Research workflow — the loop this page is a shortcut through.
- Costs and execution — why 38.7% went to fees.
- The graveyard — six ideas that died here, and how.
- The measurements — every number this repo stands behind.