Skip to main content

Deterministic core for rule-driven trading agents: bar cache, walk-forward engine, RuleSpec strategy DSL, screener

Project description

nakagai

The deterministic, LLM-free core for rule-driven trading agents: a point-in-time bar cache, a statistically honest walk-forward backtester (look-ahead prevention, T+1 cash settlement, bar-permutation Monte Carlo), the RuleSpec strategy DSL, and a screener compiler.

What is here

  • data/: BarCache/MemoryBars over local parquet, the DataProvider contract and its Alpaca implementation (single-symbol and batched multi-symbol), and a sync routine that keeps the cache current.
  • engine/: the walk-forward backtester itself, point-in-time MarketContext assembly, T+1 cash settlement, run metrics, and the bar-permutation Monte Carlo null.
  • strategies/: rule-based (rules/), boolean-composed (composite/), and ICT-flavored (ict/) strategies, plus a catalog loader that turns JSON specs into strategy classes.
  • screen/: a conditions-only screener over the same RuleSpec grammar. Evaluation is deterministic and LLM-free; an optional English-to-spec compiler shares the nlbuilder extra with nlbuilder/, which installs anthropic.
  • nlbuilder/: English-to-RuleSpec compilation via the Claude API, behind the optional nlbuilder extra (installs anthropic).
  • stats.py: permutation p-values, bootstrap confidence intervals, and the decision-exact null harness for backtest results.
  • icir.py: rank-IC / IR of rule-spec margins vs forward returns (the informational ICIR lens).
  • filelock.py: cross-process advisory file locking for concurrent read-modify-write on shared result files.

Quickstart

This builds a BarCache, loads one of the shipped example strategies, runs the walk-forward engine over the cached window, and prints run metrics next to buy-and-hold. No network, no credentials, no optional extras, and it prints the same numbers every time: the engine's whole contract is that a backtest reads the cache and nothing else. Run it from the repo root with uv run python quickstart.py (or paste it into a REPL):

import tempfile
from pathlib import Path

import numpy as np
import pandas as pd

from nakagai.data.cache import BarCache
from nakagai.data.schema import TimeframeSet, validate_bars
from nakagai.engine.engine import Engine
from nakagai.engine.metrics import buy_and_hold_return, summarize
from nakagai.strategies.catalog import load_catalog

# 1. Generate a deterministic hourly series. Swap this block for
#    AlpacaProvider().fetch_bars("SPY", "1h", start, end) once you have
#    ALPACA_KEY_ID / ALPACA_SECRET_KEY; everything below is unchanged, which is
#    the point of the DataProvider seam.
rng = np.random.default_rng(0)
idx = pd.date_range("2024-01-01", periods=2000, freq="1h", tz="UTC", name="ts")
close = pd.Series(400 * np.exp(np.cumsum(rng.normal(0, 0.006, len(idx)))), index=idx)
prev = close.shift(1).fillna(close.iloc[0])
bars = validate_bars(pd.DataFrame({
    "open": prev,
    "high": np.maximum(close, prev) * 1.004,
    "low": np.minimum(close, prev) * 0.996,
    "close": close,
    "volume": 1_000_000.0,
}, index=idx))

# 2. Store it in a local BarCache: parquet on disk, offline after this.
cache = BarCache(Path(tempfile.mkdtemp()))
cache.upsert("SPY", "1h", bars)

# 3. Load a shipped example strategy from the catalog.
specs_dir = Path("nakagai/strategies/catalog/specs")
catalog = load_catalog(specs_dir)
strategy = catalog["sma_cross"]({})

# 4. Run the engine over the cached window.
tfs = TimeframeSet(driving="1h", deltas={"1h": pd.Timedelta(hours=1)})
engine = Engine(strategy, cache, "SPY", bars.index[0], bars.index[-1], tfs=tfs)
result = engine.run()

# 5. Print metrics next to buy-and-hold.
bh = buy_and_hold_return(bars, bars.index[0], bars.index[-1])
metrics = summarize(result, bh_return=bh)
print(f"trades: {metrics['n_trades']}, win_rate: {metrics['win_rate']:.2f}, "
      f"profit_factor: {metrics['profit_factor']:.2f}, total_return: {metrics['total_return']:.2%}, "
      f"bh_return: {metrics['bh_return']:.2%}")

Because the series is seeded, this prints the same line on every machine, which makes it a usable smoke test as well as an example:

trades: 25, win_rate: 0.32, profit_factor: 0.92, total_return: -1.27%, bh_return: -28.77%

A trend follower run on a random walk is not supposed to make money, and it doesn't. That is the example working, not failing: the engine's job is to tell you that honestly. Point step 1 at real bars to see something worth judging.

Two details of the generated series matter if you change it. Position size comes from risk_pct divided by the ATR stop distance, so a series with a low price-to-volatility ratio asks for more shares than equity0 can buy and every entry is skipped, which reads as a silent zero-trade run. And the bars are continuous hourly, with no session gaps, which is fine for the 1h driving timeframe here but is not what session-aligned daily logic expects.

The RuleSpec DSL

A RuleSpec is plain JSON: an entry condition tree for long and short, and a risk block for the stop and target. Conditions compare an indicator or price source against another indicator or a constant, with operators like crosses_above and crosses_below; all/any groups combine them into arbitrarily nested boolean trees. nakagai.strategies.rules.validate_spec is the single source of truth for the grammar, so a spec that loads has already been checked. Here is the shipped sma_cross.json example, abridged to the DSL fields (catalog card metadata like category and tags omitted):

