Signal
Quant & research
Research systems that don't lie to themselves: read-only ingest, regime classification, market memory and calibration. The backbone of XCAP.
SG-01 Read-only ingest & keyless edge
lesson Build a read-only ingest edge that pulls public world data to disk without ever exposing a single credential, and that is structurally incapable of trading or moving money.
Read-only ingest & keyless edge
lessonBuild a read-only ingest edge that pulls public world data to disk without ever exposing a single credential, and that is structurally incapable of trading or moving money.
A research system that touches the network everywhere is a system that can leak keys, fire off trades by accident, and contaminate results with non-reproducible data. The keyless edge is the boundary where the world comes in without being able to do harm.
THE LESSON
XCAP's core intuition is brutal and counterintuitive to anyone coming from SaaS: in a trading-research system, the network is not a convenience, it's the largest attack surface you have. Every keyed endpoint is a credential that can leak; every write call is a trade that can be triggered by a bug. That's why XCAP collapses its entire network exposure to a single point: a read-only, keyless, GET-only ingest edge. The rest of the system is deterministic from disk. Once you grasp this, you understand why `binance_public.py` only requests klines (historical OHLCV candles) over HTTP GET, with no API key, signing nothing, never touching an account or order endpoint.
Concretely, the pattern is: an abstract source (in XCAP, an `OHLCSource` hierarchy) with two implementations. `LocalCSVSource` reads CSVs already on disk — zero network, the default case, the one ALL automated loops use. And `HttpOHLCSource` (backed by `binance_public.py`), which only activates when a caller explicitly passes `allow_network=True` or invokes the `data-fetch` command. The asymmetry is deliberate: the safe path is the default and the networked path demands an explicit option on every call. Never a global ambient flag, never an environment variable that stays switched on.
The Binance endpoint you use is the public market one: `GET https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1d&limit=1000`. It returns an array of arrays: `[openTime, open, high, low, close, volume, closeTime, ...]`. Notice what is NOT here: no `X-MBX-APIKEY` header, no HMAC signature query param, no signed timestamp. That endpoint physically cannot read your balance or place an order, because Binance's account API lives on a different route and requires a signature. Security isn't your promise to behave well: it's a property of the endpoint you chose.
The second pillar is normalization at the edge. Real-world data arrives dirty: timestamps in milliseconds, numbers as strings, duplicate candles, gaps. Your edge must normalize to ONE canonical format before anything downstream touches it. In XCAP, the CSVs in `data/real` are lowercase (`open,high,low,close,volume`), timestamps parsed to dates, and `LocalCSVSource` reads them the same way every time. This discipline is what makes the system reproducible: the regime classifier (SG-02) and Market Memory (SG-03) read a stable format from disk, not a volatile HTTP response that changes between runs.
Verification is the closing move. XCAP has a `check` command (run with `PYTHONPATH=src python3 -m xcap.control check`) that asserts as an invariant that the connector is keyless, read-only, cannot trade, and cannot move money. It's not a test that passes once: it's an assertion that runs on every `check`, so that if someone adds a key or a write method, the `check` turns red. That's how you turn a design intention into a property the system defends on its own. The golden rule: security that depends on you remembering something is not security; security that a red test forces you to respect is.
An important operational nuance: the XCAP dashboard binds only to `127.0.0.1`, never `0.0.0.0`. The ingest edge is the only outbound surface; the inbound one (the dashboard) doesn't listen on the network. And FileVault (AES-256) encrypts the disk at rest. The full chain — encrypted disk, keyless read-only ingest, local-only dashboard, Capital Gate closed — is what lets you keep a trading-research system on a laptop without it being a ticking bomb.
EXERCISE
Implement a data source with two backends. Create `ohlc_source.py` with a base class `OHLCSource` (method `get(symbol, interval, limit) -> list[Bar]`). Implement `LocalCSVSource` (reads from `./data/{symbol}.csv`, no network) and `HttpOHLCSource` (GET to `api.binance.com/api/v3/klines`, with no API key whatsoever). The `HttpOHLCSource` constructor must take `allow_network: bool = False` and raise `RuntimeError('network not allowed')` if `get()` is called with `allow_network=False`. Normalize both to the same `Bar(time, open, high, low, close, volume)` structure with floats and a parsed date. Write `check_connector()` that verifies by introspection that the HTTP class has no method or attribute whose name contains 'order', 'trade', 'sign', 'key', 'secret', or 'withdraw', and that fails if it finds one.
DELIVERABLE
`ohlc_source.py` with `LocalCSVSource` + `HttpOHLCSource(allow_network=False)` by default, normalization to a canonical `Bar`, and a `check_connector()` function that asserts keyless/read-only by introspection and returns a PASS/FAIL report.
KEY INSIGHT
The safe path must be the default and the dangerous path must demand an explicit option on every call — never a global flag. An `allow_network=True` passed as an argument on every use is safe; an `ALLOW_NETWORK=1` environment variable that stays switched on is a leak waiting to happen.
MISTAKES TO AVOID
- ×Using an endpoint that requires an API key 'because it gives more data' — the moment you sign a request, your edge is no longer structurally incapable of trading, and the whole guarantee collapses.
- ×Putting network control in a global environment variable instead of an explicit per-call argument; it stays switched on between runs and the automated loops inherit network access unintentionally.
- ×Failing to normalize at the edge: letting ms timestamps and numbers-as-strings propagate downstream, breaking reproducibility between the local source and the HTTP one.
- ×Binding the dashboard to `0.0.0.0` to 'see it from your phone' — you expose all your research to the network; use `127.0.0.1` and an SSH tunnel if you genuinely need it.
- ×Verifying the keyless property once by hand instead of encoding it as an assertion in a `check` that always runs; the guarantee erodes on the first refactor nobody reviews.
SG-02 Price-regime classifier
lesson Build a pure, deterministic price-regime classifier that labels the state of the market (trend, volatility, behavior, stress) without ever sizing or trading a position.
Price-regime classifier
lessonBuild a pure, deterministic price-regime classifier that labels the state of the market (trend, volatility, behavior, stress) without ever sizing or trading a position.
Deciding without knowing which regime you're in is trading blind: a strategy that wins in a trend bleeds out in a range. The regime label is the honest precondition of any decision, and separating it from execution prevents the bias of 'wanting to trade' from contaminating the diagnosis.
THE LESSON
The conceptual error XCAP corrected in its June 2026 audit is subtle and worth its weight in gold: confusing operational health with price regime. `market_state.py` measures system health (do I have fresh data? are the flags right?). `regime.py` measures something entirely different: what STATE is the market itself in? They are orthogonal axes, and mixing them produces a classifier that doesn't know what it's saying. The first discipline is: a regime classifier speaks only of price, never of the state of your infrastructure.
XCAP's design classifies along four independent axes. Trend (up, down, sideways? — via the slope of moving averages or cumulative return over a window). Volatility (calm or agitated? — via the standard deviation of returns, normalized). Behavior (persistent/trending or mean-reverting? — via autocorrelation of returns). And Stress (is a drawdown developing?). Each axis is computed purely: same input, same output, no hidden state, no randomness. Then a documented priority cascade derives a single headline `Regime` from the four axes.
XCAP's sharpest insight is in how it defines Panic, and it's where almost everyone gets it wrong. The temptation is to define panic as 'high volatility' (an elevated stdev ratio). That's incorrect. A monotonic crash — price falling in an almost straight line — has LOW internal volatility, because each candle resembles the previous one. If you define panic by stdev, you miss exactly the most dangerous crash. XCAP's correct definition: panic is a large drawdown developing FAST, measured by a sharply negative recent return, not by dispersion. This is what distinguishes a classifier designed by someone who looked at real crashes from one that copied a formula out of a book.
Purity isn't an aesthetic whim, it's the foundation of trust. `regime.py` is a pure function: `classify(bars) -> Regime`. It doesn't read from disk inside itself, doesn't call the network, doesn't mutate anything global, has no random branch. This means you can run it a thousand times over the same window and get the same label, you can test it with deterministic fixtures, and you can audit exactly why it said 'Panic' on a specific date. A classifier with hidden state is a classifier you can't trust to build Market Memory on top of.
The most important rule, and the one that connects to the whole constellation: the classifier is label-only. It NEVER sizes or trades. It returns a label and nothing more. The reason is one of honesty architecture: if the same module that diagnoses the regime also decides position size, you have an incentive for the diagnosis to justify the trade you already wanted to make. Separating diagnosis from action is what makes it possible, in SG-03, to honestly measure whether the regime has predictive value — because the label was generated without knowing what you'd do with it.
To implement it well, document the priority cascade as readable code, not as a tangle of ifs. For example: if Stress triggers Panic, Panic wins over everything else; if not, and Volatility is high with a strong Trend, it's Trending-Volatile; if Behavior is mean-reverting and Trend is sideways, it's Range; and so on. Each branch must have a comment explaining the market reasoning, not just the threshold. That comment is what a reviewer — or you yourself in six months — needs in order to trust the label.
EXERCISE
Write `regime.py` with a pure function `classify(bars: list[Bar]) -> Regime`. Compute four sub-signals: `trend` (sign and magnitude of the return over the window), `vol` (stdev of daily returns), `behavior` (lag-1 autocorrelation of returns), `stress` (recent maximum drawdown AND speed = return over the last N days). Implement the priority cascade where Panic = large drawdown + sharply negative recent return (NOT high stdev). Return a `Regime` with the headline label and the four axes exposed. Test with three synthetic fixtures: a clean uptrend, a monotonic crash (must yield Panic despite low internal vol), and a noisy range. Verify the function is pure by running `classify` twice on the same input and asserting equality.
DELIVERABLE
`regime.py` with a pure, deterministic `classify(bars) -> Regime`, four axes (trend/vol/behavior/stress), a documented priority cascade, and three fixture tests where the monotonic crash is correctly classified as Panic.
KEY INSIGHT
Panic is not high volatility — a monotonic crash has low internal volatility because each candle imitates the previous one. Define panic by the speed of the drawdown (a sharp recent return), not by dispersion, or you'll miss precisely the most dangerous regime.
MISTAKES TO AVOID
- ×Mixing price regime with operational health in the same module; they are orthogonal axes and combining them produces labels that mean nothing concrete.
- ×Defining Panic by a high stdev ratio: you miss monotonic crashes, which are exactly the ones that matter.
- ×Putting a random branch or a disk read inside the classifier, breaking its purity and making it impossible to audit why it labeled a date.
- ×Letting the classifier size or suggest trades; the moment diagnosis and action share a module, the diagnosis starts justifying the trade you already wanted.
- ×Hiding the priority cascade in uncommented ifs without the market reasoning; no one will be able to trust or review the threshold in six months.
SG-03 Market Memory: forecast + calibration
lesson Build Market Memory: a ledger that locks in a falsifiable prediction BEFORE the outcome, resolves it against the realized return, and scores its own calibration (hit-rate, Brier, calibration by confidence and by regime).
Market Memory: forecast + calibration
lessonBuild Market Memory: a ledger that locks in a falsifiable prediction BEFORE the outcome, resolves it against the realized return, and scores its own calibration (hit-rate, Brier, calibration by confidence and by regime).
Without a record of predictions locked in before the result, a research system has no honest way of knowing whether it knows anything. It's the difference between 'I think it works' and 'here's my scored out-of-sample track record.' It's the only real source of evidence of edge.
THE LESSON
The central finding of XCAP's audit was devastating: every prior module was either stateless computation or a passive accumulator; NOTHING recorded a falsifiable prediction before the outcome and then scored its own calibration. That missing loop is exactly what makes a research system hard to fool, and the only honest source of evidence of edge. Market Memory (`market_memory.py`) is that loop. If you don't build this module, everything else is theater: pretty calculations that are never confronted with reality.
The heart is `advance_market_memory(universe, state_dir, horizon_days=5, ...)` and its sequence is sacred in this order: observe new dates → classify the regime (using the `regime.py` from SG-02) → record a LOCKED forecast (a baseline derived from the regime, with its probability and its horizon) → resolve the forecasts that have already matured against the realized forward return → score. The lock is the key word: once you record 'on date T I predict prob_up=0.62 over 5 days,' that record is immutable. You can't go back and touch it when you see the result. That immutability is what turns the ledger into evidence rather than after-the-fact rationalization.
The metrics aren't a single one; they're an honest dashboard. Hit-rate (what fraction of directional predictions were right?). Brier score (how well calibrated were the probabilities? — it penalizes both overconfidence and underconfidence). Calibration by confidence bucket (when you said 70%, were you right 70% of the time?). And the jewel: hit-rate BY REGIME. This last one is what truly informs: it tells you whether your prediction has value in a trend but is garbage in a range, or whether you only get it right in calm conditions. A global hit-rate of 55% can hide 70% in a trend and 40% in panic — and that decomposition is what tells you where, if anywhere, there's edge.
The anti-fabrication property is by construction (we go deeper in SG-04, but here it's structural): only forecasts whose horizon has already matured are resolved, with data that existed AFTER the forecast date. It's out-of-sample by design. You can never 'predict' a date whose result you already know, because the forecast was locked at T and is only resolved when T+horizon arrives with real data. The state dir is gitignored — the history isn't versioned, so there's no temptation to edit it by hand and dress up the results.
The three integrity properties XCAP enforces, and which you must replicate: it accumulates (the ledger grows between runs, never resets), it's idempotent (running the tick twice over the same dates doesn't duplicate forecasts or resolve them twice), and Gate-closed (it never trades). This is the same family of invariants as the capital invariant in SG-05: the system's state is the sum of real events, never a replay or a fresh baseline. The `market-memory-tick` CLI orchestrates this and reads `data/real`-format CSVs (lowercase) via `LocalCSVSource` — zero network, fully deterministic from disk.
A point that separates the engineer from the amateur: the baseline you record must be honest and modest. XCAP records a 'regime_baseline' — a probability derived from the regime, not an elaborate model you've already optimized by looking at the past. Why start humble? Because the baseline is your measuring stick. If a sophisticated forecaster can't beat the regime baseline out-of-sample, you don't have edge, you have overfitting. Market Memory exists to refute your ideas, not to confirm them. And a good forecast record is evidence, never proof — which is why the Capital Gate stays closed no matter how good the hit-rate looks.
EXERCISE
Build `market_memory.py` with an append-only JSON-lines ledger. Implement `advance(universe, state_dir, horizon_days=5)` that: (1) reads the new dates, (2) for each date classifies the regime with your `regime.py`, (3) records a locked forecast `{date, symbol, regime, prob_up, horizon, resolved: false}`, (4) resolves the matured forecasts (where the bar at date+horizon exists) by computing the real forward return and marking `hit`, (5) writes metrics: global hit-rate, Brier, and a hit-rate breakdown BY regime. Make the tick idempotent: if a forecast for (date, symbol) already exists, don't duplicate it; if it's already resolved, don't re-resolve it. Demonstrate accumulation by running the tick over two consecutive date ranges and showing the ledger grows without resetting.
DELIVERABLE
`market_memory.py` + an append-only ledger in `state/` (gitignored) with forecasts locked before the outcome, out-of-sample resolution, hit-rate/Brier/calibration-by-regime metrics, and an idempotent, accumulating `market-memory-tick` CLI.
KEY INSIGHT
The metric that truly informs is hit-rate BY REGIME, not the global one. A global 55% can hide 70% in a trend and 40% in panic — and that decomposition is the only thing that tells you where, if anywhere, there's real edge to exploit.
MISTAKES TO AVOID
- ×Recording the prediction and resolving it in the same run while already looking at the result; that's not a forecast, it's after-the-fact fitting, and it destroys all evidence of edge.
- ×Letting the ledger reset between runs; you lose the history that is precisely the asset, and you break the accumulation property.
- ×Making the tick non-idempotent: running it twice duplicates forecasts or re-resolves them, inflating or corrupting the metrics.
- ×Reporting only the global hit-rate and hiding the breakdown by regime, which is where the actionable information lives.
- ×Versioning the state dir in git, opening the door to editing the history by hand and dressing up the calibration; keep it gitignored.
SG-04 Anti-fabrication by construction
lesson Design the system to be structurally incapable of inventing data or edge: null controls, significance with multiple-comparison correction, out-of-sample evaluation, and an edge_demonstrated that is always False by default.
Anti-fabrication by construction
lessonDesign the system to be structurally incapable of inventing data or edge: null controls, significance with multiple-comparison correction, out-of-sample evaluation, and an edge_demonstrated that is always False by default.
The most expensive way to lie to yourself in research is to produce numbers that look like edge but are noise, drift, or data-snooping. Anti-fabrication isn't a final review: it's an architectural property that makes it impossible for a forecaster to claim victory without having beaten chance AND drift, with significance.
THE LESSON
The question that defines this module: how do you build a system that CAN'T lie to you about whether it has edge? XCAP's answer isn't 'be careful.' It's introducing structural adversaries — null controls — that every edge claim must compete against. The Hypothesis Lab (`hypothesis_lab.py`) has a registry of real forecasters (regime_baseline, trend_follow, momentum_follow, mean_revert) AND two deliberate null controls: `always_up` (the DRIFT null — the market rises on average, is your forecaster just capturing that?) and `coin_flip` (the CHANCE null — seeded and deterministic, do you beat a coin toss?). A forecaster that doesn't beat BOTH nulls doesn't have edge, it has an illusion.
The second pillar is significance with multiple-comparison correction. If you test twenty forecasters, one will look good by pure chance — that's the data-snooping problem. XCAP neutralizes it with a Bonferroni-corrected z threshold that SCALES with K (the number of forecasters evaluated): the more hypotheses you test, the higher the bar each one must clear. `evaluate_forecasters(universe, names, horizon_days, min_samples, alpha)` compares each real forecaster against both nulls (Δchance and Δdrift) and only grants `candidate_edge` if it beats chance AND drift AND clears the corrected threshold AND has at least `min_samples` observations. All four conditions, jointly. Removing any one opens a door to fabrication.
The detail that proves the system is honest and not rigged: `edge_demonstrated` is ALWAYS False. The lab can grant `candidate_edge` — 'this deserves more investigation' — but it never declares demonstrated edge, because no backtest, however good, proves future edge. This is a design decision that protects against your own optimism: the system literally has no code path that says 'yes, you have edge, trade.' The Capital Gate stays closed by construction, not by your discipline.
The verification that the scoreboard isn't rigged is elegant and you must replicate it: XCAP checked that on synthetic trends, `trend_follow` wins big (it detects the real signal), `mean_revert` goes significantly NEGATIVE (it detects an anti-edge — betting against the trend loses), and `coin_flip` lands at ~50% (chance isn't rewarded). If your coin_flip came out with a 60% hit-rate, your pipeline is broken or you have leakage. The nulls aren't just adversaries: they're calibration probes for your own measurement system. A coin_flip that doesn't give ~50% is a system that's lying to itself, and you must stop everything until you understand why.
The shared foundation prevents the trick of moving the goalposts: `scoring.py` is the single source of truth for the primitives (prob_up, is_hit, brier, conf_bucket, hit_rate_z). Both Market Memory (SG-03) and the Hypothesis Lab score with THE SAME code. If you had two implementations of 'hit', you could — consciously or not — use the more generous one for your favorite forecaster. A single shared primitive closes that door. This is anti-fabrication by construction: you can't pick the rule that suits you because there's only one rule.
Every evaluation is out-of-sample by the same mechanics as SG-03: each bar is classified once, the forecast is generated without seeing the future, and it's resolved against the real forward return. The `docs/HYPOTHESIS_LAB.md` doc and the 4 invariants of `check` encode these guarantees. The underlying lesson, which is the thesis of the entire Signal constellation: the binding constraint for trading real capital is DEMONSTRATED edge, and these tools measure edge honestly (null controls + significance) instead of asserting it. A system that can only emit `candidate_edge` and never `edge_demonstrated` is a system that respects what it doesn't know.
EXERCISE
Extend your Hypothesis Lab with `forecasters.py` (a registry with at least `trend_follow`, `mean_revert`, and two nulls: `always_up` for drift and a seeded `coin_flip` for chance) and `evaluate_forecasters(names, horizon_days, min_samples, alpha)`. For each real forecaster, compute its out-of-sample hit-rate and the delta against BOTH nulls. Implement a Bonferroni-corrected z threshold that scales with K = the number of forecasters. Grant `candidate_edge=True` only if it beats chance AND drift AND clears the threshold AND has >= min_samples. Set `edge_demonstrated=False` always. Validate your pipeline on a synthetic series with a trend: `trend_follow` must come out significantly positive, `mean_revert` significantly negative, and `coin_flip` must land at ~50% (if not, your measurement has leakage).
DELIVERABLE
`forecasters.py` + `hypothesis_lab.py` with two null controls (chance and drift), a Bonferroni z threshold that scales with K, a four-condition gate for `candidate_edge`, `edge_demonstrated` always False, and a validation where coin_flip comes out ~50% proving the scoreboard isn't rigged.
KEY INSIGHT
Null controls aren't just adversaries you compete against — they're calibration probes for your own measurement system. If your coin_flip doesn't come out ~50%, you have leakage or a bug, and you must stop EVERYTHING and understand why before believing any other number.
MISTAKES TO AVOID
- ×Comparing the forecaster only against chance and forgetting drift; many 'edges' are just the market's underlying upward bias in disguise.
- ×Failing to correct for multiple comparisons: test 20 forecasters and one will look significant by pure chance (data-snooping).
- ×Allowing a code path that declares `edge_demonstrated=True`; no backtest proves future edge, and that flag invites opening the Capital Gate prematurely.
- ×Having two implementations of 'hit' or 'brier' instead of a single shared primitive; it lets you pick the more generous rule for your favorite forecaster.
- ×Ignoring that coin_flip came out at 60%: that's not luck, it's leakage in your pipeline, and every other result from the lab is garbage until you fix it.
SG-05 Capital invariant
lesson Implement the capital invariant: a ledger where capital accumulates between sessions, only moves on REAL closes (realized P&L), and never resets — so that the number on screen always reflects closed results, not replays or paper gains.
Capital invariant
lessonImplement the capital invariant: a ledger where capital accumulates between sessions, only moves on REAL closes (realized P&L), and never resets — so that the number on screen always reflects closed results, not replays or paper gains.
A balance that inflates with unrealized P&L or resets between runs is a balance that lies. Capital integrity is the condition without which no other number in the system deserves trust: if the accounting can lie, so can everything else.
THE LESSON
This module is born from a real and painful failure in XCAP, which is why it teaches so well. A scheduled autorun was silently RESETTING the ledger to the initial balance of 500 and replaying the same identical fixture every 5 minutes. The system showed activity, numbers changing, it looked alive — and it was completely fake. Three rules were violated at once, and they're the three that define the capital invariant. Memorize these rules because they're the core: accumulate, realized-only, and re-entry continues previous sessions.
Rule 1 — Accumulate. Capital does NOT reset between runs or sessions. If you ended yesterday's session at 547.30, today you start at 547.30. A fresh baseline at every startup isn't accounting, it's a demo in disguise. The ledger loads from disk on startup and persists on shutdown; the state lives between executions.
Rule 2 — Realized-only. The balance only moves when a position is CLOSED/sold. Unrealized P&L (mark-to-market, the floating value of an open position) never inflates the balance. The exact invariant, which you must be able to assert at any moment: `current_balance == starting_balance + realized_pnl`. An open position with +200 of floating profit does NOT change the balance; it only does so when you close it and those +200 become realized. This avoids the oldest psychological trap in trading: counting your gains before you've banked them.
Rule 3 — Re-entry continues. On restart, the capital is the accumulated result of previous sessions' trades, not a new baseline. The system must be able to shut down and start up a thousand times with the capital always being the honest sum of every real close that occurred. This is the same as Market Memory's accumulation property (SG-03): the state is the sum of real events, one direction only, no replay.
The architectural lesson is the distinction between two commands XCAP keeps deliberately separate. The scheduled autorun runs `sim-autopilot-tick`: it loads the existing ledger, rotates synthetic regimes, and ACCUMULATES on top of what came before. There's also `sim-run-agent`, which resets and replays a fixture — but that one is kept ONLY for deterministic demos, and must NEVER be what the autorun runs. The bug was precisely pointing the scheduler at the wrong command. The lesson: a command that resets and a command that accumulates must never be confusable; name them so the dangerous one shouts what it does.
How this is hardened so it never breaks again: the invariant is LOCKED by `tests/test_sim_autopilot_tick.py`. If you touch the ledger or the autorun, those tests must stay green. It's not documentation, it's an executable lock. The operational rule the user imposed emphatically: if you touch the accounting, you run that test, and if it turns red, you've broken capital integrity and you stop. This is the same philosophy as the Capital Gate and the `check` from SG-01: the invariants that truly matter are encoded as tests that refuse to let you break them. XCAP's accounting isn't trustworthy because the author is careful; it's trustworthy because a red test stops him from being careless.
EXERCISE
Implement `ledger.py` with a balance persisted in `state/ledger.json`. Functions: `load()` (reads the accumulated balance, or the starting_balance only on the very first startup), `open_position(symbol, entry, size)`, `mark(symbol, price)` (computes UNrealized P&L but does NOT touch the balance), and `close_position(symbol, exit)` (realizes the P&L and ONLY then updates the balance, then persists). Maintain the invariant `current_balance == starting_balance + sum(realized_pnl)` and assert it after every operation. Write `test_capital_invariant.py` that: (1) opens a winning position, calls `mark` with a higher price, and verifies the balance did NOT change; (2) closes it and verifies it now DID rise by exactly the realized P&L; (3) simulates a restart (load after persisting) and verifies the balance continues, doesn't reset.
DELIVERABLE
`ledger.py` with an accumulating persisted balance + `test_capital_invariant.py` that tests the three rules: mark doesn't move the balance, close does move it by the exact realized P&L, and a restart continues the accumulated total instead of resetting.
KEY INSIGHT
The invariant `current_balance == starting_balance + realized_pnl` is a single line that, asserted after every operation, makes it structurally impossible for paper P&L or a replay to inflate your balance. When the accounting is a verifiable equation and not a belief, it stops being able to lie to you.
MISTAKES TO AVOID
- ×Resetting the ledger to starting_balance on every autorun startup — XCAP's original bug; it shows fake activity and erases all real history.
- ×Letting unrealized P&L (mark-to-market) touch the balance; you count gains you haven't banked yet and the number on screen lies.
- ×Confusing the command that accumulates (`sim-autopilot-tick`) with the one that resets-and-replays (`sim-run-agent`); name the dangerous one so it's impossible to point the scheduler at it by mistake.
- ×Touching the ledger or the autorun without running `test_capital_invariant.py` afterward; the invariant erodes on the first refactor nobody verified.
- ×Treating the invariant as a comment in the code instead of an assertion executed after every operation; a comment won't stop you from breaking it, an assert will.
SG-06 Autopilot ticks & honest loops
lesson Build autopilot loops that accumulate real learning between ticks — deterministic from disk, idempotent, Gate-closed — instead of generating noise or fake activity.
Autopilot ticks & honest loops
lessonBuild autopilot loops that accumulate real learning between ticks — deterministic from disk, idempotent, Gate-closed — instead of generating noise or fake activity.
A poorly designed autonomous loop is dangerous: it runs on its own, with no one watching, and it can reset state, duplicate records, or trigger real actions. The difference between a loop that learns and one that merely moves is the whole difference between research and automated theater.
THE LESSON
An autopilot tick is code that runs without a human watching, and that's exactly why it must be the MOST disciplined code in the system. The error XCAP suffered firsthand teaches everything: a scheduled tick that every 5 minutes reset the ledger and replayed an identical fixture. Seen from outside, the system looked alive — numbers moving, constant activity. Inside it was a lie on loop. The question that defines a good tick: does this loop, running a thousand times, ACCUMULATE something true, or does it only generate the appearance of activity?
Property 1 — Deterministic from disk. The tick reads its input from disk (`data/real` CSVs via `LocalCSVSource`), not from the network. XCAP's automated loops NEVER enable the network — `allow_network` stays False, always. This is a direct continuation of SG-01: the network is explicit opt-in per human call (the `data-fetch` command), never something an autonomous loop activates on its own. A tick that touches the network unsupervised is a tick that can leak, fail non-reproducibly, or hang waiting on a socket. Deterministic from disk means the same state on disk produces the same result, bold and reproducible.
Property 2 — Idempotent. Running the tick twice over the same dates must not duplicate forecasts, re-resolve what's already resolved, or count a close twice. This matters enormously in loops because schedulers fail, retry, overlap. If your tick isn't idempotent, a retry corrupts state silently. The way to achieve it: each event (a forecast, a close) has a natural key (date+symbol) and the tick checks for existence before writing. `advance_market_memory` and `sim-autopilot-tick` are idempotent for this reason.
Property 3 — Accumulate, don't reset. The tick loads the previous state and builds ON TOP of it. `sim-autopilot-tick` loads the ledger, rotates synthetic regimes, and accumulates — exactly the capital invariant rule from SG-05 and Market Memory from SG-03. This is the same invariant appearing for the third time in the constellation, and it's no accident: the honesty of an autonomous system IS its refusal to reset. A loop that starts from zero every time learns nothing; a loop that accumulates is the only thing that can build a calibration history worth anything.
Property 4 — Gate-closed by construction. The tick NEVER trades real capital. The Capital Gate (`capital/broker/live_trading/automation` all False) is verified by `python3 -m xcap.control check`. An autopilot is precisely where a trading system kills its owner: it runs on its own, and if it had the ability to trade live, a bug at 3am empties the account. That's why in XCAP the autopilot is sim-only, no exceptions, and `check` asserts it on every run. Automation and the ability to trade live are two things that must NEVER coexist without an extremely deliberate human decision.
How all this is operated and hardened: the tick runs via the scheduler (in XCAP, a scheduled task that invokes `sim-autopilot-tick`, the command that accumulates — NEVER `sim-run-agent`, the one that resets for demos). The behavior is LOCKED by `tests/test_sim_autopilot_tick.py`: if you touch the autorun, those tests stay green or you've broken the loop's integrity. And every run ends with a `check` that reasserts the invariants (keyless, Gate-closed, accumulation). The final principle, which seals the entire constellation: an honest loop is one whose guarantees don't depend on someone watching it, because they're encoded as tests and invariants the loop itself verifies on every pass. Build loops that refuse to lie even when no one is looking — that's the only automation a research system can trust.
EXERCISE
Build `autopilot_tick.py` with a function `tick(state_dir)` that on each call: (1) loads the previous state from disk (ledger + forecast ledger), (2) reads new bars via `LocalCSVSource` with `allow_network=False`, (3) advances Market Memory (locks new forecasts, resolves matured ones) accumulating, (4) runs `check()` asserting Gate-closed and keyless, (5) persists. Make it idempotent: run `tick` twice in a row over the same state and verify the second one adds or changes nothing. Write `test_autopilot_tick.py` that tests: idempotence (double tick = identical state), accumulation (two ticks over different ranges grow the state), and that `check` fails if someone sets `allow_network=True` or opens the Gate. Schedule the tick with a recurring task that invokes ONLY the command that accumulates.
DELIVERABLE
`autopilot_tick.py` (deterministic from disk, idempotent, accumulating, Gate-closed) + `test_autopilot_tick.py` that locks idempotence and accumulation, and a scheduler pointing at the command that accumulates — never the one that resets.
KEY INSIGHT
The accumulation invariant (don't reset) appears three times in Signal — capital, Market Memory, and autopilot — and it's no accident: the honesty of an autonomous system IS its refusal to reset. A loop that starts from zero every pass doesn't learn; it only simulates being alive.
MISTAKES TO AVOID
- ×Pointing the scheduler at the command that resets-and-replays (`sim-run-agent`) instead of the one that accumulates (`sim-autopilot-tick`); the exact bug that faked XCAP's activity.
- ×Letting the loop enable the network (`allow_network=True`); an autonomous tick touching the network can leak, hang, or fail non-reproducibly with no one watching.
- ×Making the tick non-idempotent: when the scheduler retries or overlaps, it will duplicate forecasts and corrupt the state silently.
- ×Giving the autopilot any ability to trade live without a deliberate human decision; automation + the ability to trade live is how a loop kills an account at 3am.
- ×Relying on human supervision instead of encoding the guarantees as tests and invariants the loop itself verifies with `check` on every pass.
Next constellation
Brand & Surface
Brand and surface