Skip to content

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

pip install -r requirements.txt
python -m nullres fetch --config configs/btc_4h.toml

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. 120 is the default, used when the config does not say otherwise.
  • close.shift(120) — the closing price 120 bars ago. shift looks 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 into 1.0 and 0.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:

from nullres.strategies.mine import Momentum

Then inside the REGISTRY = {...} block:

    "momentum": Momentum,

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:

python -m nullres run -c configs/null.toml --set strategies=momentum,buy_hold
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

python -m nullres run -c configs/btc_4h.toml --set strategies=momentum,buy_hold
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

python -m nullres budget -c configs/btc_4h.toml
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

  1. The buy_hold row. Did you beat it on sharpe? If not, stop.
  2. cost. The fraction of the account paid to the exchange. Above ~20% and you are trading too often, whatever the returns say.
  3. t-stat. Below about 3, the result is indistinguishable from luck. Both strategies above score under 1.
  4. 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:

python -m nullres run -c configs/btc_4h.toml --set params.momentum.lookback=240

To make a setting permanent, put it in the config file instead:

strategies = ["momentum", "buy_hold"]

[params.momentum]
lookback = 240

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:

python -m nullres audit -c configs/btc_4h.toml
python -m nullres robust -c configs/btc_4h.toml --strategy momentum

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