{
  "title": "Moving average crossover",
  "description": "The classic trend follower: long when the fast SMA crosses above the slow SMA on the 1h chart, short on the cross down. ATR-sized stop, fixed reward:risk target.",
  "spec": {
    "version": 2,
    "name": "sma_cross",
    "timeframe": "1h",
    "long": {"all": [
      {"lhs": {"ind": "sma", "n": 20}, "op": "crosses_above", "rhs": {"ind": "sma", "n": 50}}
    ]},
    "short": {"all": [
      {"lhs": {"ind": "sma", "n": 20}, "op": "crosses_below", "rhs": {"ind": "sma", "n": 50}}
    ]},
    "risk": {"stop": {"kind": "atr", "n": 14, "mult": 2.0}, "target": {"kind": "rr", "rr": 2.0}}
  }
}

Two more examples ship in nakagai/strategies/catalog/specs/: rsi_reversion.json (mean reversion) and macd_trend.json (momentum). load_catalog(specs_dir) turns every JSON file in a directory like this one into a RuleStrategy subclass.

The lab

nakagai/lab/ searches strategy space and scores the winner honestly.

A trial is a mutated spec, not a parameter set: v2 specs declare no tunable params, so the tunable surface is the spec JSON itself. literal_trials moves the numeric literals inside one spec; composite_trials assembles catalog plays into composites. Every mutant is validated before it is returned.

A study runs a frozen trial set. N is fixed when the study is built and cannot grow, because the null below is computed for exactly that N.

The null is what makes a survivor mean anything. Running four hundred trials and keeping the best one finds noise with a good story; the fix is to replay the entire search on permuted bars and take the best across all trials, which gives the exact distribution of "best of N when there is nothing there".

cache must be built over the same bars as frames, i.e. cache = MemoryBars(frames); otherwise the observed statistic and the null are scored on different histories and the resulting p-value means nothing.

from nakagai.data.cache import MemoryBars
from nakagai.lab import (StudySpec, best_of_n_null, literal_trials,
                         run_study, study_verdict)

trials = literal_trials(base_spec, n=60, seed=7)
study = StudySpec(trials=tuple(trials), symbols=("SPY",),
                  windows=tuple(windows), seed=7)

cache = MemoryBars(frames)
observed = run_study(cache, study, registry)
nulls = best_of_n_null(frames, study, registry, n_permutations=200)
verdict = study_verdict(observed.best.pf, nulls,
                        n_trades=observed.best.n_trades)
# {"p_value": 0.015, "survived": True, ...}

n_trades is the WINNING trial's ledger, not the sum across the trial set. The verdict is a statement about one trial's PF, so the trade floor has to apply to that same trial: eight trials making five trades each sum to forty and sail past a floor of twenty, while the winner's own record is five trades and is noise.

The permutation count sets p-value resolution: 200 permutations resolve to 0.005. It is also the entire compute cost, scaling as trials x symbols x windows x permutations.

tests/test_lab_calibration.py is the module's real specification. It runs the whole pipeline on bars with no exploitable structure and asserts the p-values come out uniform, then runs it on bars with a real effect and asserts it is found. Run it with uv run pytest -m slow. The gate was measured at 24 replicates, 4 trials by 16 permutations: it took about 24 minutes and the mean p-value on pure noise came out 0.5074 against an expectation of 9/17 (approximately 0.5294) at this permutation count, while the positive control detected the real effect at the permutation resolution floor.

In CI, the gate runs automatically only when a change touches the lab or the core modules it depends on (see .github/workflows/calibration.yml); otherwise it can be triggered by hand via workflow_dispatch.

What is NOT here

This repo does not include the curated Playbook content (the hand-authored strategy specs), the evidence store and proving pipeline, the intraday scanner, or the hosted platform: API, web UI, and the mandate and approvals judgment layer. The hosted product at nakag.ai is built on top of this core.

Development

uv sync --all-extras
uv run pytest

uv sync --all-extras pulls in anthropic so the nlbuilder tests run too; the rest of the package works fine without it.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nakagai-0.1.0.tar.gz (186.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nakagai-0.1.0-py3-none-any.whl (103.1 kB view details)

Uploaded Python 3

File details

Details for the file nakagai-0.1.0.tar.gz.

File metadata

  • Download URL: nakagai-0.1.0.tar.gz
  • Upload date:
  • Size: 186.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for nakagai-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e394a01802cf38ad742c42fac9d401b11fc026cb7db6eafd545e0f785ca0f4d8
MD5 a22933617dab73d6a997494a2eeaa32c
BLAKE2b-256 6e5e1c463d35cd4e59dbde20b3840cb4b7ec99933360928543777a4064f980fa

See more details on using hashes here.

Provenance

The following attestation bundles were made for nakagai-0.1.0.tar.gz:

Publisher: ci.yml on loubylabs/nakagai

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nakagai-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: nakagai-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 103.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for nakagai-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5d88f172e341a874d8c23e89f4f9693442027fa47347580eea58ef0052bad4ce
MD5 94da84998c3d4a6c22296d1a21402643
BLAKE2b-256 a57988e8c3bcf70a72f384b46fffc42d8905f8fd8afb64fb3168ca551c671e3d

See more details on using hashes here.

Provenance

The following attestation bundles were made for nakagai-0.1.0-py3-none-any.whl:

Publisher: ci.yml on loubylabs/nakagai

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page