CLI reference
The Python track is a set of small scripts, each with an argparse surface. Run them from the project root — the strategy and dataset paths in configs resolve relative to the repo root, not your current directory.
All paths in a config resolve against the repository root via
(_ROOT / cfg.strategy).resolve(). Running a script from a subdirectory is
therefore safe.
What the rebase covers, and what it does not
Two keys, and only two: strategy and dataset. Each is joined to the repository root
before the cell runs — (_ROOT / cfg.strategy).resolve() — so a relative path in the YAML
is never relative to you. dataset: null has nothing to rebase and falls through to the
synthetic fixture.
examples/sweep_quick.yaml writes app/strategies/ema_cross.pine and
tests/fixtures/btc_1h_sample.json that way for exactly this reason. The keys you might
expect to be paths are not: asset is a label copied into each cell and timeframes is
the outer loop of cell expansion, and neither is passed to the CLI.
Pipeline at a glance
examples/sweep_quick.yaml
│
▼
sweep.py ──────────────► results/<run-id>/
│ ├─ cell_<i>.json
│ ├─ trades_<i>.csv
│ ├─ config.yaml
│ └─ summary.parquet
▼
rank.py ────► ranked.csv
report.py ──► report.html
derive_r_multiples.py ──► trades.csv ──► propfirm/evaluate.py ──► result.json
▲
propfirm/sweep.py ────────────────────────────────────┘orchestrator/sweep.py
Run a multi-cell backtest sweep.
| Flag | Type | Default | Notes |
|---|---|---|---|
--config | str | — | Required. Path to the sweep YAML |
--run-id | str | None | Optional run-id label; otherwise generated |
--dry-run | flag | False | Print the expanded grid and exit without running |
orchestrator/hyperopt.py
Bayesian hyperparameter search over a Pine strategy.
| Flag | Type | Default | Notes |
|---|---|---|---|
--config | str | — | Required. Path to the hyperopt YAML |
--trials | int | 50 | Trial budget |
--study | str | config file stem | Study name |
--resume | flag | False | Append to an existing study DB |
--run-id | str | None | Run label |
--dry-run | flag | False | Print the resolved config and exit |
orchestrator/rank.py
Rank a sweep
summary.parquet.
| Flag | Type | Default | Notes |
|---|---|---|---|
--summary | str | — | Required |
--metric | str | profit_factor | No choices= — any column in the parquet is accepted |
--min-trades | int | 1 | Drop rows with fewer trades |
--min-win-rate | float | None | Optional floor |
--max-drawdown-lt | float | None | Optional ceiling |
--require-positive-pl | flag | False | Keep only positive-P&L rows |
--top | int | 10 | Rows written |
--out | str | — | Required. Output CSV |
orchestrator/report.py
Build a self-contained HTML report.
| Flag | Type | Default | Notes |
|---|---|---|---|
--summary | str | — | Required |
--out | str | — | Required |
--metric | str | profit_factor | Applies to the top-10 bar and titles; the histogram is hardcoded to profit_factor |
orchestrator/derive_r_multiples.py
Phase-2 bridge: per-cell trades → an
evaluate.py-shaped CSV.
| Flag | Type | Default | Notes |
|---|---|---|---|
--run-dir | str | — | Required. A results/<run-id> directory containing trades_*.csv |
--cells-from | str | None | CSV whose cell_id column selects which cells to include |
--risk-pct | float | 0.01 | Assumed per-trade risk as a fraction of capital (0.01 = 1%) |
--out | str | — | Required. Writes open_time, close_time, side, r_multiple |
How the two tracks meet
One CSV with a fixed header. Phase-1 writes a trades_<i>.csv per cell into
results/<run-id>/. derive_r_multiples.py reads that run directory, keeps every cell or
only the ones named by the cell_id column of --cells-from, and writes
open_time, close_time, side, r_multiple — which is exactly the column set
propfirm/evaluate.py takes as --trades and propfirm/sweep.py takes as --from-run.
The bridge is a file format, not an API, so a column that drifts fails at the boundary
rather than halfway through.
Nothing but the trade list crosses it. The Phase-1 concerns — asset, timeframes, the
ranking metric — stay on the Python track. The Phase-2 scripts take a run directory, a
trades file and their own config, and nothing else; the r_multiple they read is already
computed from --risk-pct.
propfirm/evaluate.py
Prop-firm evaluation for a single challenge.
| Flag | Type | Default | Notes |
|---|---|---|---|
--trades | str | — | Required. CSV with open_time, close_time, side, r_multiple |
--firm | str | ftmo | |
--challenge | str | — | Required. Challenge id from the live directory |
--risk | float | 1.0 | Risk per trade, percent of balance |
--seed | int | 42 | Monte Carlo seed |
--out | str | — | Required. Output JSON |
propfirm/sweep.py
Fan per-cell Phase-1 trades across (firm, challenge) targets.
| Flag | Type | Default | Notes |
|---|---|---|---|
--from-run | str | — | Required. Phase-1 run dir containing trades_<id>.csv |
--config | str | — | Required. YAML with firms / challenges / risk / seed |
--cells-from | str | None | CSV with a cell_id column; default is every cell that has trades |
--out-dir | str | <from-run>/propfirm | Output directory |
--dry-run | flag | False | Print the expanded job grid and exit |
propfirm/rank.py
Rank a prop-firm sweep summary.
| Flag | Type | Default | Notes |
|---|---|---|---|
--summary | str | — | Required |
--metric | str | passProbability | Constrained to passProbability, evTotal, fundedProbability, win_rate, avg_win_R, avg_loss_R, n_trades |
--min-trades | int | 1 | Drop jobs whose source cell has fewer trades |
--require-positive-ev | flag | False | Keep only evTotal > 0 |
--top | int | 10 | |
--out | str | — | Required |
propfirm/report.py
Build a self-contained HTML report for a prop-firm sweep.
| Flag | Type | Default | Notes |
|---|---|---|---|
--summary | str | — | Required |
--out | str | — | Required |
--metric | str | passProbability | Constrained to passProbability, evTotal, fundedProbability |
Sweep config schema
orchestrator/sweep.py reads a flat, top-level mapping. There is no sweep: wrapper, and
unknown keys are silently ignored.
| Key | Type | Default | Meaning |
|---|---|---|---|
asset | str | BTCUSDT | Label copied into each cell. Not sent to the CLI when dataset is set |
timeframes | list | [60] | Outer loop of cell expansion. Also not passed to the CLI |
parameter_grid | name → list | {} | Full cross product over sorted(keys) |
strategy | str | app/strategies/ema_cross.pine | Resolved against the repo root |
dataset | str | null | None | Pinned OHLCV file. null ⇒ synthetic fixture |
synth_n | int | 1000 | Candle count for the synthetic feed |
synth_seed | int | 42 | Seed for the synthetic feed |
initial_capital | float | 10000.0 | |
commission_pct | float | 0.1 | Percent per side, charged on entry and exit notional |
slippage_pct | float | 0.05 | Percent, applied adversely on both sides |
ranking_metric | str | profit_factor | Sets the cell ranking, the CLI top-3 sort and the report bar chart |
robustness_filter | mapping | {} | Parsed and written back — never applied by sweep.py |
max_workers | int | 1 | 1 = serial loop; >1 = ThreadPoolExecutor |
Cell count is len(timeframes) × Π len(values).
Why the cell count is the check that matters
The product is over the inputs that were actually rewritten into the script, not over the lists in the YAML. Rewriting is a textual match that needs its single space — a declaration padded for alignment is skipped and keeps whatever value the source declares. The dimension collapses to one value instead of erroring, which is why the arithmetic still balances and nothing warns you.
The cross product runs over sorted(keys), so declaration order in the YAML is irrelevant,
and timeframes is the outer loop. examples/sweep_extended.yaml is the same schema at
432 cells (6 fast × 6 slow × 3 atr × 4 timeframes) with max_workers: 4.
A real example
examples/sweep_quick.yaml expands to 6 cells
(fast_len 2 × slow_len 2 × atr_len 1 × timeframes 1):
asset: BTCUSDT
timeframes: [60]
strategy: app/strategies/ema_cross.pine
dataset: tests/fixtures/btc_1h_sample.json
synth_n: 1000
synth_seed: 42
parameter_grid:
fast_len: [9, 21]
slow_len: [21, 55]
atr_len: [14]
initial_capital: 10000
commission_pct: 0.10
slippage_pct: 0.05
ranking_metric: profit_factor
robustness_filter:
min_trades: 5
max_workers: 1Grid rewriting matches input.int(…) / input.float(…) with a
single space before the parenthesis. A declaration padded to align
(atr_len = input.int(14, …)) is not rewritten, which is why
atr_len contributes only one cell in the example above rather than three.
examples/sweep_extended.yaml is the same schema at 432 cells
(6 fast × 6 slow × 3 atr × 4 timeframes) with max_workers: 4.
Hyperopt config schema
orchestrator/hyperopt.py reads exactly three top-level blocks: hyperopt:, sweep: and
search_space:.
hyperopt:
| Key | Default | Notes |
|---|---|---|
asset | BTCUSDT | |
timeframes | ["60"] | Only timeframes[0] is used |
strategy | app/strategies/multi_signal.pine | |
dataset | None | |
search_space_path | <config>.space.yaml | Only consulted when search_space: is absent |
objective | sharpe | One of sharpe, profit_factor, net_profit_pct, calmar, expectancy, sortino_proxy. An unknown name silently falls back to sharpe |
direction | maximize | Sets the Optuna study direction only — the score function always maximises, so minimize does not actually minimise |
train_frac | 0.7 | Train cutoff at round(bars × train_frac) |
initial_capital | 10000.0 | Falls back to sweep: |
commission_pct | 0.1 | Falls back to sweep: |
slippage_pct | 0.05 | Falls back to sweep: |
min_train_trades | 5 | Applied to the train partition |
min_validate_trades | 3 | Winner-eligibility gate |
min_validate_score | None | No threshold unless the key is present |
n_jobs | 1 | → study.optimize(n_jobs=…). YAML-only, no CLI flag |
seed | 42 | Sampler seed |
sampler | tpe | tpe | random | cmaes. Unknown ⇒ KeyError |
pruner | median | median | hyperband | none. Unknown ⇒ KeyError |
pruner_warmup_steps | 0 | Also gates whether pruning is reported at all |
pruner_interval_steps | 0 | Passed as max(value, 1) |
sweep:
Only three keys are read, and only as fallbacks: initial_capital, commission_pct,
slippage_pct. Everything else is ignored.
search_space:
Each entry is a spec:
| Form | Fields | Optuna call |
|---|---|---|
type: int | low, high (required), step (default 1) | suggest_int |
type: float | low, high (required), step, log (default false) | suggest_float |
type: cat | choices (required) | suggest_categorical |
type: bool | — | suggest_categorical(name, [False, True]) — a 2-way choice, not suggest_bool |
| anything else | — | ValueError |
Parameters are sampled in sorted() order, so declaration order in the file does not matter.
Prop-firm sweep config
Flat, single level:
| Key | Type | Default | Validation |
|---|---|---|---|
risk_pct | float | 1.0 | A fraction — 0.01 means 1% |
seed | int | 42 | |
max_workers | int | 1 | 0 coerces to 1 |
firms | list | [] | Each entry needs firm and a non-empty challenges list, or ValueError |
Running the pipeline
# 1. sweep (add --dry-run to print the grid first)
python orchestrator/sweep.py --config examples/sweep_quick.yaml
# 2. rank and report
python orchestrator/rank.py --summary results/<run-id>/summary.parquet \
--metric profit_factor --out ranked.csv
python orchestrator/report.py --summary results/<run-id>/summary.parquet --out report.html
# 3. hyperparameter search
python orchestrator/hyperopt.py --config examples/hyperopt_quick.yaml --trials 5
# 4. bridge to R-multiples, then evaluate against a challenge
python orchestrator/derive_r_multiples.py --run-dir results/<run-id> --out trades.csv
python propfirm/evaluate.py --trades trades.csv --firm ftmo --challenge <id> --out result.jsonStart with —dry-run. It prints the expanded grid without executing anything,
which is the cheapest way to catch a cell-count surprise.
What each --dry-run prints, and what it cannot tell you
Three payloads behind one flag name. orchestrator/sweep.py prints the expanded grid,
orchestrator/hyperopt.py prints the resolved config, and propfirm/sweep.py prints the
expanded job grid. npm run verify is the same idea as config parsing without executing.
All of them print and exit before a cell runs, so the printed count is the entire check:
compare it against len(timeframes) × Π len(values). A count that is too small means an
input declaration was padded and skipped. Nothing on that path can tell you a cell would
have succeeded, or what it would have produced.
Modules with no CLI
propfirm/firms.py is a library — import list_firms() / list_challenges() rather than
running it. pinets_cli/runner.py and pinets_cli/dataset.py are likewise imported by the
scripts above.
Fixture generator
tests/fixtures/generate.py regenerates the offline test fixture:
| Flag | Type | Default |
|---|---|---|
--n | int | 1000 |
--seed | int | 42 |
--start | str | 2024-01-01T00:00:00Z |
--out | str | tests/fixtures/btc_1h_sample.json |