Architecture decisions
Some rules in this project are not conventions — they are invariants. A change that breaks one of them is wrong even if every test passes.
Each rule below is stated first, in plain language. Where a rule hides a mechanism, the mechanism sits in a collapsible region directly beneath it. Read the statement; open the detail when you need to know why, not what.
| Rule | In one line |
|---|---|
| No script ships unverified | A green pinets-cli run against a pinned dataset |
| Costs are declared | Every strategy() script sets commission_*, slippage and default_qty_* |
| Runs are deterministic | Two runs over the same pinned dataset produce byte-identical JSON |
| No AGPL source is vendored | AGPL tooling is invoked across a process boundary, never copied in |
| Known failures stay failing | A broken test is reported, never edited until it passes |
Pine invariants
1. No script ships unverified
No .pine file is delivered without a green pinets-cli run against a pinned dataset.
Acceptance means: exit code 0, plots and result.trades present, no error field, and
every value finite.
npx pinets-cli run app/strategies/ema_cross.pine \
--symbol BTCUSDT --timeframe 60 --candles 1000 --warmup 200 \
--format full --quiet --clean > out.jsonA script that has not been through this is a draft, not a deliverable.
Why no dataset hash is needed, and what actually provides determinism
A hash answers exactly one question: is this the same data? Determinism here is not about the bytes on disk, it is about the inputs being identical from one run to the next — and five mechanisms already guarantee that. A hash would have been a sixth, redundant one.
The honest accounting of the hash itself: the only hashlib use in the tree is
the Binance parquet cache filename — it is never compared or asserted, and no checksum
is recorded in config.yaml, summary.parquet or best.json.
orchestrator/sweep.py imports hashlib and never uses it. So the
documented mechanism is absent, and what enforces the invariant in practice is this:
- One dataset file per run. If the config sets
dataset, that file is used as-is; otherwise a seeded synthetic series is written once toresults/<run-id>/pinned_ohlcv.jsonand reused if it already exists. - The canonical
.pineis never mutated. Each cell runs from a copy in a temp directory, which is removed on both the success and failure paths. - Fixed, non-configurable run parameters.
candles=1000,warmup=200,format_full=True,quiet=True,clean=True,timeout_s=120are hardcoded — they are not in the YAML schema, so a config edit cannot change them mid-comparison. - Failures are recorded, not invented. A failed cell is stored with
ok=Falseand an error string; no metrics are fabricated for it. - Seeded regression tests.
test_strategy_is_bytewise_deterministicruns a script twice and compares the result while excluding metadata that is allowed to drift;test_fixture_is_deterministicasserts two seeded synthetic series are equal.
2. Strategy-mode scripts must declare their costs
Every .pine that calls strategy() must declare commission_*, slippage and
default_qty_* explicitly.
strategy("My strategy",
overlay = true,
commission_type = strategy.commission.percent,
commission_value = 0.1,
slippage = 2,
default_qty_type = strategy.percent_of_equity,
default_qty_value = 100)This exists because the engine’s defaults are optimistic. An undeclared cost model is the easiest way to turn a losing strategy into a winning-looking one.
3. Runs must be deterministic
Two consecutive pinets-cli run invocations against the same pinned dataset must produce
byte-identical JSON, modulo metadata. This is what makes a seeded optimizer regression
meaningful: if the baseline cannot be reproduced exactly, “the parameters still match” proves
nothing.
The same principle governs the browser optimizer. A five-trial TPE run at seed 42 is
recorded in tests/fixtures/optimizer-ui-baseline.json and is expected to reproduce the same
parameters, PnL, drawdown, trade count and win rate after any change to the surrounding UI.
Licensing
No AGPL source is vendored into this repository. AGPL-licensed tooling stays inside its own published package and is invoked across a process boundary — a CLI subprocess, or a browser-local bundle.
| Package | Licence | Posture |
|---|---|---|
pinets | AGPL-3.0 / commercial dual | Subprocess, or browser-local inside @luxalgo/vela-pinets. Never copied |
pinets-cli | AGPL-3.0 | Subprocess only — a CLI tool, not linked into our code |
@luxalgo/vela-pinets | AGPL-3.0 | Browser-only glue, loaded as a vendor bundle |
@luxalgo/vela | Apache-2.0 + attribution | Browser bundle; keep the visible attribution watermark |
monaco-editor | MIT | Vendor bundle; MIT notice retained |
echarts | Apache-2.0 | Full build vendored — the smaller common bundle lacks the monthly-performance heatmap. Licence text and NOTICE are both vendored |
@luxalgo/prop-firm-sim-core / -cli / -mcp | MIT | Subprocess / optional |
Everything authored under app/, pinets_cli/, orchestrator/, propfirm/, tests/ and
docs/ is MIT.
tests/test_license_hygiene.py enforces two of these mechanically:
docs/licenses.mdexists and mentions every npm dependency inpackage.json.- No file in the project tree contains the distinctive AGPL tokens
PineTSorPineTSContext— an approximation for “did we accidentally copy AGPL code”. False positives are acceptable; catching vendoring is the point.
What the AGPL token scan approximates, and the two ways it misses
tests/test_license_hygiene.py does not read licences. It scans the project tree for two
strings, PineTS and PineTSContext, and treats their absence as evidence that no AGPL code
was copied in. That is an approximation, and it is worth knowing precisely how it can be
wrong:
- The token is renamed. If the upstream project renames the symbol, the scan keeps passing while the copied code is still in the tree. It can only catch the names it knows.
- The path does not match. The scanned directory list includes
pinets-cliwith a hyphen, whereas the directory on disk ispinets_cliwith an underscore. That entry matches nothing and contributes no coverage, silently.
The scan also skips anything under a vendor/ directory — those are third-party
bundles by design — and skips the two test files that must name the tool. Those two skips are
accounted for by design: a vendored bundle is third-party code, and the tests have to name
the tool they are checking for.
Before any redistribution
For local personal use, none of that applies.
Known failures are left failing
There is an inherited assertion in app/__smoke_worker_real_run.cjs that expects
ctx.trades from getContext("main"), while production uses an explicit selection array.
It has not been “fixed”. The optimizer calculations were not changed to satisfy it, and the assertion was not weakened to turn the suite green. It is reported separately, because a test that was edited until it passed tells you nothing.
The same applies to the six broken orchestrator/hyperopt.py tests — they reflect API drift
against the installed Optuna version, and are reported rather than papered over.
Why an unfixed test is worth more than an edited one
A test that was edited until it passed has been made to agree with whatever the code happens to do. It still reports green, and it no longer reports anything at all.
An unfixed failure is different in kind. It is a standing, machine-checkable statement that two things disagree — the inherited assertion and the production code that is right today. It costs a reported failure, and it keeps its content until someone resolves the disagreement on purpose.
The run tooling reasons the same way. A failed cell is stored with ok=False and an error
string rather than being dropped, and no metrics are fabricated for it — a fabricated number
cannot be argued with, and a dropped cell cannot be diagnosed. Both are one rule: record what
happened, do not author what should have happened. That is why these failures are reported
rather than papered over.
Behavioural rules worth preserving
- Engine and synthetic results stay distinct. A UI change must never relabel a reconstructed trade list as a real fill. See Engines.
- Storage failure degrades, it does not break. If IndexedDB is full or unavailable, results stay usable in memory.
- Delayed writes cannot corrupt status. Saves are serialised so a late progress message cannot overwrite a terminal state.
- Selection follows the original trial index. Sorting or filtering the results table must not lose the user’s selection.
- Stopping keeps finished work. Stop terminates the run, retains completed trials, and excludes only the evaluation that was in flight.
- Duplicate starts are ignored. Starting a run that is already running is a no-op, not a second pool.
- Analysis starts nothing. The Analysis surface reads persisted runs. It never spawns a worker and never runs a backtest.
- Reset is scoped. Clearing stored state removes only this project’s keys and databases; an unrelated same-origin value is preserved.
- The design reference is not served. The
astra.cmdharness asserts the reference directory returns 404, so a design reference can inform the UI without shipping with it.
Two of those rules hide a mechanism. Open these before you rely on the behaviour.
Why selection is keyed to the trial index, not the row
A row’s position in the results table is a rendering decision. Sort by objective and the best trial moves; filter to a subset and the rows renumber. Anything keyed to the row would be describing the view, not the run.
The original trial index is the one handle that survives every view, which is why sorting and filtering cannot lose your selection. It is also the identity the run already carries: a failed cell is stored against the trial that produced it, and a stopped run retains its completed trials under the same indices.
What a stopped run guarantees
Stop is not a cancellation of the run’s history. It ends the run, retains every completed trial, and excludes only the evaluation that was in flight — that one is dropped rather than written, so no half-scored trial is left behind.
Preferring that to a clean exit state is the same choice made elsewhere on this page: keep what actually happened, and do not shape the record to look tidy. Completed trials are measurements, and the value of the run is what the pinned dataset and the fixed parameters produced. Throwing them away would trade a result you can check for a state that merely looks complete — and a state edited to look complete is the same failure mode as a test edited until it passed.
So a stopped run is a shorter run, not a wrong one. Read the trial count before you read the best objective.
Where these come from
The canonical statements live in the repository’s own docs — docs/architecture.md for the
invariants, docs/licenses.md for obligations, and the docs/*_WORKSPACE.md /
docs/OPTIMIZER_REDESIGN.md files for per-feature implementation and verification notes.