PyBroker is a Python framework for developing and backtesting algorithmic
trading strategies, with a focus on strategies driven by machine learning.
Its backtesting engine is built on NumPy and accelerated with Numba. Users
define per-bar execution functions that place orders through an
ExecContext (Strategy.add_execution); the engine replays historical
bars for multiple instruments, simulates fills with fees, slippage, stops,
and position limits through a Decimal-based Portfolio, and reports
results as TestResult DataFrames with bootstrapped confidence intervals
on the metrics. Models are trained and evaluated with Walkforward
Analysis: the data is split into successive train/test windows so models
only ever predict on bars that came after their training data. On top of
that sit user-defined and built-in indicators, hyperparameter optimization
(Optuna), multi-timeframe intervals, rotational trading, ranked position
sizing, disk caching of data/indicators/models, and parallelized
computation. Bar data comes from built-in data sources (Alpaca, Yahoo
Finance, AKShare) or any user-supplied DataFrame/DataSource.
Import name pybroker, PyPI name lib-pybroker. Version is
single-sourced at src/pybroker/__init__.py:__version__ (setup.cfg reads
it via attr:). Work integrates on dev; PRs target dev, and master
is the release branch. This
file governs changes to the core library (src/pybroker/) and the
distributable agent skills (skills/).
# Setup (once): Python 3.11+ venv, then editable install with test deps
pip install -e ".[test]"
# Tests (~5,000). Local venvs are gitignored (.venv*) — use a project venv
# on the tooling Python (3.12) if the checkout has one, e.g.
# .venv-bench/bin/python -m pytest.
python -m pytest # full suite
python -m pytest tests/test_<module>.py # one module (mostly 1:1 with src)
python -m pytest -n auto --dist loadgroup # parallel; keeps xdist_group pins (ray/loky)
python -m pytest -p no:randomly ... # deterministic order when bisecting failures
# Quality gates (tox envs defined in setup.cfg)
tox -e format # ruff format --diff — CHECK ONLY; `tox -e format -- src tests` to write
tox -e lint # ruff check src tests
tox -e typecheck # mypy on src (mypy version pinned in the tox env)
tox -e py311,py312,py313,py314 # full test matrix
# Benchmarks (asv; see Performance & Benchmarks)
asv run --quick # fast feedback, one sample per benchmark
# what the CI PR gate measures with; the block/pass decision itself is made
# by .github/scripts/asv_gate.py (blocks at 1.25x, but only above a 10ms
# baseline — see Performance & Benchmarks)
asv continuous dev HEAD --factor 1.1 --interleave-rounds
# Docs — CI runs `tox -e docs` on Python 3.12 (`[testenv:docs] basepython`);
# it is STRICT (`sphinx-build -n -W --keep-going`), so any warning fails the
# build. Deps come from requirements.txt. Run the same flags from a project
# venv on 3.12 instead of a bare `sphinx-build -b html`, which hides
# warnings that fail CI.
python -m sphinx -n -W --keep-going -b html docs/source/ docs/_build/- Never leak future bars. No negative indexing or backward shifts that read past the current bar; new indicators must join the no-lookahead sweep. (§ Lookahead-Bias Guardrails)
- Pandas only at the I/O boundary. Core computation is NumPy + Numba; six modules are pandas-free and must stay that way. (§ Pandas Boundary)
- Every compiled kernel is
@njit(cache=True). 87/87 today; zero exceptions. (§ NumPy + Numba Core) - Never widen, mutate, or copy the user's input DataFrame. Feature data stays numpy-backed and out-of-band. (§ Project Rules)
- Do not "clean up" mid-file or lazy imports. They break intentional cycles; E402 is disabled in ruff for exactly this. (§ Architecture)
- Decimal for money and share counts; float64 for everything vectorized. Quantize to cents only at the output boundary. (§ Money, Floats & Determinism)
- Identical results every run. Preserve every determinism rationale comment; never iterate an unsorted set into results. (§ Money, Floats & Determinism)
- Never
git stash; commit/push only when asked. Use a detached worktree for comparisons. (§ Project Rules)
The layering ladder below is also the module inventory of src/pybroker/:
L0 common, vect, parallel — import nothing from pybroker at runtime
L1 interval, log, config — import common only
L2 scope — common, interval, log
L3 cache, portfolio, eval, slippage, data
L4 model, indicator, optimize
L5 context
L6 strategy — the only module that may import everything
ext/data.py — common + data only (optional data sources)
- Runtime imports point downward only. If a change needs an upward
import, the code is in the wrong module — move it, don't import it.
(
common.pyhas oneTYPE_CHECKING-onlyfrom pybroker.strategy import Execution, which doesn't count — it never executes.) - Intentional cycle breaks — do not "fix" (as of 2.0.0):
- scope↔model: importlib indirection in
scope.py(_ModelImports) plus a mid-filefrom pybroker.scope import ...inmodel.py. - optimize↔strategy: mid-file import block in
optimize.pyplusTYPE_CHECKING-only imports ofstrategy. - scope↔model (function-local):
from pybroker.model import _lag_feature_colsinside a function inscope.py. - optimize↔strategy (function-local):
from pybroker.strategy import _DEFAULT_JSON_INCLUDEinside a function inoptimize.py. - portfolio→slippage and slippage→strategy are
TYPE_CHECKING-only. Ruff ignores E402 globally to permit these. Moving them to the top of the file creates real import cycles.
- scope↔model: importlib indirection in
- Public API: everything public is re-exported from
src/pybroker/__init__.pyvia theimport X as Xform; there is no__all__anywhere. A new public name means adding an aliased import there — that file is the export list. - Global state: module-level convenience functions (
param,register_columns,enable_*_cache,hyperparam, ...) delegate to theStaticScopesingleton inscope.py. Exception:set_parallelmutates module-level config inparallel.py. Custom data columns must go throughregister_columns;StaticScopefreezes columns while a strategy runs and keepsordered_data_colsdeterministic — never iterate the unorderedall_data_colsinto model input.
- Every compiled function is decorated exactly
@njit(cache=True)— no bare@njit, no object mode, no exceptions. Import onlyfrom numba import njit; the codebase uses noprange, nonumba.typed, noobjmode. Kernels live invect.py(indicators),eval.py(metrics), and a handful inmodel.py/scope.py/interval.py. - Boundary contract: only scalars, ndarrays, and
NamedTuples of float/int cross the njit boundary (canonical: the result tuples ineval.py). Never pass dicts, dataclasses, or Python objects. - Validate outside, index inside: njit kernels index without bounds
checking, so bounds validation and float64/C-contiguity coercion happen
in the Python caller (canonical:
_checked_stacked_lagsinmodel.pyand its docstring). - Numba semantics traps:
- Division by zero raises
ZeroDivisionErrorinstead of returning inf — guard divisors explicitly (seereturnvinvect.py). int(nan)yields INT64_MIN, not an error — an unchecked out-of-bounds-write hazard.- Numba's RNG state is separate from NumPy's — seeding must happen
inside compiled code (see
_seed_bootstrapineval.py).
- Division by zero raises
- Array discipline: preallocate with
np.empty/np.fulland index-fill; never grow arrays in loops. Prefer O(n) algorithms — the house patterns are monotonic-deque rolling min/max and Neumaier-compensated rolling sums (vect.py). - dtypes: float64 for numerics, int64 for indices,
datetime64[ns]for dates.
Pandas is an I/O format in this codebase, not a compute engine. It appears where user data enters and where user-facing results leave; everything in between runs on NumPy arrays and Numba kernels.
- Pandas-free modules (must stay that way):
portfolio,vect,cache,config,log,parallel. Litmus test: if your diff addsimport pandasto a module that doesn't already import it, the design is wrong — stop and restructure. - Sanctioned ingress (DataFrame → ndarray):
DataSource.query,Strategy._fetch_data, thescope.pyframe→SymbolArrayStoreconverters (symbol_array_store_from_frameand siblings), andindicator._to_bar_data. These are the sanctioned sites, not the only.to_numpy()calls insrc/—interval.pyandmodel.pyalso convert directly where a DataFrame is already their own local input. - Sanctioned egress (results → user):
Strategy._to_test_result(theTestResultframes),eval'sBootstrapResultframes,get_signals,ModelInput.to_dataframe(for the user'spredict_fn), andExecContext.input(). - Grandfathered interior uses — frozen. These exist, are closed to
extension, and are not precedent for new pandas: indicator values
carried as
pd.Seriesbetween compute andIndicatorScope.fetch(which converts to ndarray and caches);evaluate's immediateto_numpyingress;optimizeslicing walkforward windows as DataFrames;pd.isnain_is_rankable(strategy.py). Do not add to this list. Shrinking an entry toward pure ndarray is welcome only when results are bit-identical. - Never in the per-bar loop, in any njit kernel or its per-bar caller, or in per-symbol inner loops.
- Example code follows the same rule: indicator functions and
per-bar execution functions written anywhere — docstrings, docs,
notebooks, skill content, answers — are implemented with NumPy
(+
@njit), never pandas. Pandas is confined to the third-party TA wrapper boundary and thetrain_fn/input_data_fnmodel boundary. SymbolArrayStorehands out read-only views (buffers frozen withwriteable=False; custom__getstate__rebuilds views after pickling). Never flip the writeable flag — copy if you must mutate.
- The invariant: every array observable by strategy code is pre-sliced
array[:end_index](exclusive right bound at the current bar).ExecContextholds no arrays — every property fetches through the scopes with the symbol'ssym_end_index.ctx.close[-1]is the current bar precisely because the slice already happened. - Forbidden: negative indexing into full-length arrays (a negative
index silently wraps to the end of the series — the future); shifting
future values backward; any indicator whose value at bar
idepends on input at index >i. - Defensive patterns to imitate, not remove:
ColumnScope.fetch_valueraises onend_index <= 0and clamps overshoot instead of letting an index wrap.IntervalScope.completed_indexclamps rather than allowing a negative index to wrap to a future compressed bar.IndicatorScope.fetchraisesValueErrorrather than truncating an interval-bound indicator with a base bar index.
- The regression net:
test_indicator_does_not_look_aheadintests/test_vect.py(arguments in_indicator_args) runs every indicator kernel, bumps only the final bar, and asserts all earlier outputs are bit-identical — it caught a real negative-index wraparound bug inprice_change_oscillator. Every new indicator kernel must be registered in this sweep. - Walkforward boundary: the
lookaheadparameter enforcestest_start = train_end + lookahead; the history store spans train through end-of-test contiguously so lag-1 features never silently reachlookaheadbars back (strategy.py,_build_window_storescomment). Do not "simplify" the contiguity. - Legitimate patterns that are NOT lookahead (do not flag or "fix"):
post-backtest evaluation over the completed equity curve (
eval.py); lag construction shifting past→present (shifted[lag:] = values[:-lag]); sortedness checks (arr[:-1] <= arr[1:]);[-1]on already-truncated context arrays.
- Decimal is for money and share counts (
Portfolio,Order,Trade,Entry,Position,FeeInfo/fee_mode). float64 is for prices in transit, signals, scores, and all vectorized math. Convert withto_decimal(string round-trip); quantize to centsROUND_HALF_UPonly at the output boundary (common.quantize). ThePortfolio.capture_barpattern — float accumulation withmath.fsumoversortedsymbols, converted to Decimal once — is the template; don't invent new Decimal/float mixing. - Determinism is a shipped feature. Backtests must produce identical
results across runs and be independent of
PYTHONHASHSEED. House patterns: iteratesorted(symbols), never a raw set;math.fsumfor order-independent sums; stops sorted by monotonic id;-inf(never NaN) as an unrankable sort key; bootstrap defaultseed=42applied inside njit; Optuna samplers explicitly seeded (optimize.py). - The dense determinism rationale comments at these sites are load-bearing — never delete, shorten, or reword them.
- ruff format + check: line length 79, double quotes, 4-space indent,
target py312; lint select E4/E7/E9/F with E402 off (see Architecture).
mypy must pass on
src(tox -e typecheck).[mypy] python_versioninsetup.cfgis pinned to the tooling interpreter (currently 3.12), matching typecheckbasepython— not the matrix floor. Numpy 2.5 stubs use Python 3.12typestatements that mypy rejects whenpython_versionis 3.11, and 3.11 cannot install numpy 2.5.check_python_versions.pyenforces this; it moves when tooling moves. - Typing imports: take a name from
typingwhen it exists on the supported floor (3.11); reach fortyping_extensionsonly for features newer than that floor — today exactlyoverride(stdlib 3.12) andTypeIs(stdlib 3.13).typing_extensionsre-exports the stdlib object where one exists, so this is not a backport shim. It is a declared runtime dependency (install_requires), not a dev-only one. - Exhaustive
if/elifover anEnumorLiteralends with_unreachable_<what>: Never = <expr>before the existingraise ValueError(...). That gets mypy's exhaustiveness check while keeping the runtime error message users see —assert_never()would replace it with anAssertionError. Never do this inside an@njitkernel (seevect.py's_trend): Numba cannot compile it. @overridegoes on overrides of concrete base methods only. Abstract methods are already enforced by ABC. It matters most inslippage.py, whereapply_slippagehas a no-op default andis_fill_noopdetects overriding by method identity — so a typo there silently disables slippage.- Typing: pre-PEP-604
Optional[X]/Union[X, Y]is the convention (one existing exception:scope.pyhas a bareX | None), but modern builtin generics (dict[str, int],tuple[str, ...]);NDArray[np.float64]fromnumpy.typing;Finalfor module constants;# type: ignore[code]with the specific code named. - Containers by intent:
NamedTuplefor immutable records (and the only struct allowed across the njit boundary);@dataclass(frozen=True)for configs and cache keys; a mutable dataclass only when mutation is required; hand-written__init__for hot-path stateful classes (ExecContext,Portfolio, the*Scopeclasses). - Docstrings: Google style rendered by napoleon, with Sphinx roles
(
:class:`pybroker.scope.ColumnScope`); class-levelAttributes:sections on dataclasses/NamedTuples. Module header is two string literals — the module docstring plus a separate copyright literal (Apache 2.0 with Commons Clause) — keep both, in that order.
tests/test_<module>.pymostly maps 1:1 tosrc/pybroker/<module>.py, plus feature-focused suites with no single matching module (e.g.test_model_lags.py,test_model_per_bar.py). Shared fixtures live intests/fixtures.pyand are star-imported (from .fixtures import *— F403/F405 are per-file-ignored on purpose).- Golden numbers are computed, not hardcoded: recompute expectations
from the fixture DataFrame and compare against
round(x, 2)to match money quantization. Useassert_metrics_equal(tests/test_strategy.py) andassert_metric(tests/test_eval.py) — never==onEvalMetrics(NaN fields). - No network in tests. yfinance/alpaca are mocked; pinned pickles live
in
tests/testdata/(daily_1.pklis the canonical dataset, shared with the benchmarks). - Tests import private
_underscoresymbols frompybroker.*directly — that is the convention, not a smell. tests/conftest.pyforcesParallelConfig(n_jobs=1)(autouse) and gives each xdist worker its ownNUMBA_CACHE_DIR. JIT stays on — do not addNUMBA_DISABLE_JITshortcuts.- A new indicator needs golden-value tests plus registration in the no-lookahead sweep (§ Lookahead-Bias Guardrails).
- The asv suite lives in
benchmarks/(bench_backtest,bench_common,bench_data,bench_slippage; config inasv.conf.json). The PR gate compares against the PR's base branch, normallydev. Process doc:docs/source/benchmarking.rst. - Getting a number you can trust — the gate's thresholds are worthless if
the measurement is noise:
- Measure on an idle machine. A loaded workstation has produced a 40% spread on byte-identical work, and a 0.69–1.33× range on a comparison that read 1.22–1.29× on a quiet box.
- Interleave the arms and alternate which runs first, then report the median paired ratio and the win count — never the ratio of two medians. Running all of A then all of B has repeatedly produced phantom results in both directions.
cProfileis for ranking, not for shares of runtime. Its per-call overhead swamps cheap, frequent functions: it attributed 86.9 ms todict.getwhere the real cost was 11.6 ms (72.8 ns × 159,698 calls), so ~86% of that figure was the profiler. It also cannot see C-level work at all, soDecimalarithmetic is invisible inside its callers.- Prefer scenarios above ~0.3 s. Fixed costs dominate short ones: result assembly measured 29% of a 0.07 s run and 2.5% of a 0.48 s run.
- Attribute the win to a stage, not just the total. If overall time improves but the stage you changed did not, the gain came from somewhere else and the conclusion is wrong.
- Perf-sensitive change → run the relevant benches before and after:
asv continuous dev HEAD --factor 1.1 --interleave-rounds(a targeted--bench <pattern>pass first is fine) — this reproduces what CI measures, not the gate itself. CI blocks PRs on regressions > 1.25× and only once the benchmark's baseline reaches 10ms (GATE_MIN_SECONDS); shorter benchmarks are reported, never blocking — that floor exists because the shared runner has produced ratios from 0.65–1.29× on sub-2ms benchmarks of identical code. Everything > 1.1× is reported regardless of the floor. The block/pass decision is made by.github/scripts/asv_gate.py, invoked fromasv-pr.yml— read it before changing gate behavior. Override via thebench-overridePR label. New hot path → add a benchmark. WalkforwardColdintentionally includes Numba JIT compile time — it validates thecache=Truecontract. Never add warmup to it.- Ad-hoc JSON-baseline runners exist for targeted comparisons
(
scripts/bench_interval.py+.bench/timeframe-baseline.json, andbenchmarks/run_*.py); keep their baselines valid when touching those paths. - The CI Python matrix is single-sourced in
.github/python-versions.json(versions= the test matrix, the asv PR gate and the asv nightly;tooling= format/lint/typecheck/docs/sdist). Workflows read it withfromJSON();setup.cfg,asv.conf.json,pyproject.tomland.readthedocs.ymlcannot, so.github/scripts/check_python_versions.pyfails CI when they drift. Adding or dropping a version means editing the JSON and whatever that check reports — never a workflow literal. - CI surface not covered above:
.github/workflows/schedule.ymlis a nightly duplicate ofmain.yml;.github/actions/setup-pybroker/action.ymlis the composite both asv workflows use to set up a checkout;.github/scripts/asv_gate.pymakes the benchmark block/pass decision (see above). - Workflow security is gated by two tools, not by a script. The
workflow-auditjob in bothmain.ymlandschedule.ymlruns pinact — every externaluses:must be a full commit SHA carrying a trailing# <version>comment — and zizmor (tox -e zizmor, version pinned in the tox env) for everything else: token scopes, credential persistence, template injection. pinact runsno_apiand blocks on pull requests; the nightly instead runs itsverifypass undercontinue-on-error, because a pin merely behind its tag is safe and failing there would redden CI after every upstream release..github/zizmor.ymlholds the one ignored finding (use-trusted-publishing, blocked on PyPI trusted-publisher setup). Adding an action means pasting the tag, pushing, and pinning what CI reports — never hand-editing a SHA, which is Dependabot's job.
- Docstrings are the API reference (Sphinx autodoc) — write them to publication quality.
docs/source/reference/pybroker.strategy.rstcarries a hand-curated:exclude-members:list — update it whenever public dataclass fields change.- Build with
sphinx-build -n -W --keep-going -b html docs/source/ docs/_build/(see Commands) — the strict flags CI runs viatox -e docs. Any warning is a build failure; a baresphinx-build -b htmlwill not catch what CI catches. - An include-only
.rstunderdocs/source/is still discovered as its own document and ships as a<no title>page; add it toexclude_patternsinconf.py(.. include::still resolves it). - Never create or edit
docs/source/notebooks/*.ipynbunless explicitly requested — document in docstrings instead.
skills/pybroker-{strategy-creator,indicator-creator,model-trainer,optimize,multi-interval,rotational-trading}/
are distributable skills that teach downstream coding agents PyBroker usage;
users symlink them into their agent's skills directory
(docs/source/agent-skills.rst).
- Generated vs hand-authored.
references/wiki-*.md,references/api-public-surface.md, andreferences/pybroker_*.pyiare generated from the local notebooks and source — never hand-edit them; regenerate with a project venv on the tooling Python (3.12):<venv>/bin/python scripts/gen_skill_refs.py, thenruff format skills/*/references/*.pyi(the generator doesn't format its own.pyioutput), then verify with--check. Hand-authored:SKILL.md,assets/*_template.py, the*-patterns.mdreferences, theagents/openai.yamlinterface sidecars, andwiki-index.mdoutside the strategy creator's generated## User Guide Wikiblock. SKILL.mdOverviews ship to the docs verbatim.docs/source/agent-skills.rstincludes the slice between the literal headings## Overviewand## Workflow— keep both headings intact and put new guidance in## Implementation Rules, never in the Overview.- Shared skeleton & progressive disclosure. Every skill keeps the
frontmatter (
name+ trigger-phrasedescription) and the section orderOverview / Workflow / Implementation Rules / Common Deliverables / Resources.SKILL.mdstays lean; depth lives in the*-patterns.mdreference, routed viawiki-index.md→ smallest relevant wiki page.assets/*_template.pyis the executable embodiment of the rules — keep it runnable and in sync when rules change. - Project Rules apply to skill content (skills are public docs): no
competitor platform names; parallelism documented via
set_parallel(n_jobs=...)with Ray the only named backend; short-position docs show onlymargin/unrealized_pnl. - Non-negotiable requirements every skill must keep teaching in its
SKILL.mdrules, pattern reference, and template:- No lookahead in indicator logic: strictly forbid negative indexing
into full-length arrays (a negative index silently wraps to the end
of the series — the future) and backward shifts such as
shift(-1); a value at barimay depend only on inputs at indexiand earlier. (A per-symbolshift(-1)building the training target insidetrain_fnremains the sanctioned pattern.) Novel indicator logic self-tests with the bump-last-bar check: change only the final input bar and assert every earlier output is unchanged. - Never use pandas to implement indicator or execution logic:
indicator and execution-function logic is NumPy + Numba
@njit— nopd.Series/pd.DataFrameconstruction and no pandas calls such as.rolling/.ewm/.shift/.applyinside indicator functions or per-bar execution functions. The only sanctioned pandas: the minimal frame built at a third-party TA wrapper boundary (indicator skill), and thetrain_fn/input_data_fnframes PyBroker hands to model code. - Indicator output contract: a full-length one-dimensional array, one value per input bar, warmup left-padded with NaN — never a shortened array (pad third-party TA library outputs).
- Session hygiene: generated scripts start with
pybroker.disable_progress_bar()(progress output floods agent context) andpybroker.enable_data_source_cache("<name>")(orpybroker.enable_caches) so reruns do not refetch data; addpybroker.disable_logging()for many-backtest runs such asoptimize. - Numba debug toggle: on an
@njitcompile or typing error, re-run once with theNUMBA_DISABLE_JIT=1environment variable to get a readable Python traceback, fix the code, then remove the variable — never leave JIT disabled in a final script. Debug indicator failures serially beforeparallel_indicators=True(joblib wraps worker tracebacks). - Exact API shapes come from the bundled references: agents read the
matching
references/pybroker_*.pyistub andreferences/api-public-surface.mdinstead of guessing signatures, and use current API only (ctx.long_score/ctx.short_score;strategy.set_max_*_positions, not the deprecatedStrategyConfigfields). - Never widen or mutate the user's input DataFrame; feature data stays
out-of-band (work on a
.copy()insidetrain_fnwhen adding a target column). - Execution-function hygiene: guard lookbacks with
ctx.barsorwarmup=, and set at most one order side per symbol per bar. - Backtesting framework, not financial advice: state assumptions explicitly and make no performance claims unsupported by the produced backtest.
- Validation without network: syntax-check generated files, prefer
tiny local DataFrame fixtures for runs, and never assume optional
packages (yfinance, TA-Lib, ML libraries) are installed — name the
required
pip installs. - Machine-readable results: report
result.metrics_dfas the human-readable summary and teachresult.to_json()/result.to_json_str()(andopt.to_json()for optimization) as the structured output path, with theinclude=/max_rows=/symbols=controls — not a blanket replacement for the metrics print, since the default JSON payload is usually larger.
- No lookahead in indicator logic: strictly forbid negative indexing
into full-length arrays (a negative index silently wraps to the end
of the series — the future) and backward shifts such as
Any diff that moves a version constraint — requirements.txt,
setup.cfg, pyproject.toml, or a uses: ref in
.github/workflows/ — is triaged with the
dependency-migration-triage skill before it is judged. That includes
Dependabot PRs, a bump you are asked to review, and one you make
yourself. Green CI is necessary and not sufficient: it proves the
selected checks still pass, never that any of them was capable of
catching what changed. Do not eyeball a bump and call it safe; the skill
exists because the expensive findings hide in bumps everyone assumes are
boring.
Two things about this repo save a triage from rediscovering them:
requirements.txtgoverns only the docs builds —[testenv:docs]and.readthedocs.yml, both on Python 3.12, plus ahashFilescache key. It does not feed the test matrix.install_requiresinsetup.cfgis what users actually resolve,[testenv:typecheck]pins mypy exactly, and[testenv:lint]/[testenv:format]install ruff unpinned. So arequirements.txtfloor bump usually changes nothing CI does — always check whether the matchingsetup.cfgconstraint is the one that needed to move.- The floors are open-ended
>=, so a fresh install already resolves to the post-bump version. Reproducing a "before" arm needs==pins; without them both arms of a comparison install the same thing and the experiment measures nothing.
Numeric and compiled dependencies (numpy, numba, llvmlite,
pandas) carry one extra obligation, because determinism is a shipped
feature: prove results are bit-identical, not merely that tests pass.
Run a seeded backtest over tests/testdata/daily_1.pkl on both arms and
diff full-precision EvalMetrics plus hashes of the portfolio,
positions, orders, trades and bootstrap frames. Leave the performance
verdict to the asv gate — see § Performance & Benchmarks on why a number
measured on a loaded workstation is worthless.
- Never add columns to, widen, or copy the user's input DataFrame.
Feature and derived data stays numpy-backed, out-of-band (see the
model-input docstring contract in the
model()decorator's docstring inmodel.py). - API design: obvious names; no user-side assembly of intermediate
objects; reuse existing parameters before adding new ones;
predict_fnuses the trained model's own API. If correct usage would need a documented workaround, fix the API instead of documenting the workaround. - Never reference competitor backtesting platforms by name in code, docstrings, docs, or commit messages.
- Docs describe parallelism via
set_parallel(n_jobs=...)only; Ray is the only backend that may be named. PositionBarshort semantics:equityandmarket_valueswap roles for short positions; docs and examples show onlymarginandunrealized_pnlfor shorts.- Git: never
git stash(use a detached worktree for comparisons); commit/push only when asked; PRs targetdev.
Run these in order. "It compiles and the one test I wrote passes" is not done.
- Blast radius: enumerate every call site of each changed function
(
grep -rnacrosssrc/andtests/); trace consumers of changed return values. - Entry-point parity: confirm consistent behavior across
backtest,walkforward, andoptimize, and across pooled vs per-symbol model configurations. tox -e format(apply withtox -e format -- src testsif it reports diffs)tox -e linttox -e typecheck- Targeted tests, then the full suite:
python -m pytest - If indicators, scopes, or context slicing were touched:
python -m pytest tests/test_vect.py -k look_ahead - If perf-sensitive:
asv continuous dev HEAD --factor 1.1 --interleave-rounds— no regression > 1.25× at or above a 10ms baseline (the blocking threshold); investigate anything > 1.1×. - If the public API changed: export added in
__init__.py, docstrings complete,:exclude-members:inpybroker.strategy.rstupdated. - If
skills/, public signatures/docstrings insrc/, or the doc notebooks changed: regenerate withscripts/gen_skill_refs.pyon a tooling-Python (3.12) venv,ruff format skills/*/references/*.pyi, then verify with--check.