Daemon configuration and accounting¶
Configure the strategy, risk limits, ledger and account baseline here. For installation, deployment, daily commands and troubleshooting, use the daemon runbook.
Writing a live(...) config¶
A daemon-runnable .qe evaluates to a live(...) value. The base
is a backtest(...) block — byte-identical to what you'd run
offline — wrapped with the live-only knobs.
let base = backtest(
data = ibkr_stock("SPY"),
strategy = signal(
entry = cross_above(sma(close, 20), sma(close, 50)),
exit = cross_below(sma(close, 20), sma(close, 50)),
symbol = "SPY",
size = 100,
),
execution = execution(
capital = 100_000,
commission_bps = 0.5,
slippage_bps = 1.0,
fill_model = "next_open",
),
)
live(
base = base,
broker = "ibkr-paper", # always start here
symbols = ["SPY"], # superset of data symbols allowed
journal_dir = "/Users/me/.qe/state/spy_cross",
# IBKR Gateway connection (optional — all fields have defaults).
# Override host / port / client_id when running multiple daemons
# or a non-standard TWS/Gateway layout.
ibkr = ibkr_connection(
host = "127.0.0.1",
port = 7497, # 7497 paper TWS, 4002 paper Gateway,
# 7496 live TWS, 4001 live Gateway
client_id = 1, # bump if another QE binary uses 1
account = "", # "" = daemon picks first available
),
# Pre-trade risk — every limit defaults off.
# ALL FOUR ARE MANDATORY when broker = "ibkr-live": the daemon
# refuses to start with any of them unset or <= 0.
# ALL FOUR ARE POSITIVE MAGNITUDES; <= 0 disables the gate.
risk_max_position_per_symbol = 20_000, # USD notional, |pos| · mark
risk_max_gross_exposure_usd = 60_000, # Σ |price · pos|
risk_max_daily_loss_usd = 1500, # POSITIVE; trips kill-switch
risk_max_orders_per_minute = 10,
# The venue's own nightly outage, declared so it stops costing a
# restart. Both or neither; off by default.
maintenance_window_utc_minute = 1425, # 23:45 UTC
maintenance_window_minutes = 45,
)
The maintenance window is the knob that stops IB Gateway's nightly exit from killing the daemon. Gateway ends its session once a day; the watchdog escalates that to a one-way kill in 27 seconds, and a kill needs a restart plus a reconcile ack to clear — so without this a scheduled vendor event costs a manual intervention every night.
Inside the window a disconnect still soft-pauses, probes, journals
and counts misses. It just does not trip. Still down when the
window closes and it trips on the next poll: deferred, not cancelled.
Set it knowing it weakens the control that catches a real venue
outage, on a schedule, every day. See
the live(...) reference for the ranges.
The ibkr kwarg defaults so a minimal live(base, broker, symbols)
config still connects to a local paper Gateway. The daemon picks
port = 7497 when broker = "ibkr-paper" and 7496 when
broker = "ibkr-live" — override only if you're running a non-
default Gateway port or need a stable client_id.
Multi-leg + cross-sectional (signalize_universe)¶
The daemon's live engine handles N-leg portfolios — including the
cross-sectional shape signalize_universe(...) produces. Each
leg in cfg.base.strategy.strategies gets its own evaluator pair
and position state; the engine subscribes to the union of all
leg trade_symbols (plus any reference feeds in cfg.symbols).
For cross-sectional strategies that use is_top / is_bottom /
quantile, the engine uses a fork-join barrier at every bar
close (EPIC-69). Without the barrier, the first universe symbol
to close at a new timestamp would trigger the prepass against
1 fresh + (N-1) stale slots — picking arbitrary "winners" by
arrival order. With the barrier:
on_barfor each leg's symbol records the bar but defers prepass + leg evaluation.- When every universe symbol has produced a bar for the current
close_ts_ns(quorum), the engine flushes once: refreshper_symbol_ctxs_, prepass every leg, evaluate, submit orders. - If a new
close_ts_nsarrives while a prior batch is still pending, the prior batch is force-flushed with whatever it has (reason=new_ts) so the rebalance window isn't lost. - If a 5 s wall-clock elapses without quorum (one or more
symbols never ticked that interval), the batch flushes with
NaN-filled stale slots (
reason=timeout, WARN log). The prepass correctly refuses to fire trades on NaN-valued symbols — half-formed ranks don't drive orders.
Non-cross-sectional portfolios bypass the barrier entirely (per-bar fast path).
Daily-resolution strategies¶
For yahoo_template("1d", ...) strategies (typical
signalize_universe shape), the daemon's bar aggregator uses
BarAggregator::kDailyResolutionNs — bars close at session close
(16:00 ET) instead of every N nanoseconds. All universe symbols'
daily bars close within seconds of each other, so quorum is
reached and the rebalance fires in one batch around the close.
Orders submitted at close execute at next morning's open (DAY
order convention).
What the live engine reproduces from your backtest¶
A strategy is gated by a backtest and then traded by the daemon. Where the two size differently, the thing you deployed is not the thing you validated — and historically that difference has been silent. This table is the contract. Check any config against it with:
which loads the config, prints each leg's sizing mode and per-entry budget, and exits without taking the instance lock, opening the journal, or contacting the broker.
execution(...) field |
Live | Why |
|---|---|---|
capital |
✅ reproduced | Split into per-leg budgets by the portfolio's budget_weights (or weights pre-v0.3.0). |
whole_shares |
✅ reproduced | Floors each entry to an integer lot. |
entry_schedule / exit_schedule |
✅ reproduced | Staged slices fire through the order scheduler. |
compound |
⛔ rejected at load | The backtest scales orders by equity/initial; the live engine sizes from a static budget. Refusing beats trading a different book. Full support is a follow-up. |
commission_bps, slippage_bps, impact_bps_per_pct_adv |
➖ ignored, correctly | Backtest cost models. Live pays the venue's real costs. |
fill_model |
➖ ignored, correctly | Backtest fill convention. Live fills at the venue. |
record_forecast |
➖ ignored | Diagnostic only. |
signal(... size) form |
Live | Why |
|---|---|---|
scalar (size = 2.5) |
✅ reproduced | Fixed share count, capped by the leg budget. A size = 1.0 leg against a large budget is warned about by name at startup — that combination usually predates v0.3.0 sizing. |
BudgetSized (what signalize_universe emits) |
✅ reproduced | Spends the leg's whole budget at the entry mark. |
per-bar expression (size = 0.02 / rolling_vol(close, 20)) |
✅ reproduced (v0.3.1+) | Evaluated at entry time against the same context as entry / exit. Before v0.3.1 this silently traded one share. |
InverseVolSized (what signalize_universe(... weighting = "inverse_vol") emits) |
✅ reproduced | The allocator recomputes the weights each batch from weight_expr, so validate reports the capital and the allocator's bounds (top_k, max_weight, target_gross) rather than a static per-leg budget. |
| anything else | 🚫 cannot exist | The dispatch is an exhaustive std::visit over an overload set with no generic arm, so a new SignalSize alternative stops the build until every visitor handles it. It used to be a runtime else, which caught one site and let three others go stale. |
Indicator warmup is sized from your config¶
The fetch is derived, not fixed. apps/daemon/warmup_plan.hpp:120
takes min_history across every leg's entry, exit and sizing
expression — descending into cross-sectional sites — and
:164 converts that bar count into an IBKR durationStr with a
calendar buffer. sma(close, 50) warms fine, and so does a 50/200
trend overlay.
There is no budget to design inside, but there is a failure to know
about: a short delivery refuses the start. If the venue returns
fewer bars than the plan requires, the daemon logs the refusal and
exits 4 (apps/daemon/main.cpp:1596-1602). It does not start
degraded, and it does not run a leg on NaN. On a clean start you get
Run qe_daemon validate to see the requirement without a broker
— it prints what the config demands before you depend on the venue
supplying it.
This section previously said warmup was capped at ~41 bars from a hardcoded
"60 D", "1 day"fetch, and told authors to design around it. That cap is gone. If you cut a strategy down to fit it, the constraint no longer applies.
Picking risk limits¶
A real-money venue will not start without all four (EPIC-88
T88.6). broker = "ibkr-live" — or any broker name that does not
end in -paper — is refused at startup unless every one of
risk_max_position_per_symbol, risk_max_gross_exposure_usd,
risk_max_daily_loss_usd and risk_max_orders_per_minute is set to
a positive value, and the daily-loss one has a usable
execution(capital = ...) to anchor it. The refusal runs before the
daemon opens any socket to the venue, names every limit that is
missing, and exits 4.
The defect it closes (P3-10) is that all four default OFF and
ibkr-live was accepted with none of them armed. Nothing announced
that; the daemon connected, subscribed and would have discovered the
gap only by failing to reject the first order that needed rejecting.
An unset limit is not a wide limit — it is no limit.
Two escape hatches, both deliberate: a -paper broker gets a warning
listing the same gaps and starts anyway (a paper venue that refuses
half-written configs is a paper venue nobody uses), and
qe_daemon validate <config.qe> runs the identical gate without
touching the lock, the journal or the broker — so you can prove a
live config would be accepted before you point anything at IBKR.
Sign convention (EPIC-83): all four knobs are POSITIVE
magnitudes, and <= 0 disables the gate. The three USD knobs are
validated at load time — a negative value is a hard config error, so
qe_daemon start refuses rather than starting with a silently
disabled gate. This is a change: risk_max_daily_loss_usd used to
be documented as negative while the SafeBroker backstop underneath
read it as positive, so one value armed one layer and disabled the
other. Write 1500, not -1500.
risk_max_position_per_symbol is USD notional
(|projected position| × latest mark), not a share count.
A useful starting heuristic, for a single-strategy / single-symbol deploy:
- per-symbol position: 1.2 × the largest USD notional the backtest ever held in one symbol. Catches a runaway loop without rejecting legitimate fills.
- gross exposure: 1.5 ×
execution.capital. The same multiple works for short books because the check uses|pos · price|. - daily loss: roughly the 95th percentile of historical drawdowns in your backtest, as a positive dollar figure.
- orders/minute: 10 is comfortable for a daily/intraday strategy. Drop to 3 for once-a-day rebalances; raise to 60 if you're doing high-touch market making.
When in doubt: start tighter. The cost of a false reject is the order doesn't go out (the strategy will re-fire next bar). The cost of a false negative is a runaway loop on a real account.
Re-derive these after EPIC-83.
signalize_universelegs are now budget-sized at1/(top_k + bottom_k)of capital instead of one share each (seeqe-language.md). A book that used to run ~7 % deployed now runs ~100 % deployed, so a per-symbol cap that was dead headroom before is a live constraint now. Recompute both USD caps from the new intended notional, not from what the old deploy actually traded.The daily-loss gate deserves its own beat: on a book that was effectively 7 % invested it was unreachable in practice, and on most accounts it has never fired. At full deployment it is reachable — provided the ledger it measures is tracking the book, which on the live deployment it was not, so the breaker could not fire at all. That case has its own subsection below; check
daily_loss_statebefore you trust any of this paragraph. The kill-switch is one-way — a breach stops trading for the rest of the session and clearing it meansqe_daemon stop && qe_daemon start. Pick a number you are willing to be stopped out at.Know when it is evaluated: on every mark, since EPIC-88 T88.4. The equity poller re-values the strategy book every 30 s and tests the threshold there, so a position bleeding through the limit trips within one poll interval with no order in flight — a book the ledger holds, that is. What the poller re-values is the ledger, not the account, so shares the ledger never booked bleed invisibly at any interval. Before T88.4 the only test was at submit time, which on a
rebalance = 20deploy meant a breach could sit latent for ~20 sessions and then fire on the next order attempt, judged on the equity current then. The submit-time test inPreTradeRisk::check()(and the per-submitSafeBrokerbackstop) still run — they are now the backstop, not the whole mechanism.Three rules govern the continuous evaluation, all of them there because the kill is one-way:
- Two consecutive breaches trip, not one. A single bad valuation must not cost the trading day.
- Evaluation is skipped while the measurement is suspect — operator pause, watchdog soft-pause, or a failed broker poll. Nothing can be submitted in those states anyway, so a trip would only convert a reversible halt into an irreversible one. A skip does not clear an accumulated breach.
- "Cannot value the book" is not "no loss". A held symbol with no mark makes the book unvaluable; that neither trips nor clears. The daemon logs it at WARN on the edge, because a breaker that cannot measure is not a breaker.
qe_daemon statusstill recomputesdaily_loss_breachedon every poll with no side effects, andscripts/verify-open-check.shalerts on it, so a breach the two-breach rule has not acted on yet is still visible. The journal gets adaily_loss_breachrow on the first breach and another on the trip, carrying the P&L and the streak that the trip reason string (daily-loss-kill) cannot.
What the daily-loss number is measured against (EPIC-88)¶
risk_max_daily_loss_usd is a strategy-scale number. Both layers
that enforce it — PreTradeRisk (trips the kill-switch) and the
SafeBroker backstop (rejects the one order) — compare it against
this strategy's own marked-to-market P&L, anchored on
execution(capital = ...).
Until EPIC-88 they compared it against the whole account: net
liquidation value on one side, day_pl on the other. On the paper
account that meant a $15 000 threshold measured against ~$1 002 648 of
account equity, on a book whose worst possible day was ~$8 333 gross —
a breaker that could not produce a true positive, and could produce a
false one from FX revaluation or another strategy on the same login.
status reported daily_loss_armed: true throughout.
Three consequences to know:
- A daily-loss limit with no
execution(capital = ...)refuses to start. An armed threshold with nothing to measure is the defect this replaced; the daemon exits 4 and names the missing knob rather than starting inert. qe_daemon statusnow spells the state out.daily_loss_stateis one ofabsent(no limit),inert(limit configured, nothing measuring it),untracked(the ledger being measured is not the strategy's book — see below),blind(a held symbol has no mark, so the book cannot be valued at all),unanchored(scoped, but no session anchor yet) andarmed. Onlyarmedmeans the breaker can fire;daily_loss_armedis now false for every other one.daily_pnl_usd/session_anchor_equity/strategy_capital_usdare the strategy's;account_equity_base/account_daily_pnl_baseare the account's and arm nothing.
Those two are in the account's BASE currency, not USD, and
account_currency beside them says which (CAD on the live paper
account). They were named _usd until 2026-08-11 and nothing in the
tree converts anything, so the name was the only clue and it was
wrong. Note that account_gross_usd keeps its suffix and is not the
same unit: it is summed from position marks, and the positions are US
equities. Two of the three account figures are CAD and one is USD —
which is why they must not be compared or subtracted without an FX
rate the daemon does not have.
- untracked means the ledger is empty and should not be. An empty
strategy book values cleanly to its opening capital, so the day's
loss reads $0 forever and the kill cannot fire — which is what the
live daemon did for its whole deployment while holding ~$7,430 of
stock. It is raised either by a sell the ledger refused because it
held nothing to back it (strategy_unbacked_sells) or by a startup
comparison against the strategy's own holdings
(strategy_untracked_holdings, logged at ERROR). That comparison
reads two sources: the broker's book with your declared external
baseline subtracted, and this daemon's own journalled position rows
— the second because a baseline stale enough to claim the strategy's
own shares would empty the first. verify-open-check.sh raises it as
a problem, so it reaches the morning notification. The ledger is
fill-driven and cannot repair itself — it does not know the cost
basis of shares it never saw — so this state persists until the book
is rebuilt. qe_daemon book rebuild is that repair; see below.
- The strategy book is built from this process's own fills. It is
not seeded from broker positions — adopting share counts without the
cash that bought them would invent equity, and this number gates a
one-way kill. Since EPIC-89 T89.11 a restart restores the book and
its session anchor from the journal, as one unit, keyed to the session
they were written in: cash, shares and anchor together, or none of
them. So a daemon restarted mid-session keeps the day's loss instead
of re-anchoring flat.
Three residual cases. A checkpoint written under a different
execution(capital = ...) is refused, and the daemon measures from a
flat book until the next 16:00 ET roll — the pre-T89.11 behaviour,
now only on a config change. A daemon that dies between a fill
and its journal append restores the pre-fill ledger. Both are stated
at WARN on startup. Restarting outside market hours is still the
calmer option, but it is no longer the difference between a working
daily-loss guard and an inert one.
The third is the one that actually happened, and no restart fixes it:
a ledger that never held the fills in the first place restores
faithfully as empty and stays empty. The restore cannot tell "empty
because the strategy is flat" from "empty because these fills predate
the checkpointing that would have persisted them" — which is what the
live daemon was, for its whole deployment, while holding ~$7,430 of
stock. That is the untracked state above, and qe_daemon book
rebuild is the only thing that clears it.
(This bullet used to carry a second warning, that an evening restart also dropped the orders the close eval had queued. It no longer does — see staged entry / exit in the feature list below.)
Rebuilding the ledger — qe_daemon book¶
When daily_loss_state is untracked, the breaker is measuring an
empty ledger and the kill cannot fire. The journal is what repairs it:
every fill the daemon booked was written there by the same callback
that fed the ledger, from the same quantity and price, so replaying
those rows back through the ledger reproduces what it should have held.
qe_daemon book # what the ledger holds now
qe_daemon book rebuild # DRY RUN: reconstruction + a token
qe_daemon book rebuild --token=<t> --yes # commit it
The dry run writes nothing — not the ledger, not the anchor, not the journal — and is served on the read-only socket. The commit is not.
Six things to know before you run the commit:
- Today's P&L restarts from zero. The session anchor is adopted
with the book and becomes the rebuilt book's value at that moment.
It has to: leaving the anchor at
execution(capital = ...)would report the strategy's whole since-inception return as today's loss, which on a strategy that is down is a false trip on a one-way kill. Whatever this strategy made or lost earlier today is not recoverable — the ledger never saw it. - Run it with nothing in flight, before the open or after
qe_daemon cancel-all. A fill landing mid-rebuild would be booked into the ledger being replaced and lost, so the verb refuses while any order is working or any staged slice is pending — and refuses too if the order link cannot say. - It refuses a ledger that already holds something. This is a repair, not a re-baseline: replaying over a healthy ledger would discard whatever fills the journal no longer has.
- It refuses a ledger that has not lost the book — that is, one
where
daily_loss_stateis notuntracked. Empty is not lost: a strategy out of the market has a flat ledger that is entirely correct, and on one of those the replay changes no cash and no shares. The only thing left that the commit would do is move the session anchor, which forgives today's loss and hands the one-way kill a fresh full allowance — repeatably. The same refusal covers a replay that reproduces the current ledger exactly: nothing to repair, so nothing to commit. - It refuses a reconstruction worth $0 or less at current marks.
The anchor is that value, and a non-positive anchor is ignored by
the tracker (as it is by the restart path), so committing would adopt
the shares and leave the anchor where it was. A reconstruction worth
nothing means the journalled fills cost more than
execution(capital = ...): legs sized off the whole account, or another deployment sharing thisjournal_dir. - It re-runs the detector afterwards rather than declaring victory.
If the journal was missing the fills too,
untrackedstays lit and the reply says how many symbols are still unaccounted for. Treat that as "the breaker is still not measuring", not as a partial success. And iflist_positions()does not answer, the comparison is not re-run at all — with no broker side it would compare the journal against a book rebuilt from that same journal and come back clean by construction. The previous count stands, the reply says so, and a restart once the link is back is what re-measures it.
The token covers both the reconstruction and the ledger it would replace, and the commit recomputes it — so a token that was printed before a fill landed will not commit, and you get to read the new reconstruction first.
Order matters against the external-baseline cut-over in the next section: rebuild first, then adopt a baseline. The baseline verbs refuse against an empty ledger, because an empty ledger would attribute the whole account as external and let the strategy buy a second book on top of the one it already holds.
Whose shares the caps count — qe_daemon baseline¶
risk_max_gross_exposure_usd and risk_max_position_per_symbol are
sized from the strategy's own capital. Until you say otherwise they are
checked against the whole IBKR account, so anything you hold in that
account by hand counts against the strategy's caps.
Know the shape of the failure, because it does not look like one. Exits are unaffected — an exit reduces risk, the risk-reducing test is deliberately still account-scoped, and no cap ever refuses a sell for being over-scope. Buys are what get refused. The strategy therefore goes quietly sell-only while reporting healthy: no error, no kill-switch, just entries that stop appearing. On the live account on 2026-08-07 that was $10,986 of account measured against a $10,000 cap, $3,556 of it bought by hand.
A baseline is one number per symbol: how many of the account's shares
are yours. The daemon subtracts it from the account before the caps
measure, before the engine decides what it is allowed to sell, and
before either reconciler calls a difference drift. It is journalled,
survives restarts, and carries a revision number. With none declared —
the state every daemon starts in — nothing is subtracted and the
behaviour is exactly what it was before, which is why status calls
that state ⚠ ACCOUNT rather than something reassuring.
The order, and it is not negotiable¶
qe_daemon status # EXPOSURE block: which book the caps measure
qe_daemon book # is the ledger tracking? (previous section)
qe_daemon book rebuild # ...and repair it FIRST if it is not
qe_daemon baseline # what is declared right now
qe_daemon baseline propose # DRY RUN: the split, the totals, a token
# >>> read the table against TWS here <<<
qe_daemon baseline adopt --token=<t> --note="hand-bought FTNT/INTC" --yes
qe_daemon stop && qe_daemon start # so the ENGINE moves with the caps
Do the whole thing before the open, or after qe_daemon cancel-all.
propose and adopt both refuse while anything is working, because a
share that fills between the proposal and the commit lands in the
account before it lands in the ledger — and that difference is exactly
what the verb would attribute to you, permanently.
The restart at the end is part of the procedure, not tidiness. The caps pick the new split up immediately; the engine does not. Its legs were seeded at start-up through whatever split was in force then, so until it restarts it still believes it may sell shares the baseline has just declared yours. The report says so after every adopt. Restarting before the next rebalance is what closes it.
Reading the table¶
propose prints one row per symbol and derives ext = broker − strat,
floored at zero, where strat is the strategy's own fill-driven ledger.
Two of the eleven rows the live account produced, and its totals:
sym broker strat ext strategy $ external $ running $
FTNT 21 5 16 $798.20 $2,554.24 $4,019.75
INTC 8 0 8 $0.00 $813.20 $4,019.75
TOTAL $7,429.95 $3,556.11
gross $7,429.95 of a $10,000.00 cap — $2,570.05 of headroom
the account measures $10,986.06 against the same cap today
Check the strat column against TWS. Nothing checks it for you.
That is the whole reason propose is a dry run. The refusals below
catch a ledger that is empty or has holes; none of them catches a
ledger holding the wrong number of a symbol it does carry, and the
staleness auditor cannot see it either — a baseline derived from the
ledger agrees with the ledger by construction. Every share the ledger is
short becomes a share attributed to you, whose notional the strategy
gets back as headroom it can spend. A row annotated ⚠ ledger holds more
than the account or ⛔ the ledger claims the OPPOSITE side is telling
you the two disagree before you commit anything; read those rows first.
Two smaller things in that output. no mark on a row means all three
totals are lower bounds — the gate skips unpriced rows in its own gross
walk and these numbers do the same. And headroom on a thin name can be
a couple of dollars: the per-symbol cap is checked against the
post-fill line, which on the measured account left a $1,700 cap
admitting a buy at $1,698.03.
What each refusal means¶
propose (and adopt, and set) refuse rather than derive from
something they cannot trust. Exit code 3, and the message is the
diagnosis:
| The message says | What it means | What to do |
|---|---|---|
no strategy ledger — execution(capital = ...) did not scope one |
There is no strategy book to subtract, so broker − ledger would hand you the whole account. |
Fix the config, restart. |
could not list account positions |
The position link did not answer. Silence is not an empty account. | Retry when the link is back. |
the strategy ledger is EMPTY while the account holds N symbol(s) |
The dangerous state, and the one that matters. Deriving here attributes everything to you, the caps then measure $0, and the next rebalance buys a second full book on a cap that cannot fire. | qe_daemon book rebuild first — previous section. |
the journal's strategy_book checkpoint was NOT adopted by this process |
The checkpoint was written under a different execution(capital = ...), or is malformed, so the ledger holds only what this process filled. Editing the capital alone gets you here. |
Restore the capital the checkpoint was written under and restart, or book rebuild under the new one. |
journalled position rows say the strategy is in N symbol(s) its ledger is flat on |
The ledger has holes: this daemon's own journal recorded fills for a symbol the ledger shows nothing in. A symbol you hold and the strategy has never traded is not this — that one never wrote a position row, and declaring it is what the verb is for. | qe_daemon book rebuild. |
the ledger has refused N sell(s) it could not back |
The ledger has already said out loud that it is behind the engine, so its share counts are a lower bound. | qe_daemon book rebuild. |
could not list working orders |
Unknown whether anything is in flight. A leg that printed but whose execution report has not landed appears in the account and not in the ledger. | Retry when the order link is back. |
N order(s) working ... M staged slice(s) pending |
Something is in flight. | Before the open, or after qe_daemon cancel-all --yes. |
the risk gate's projected account book disagrees with the venue on X |
Nothing is working, so the two snapshots this baseline would be derived from describe different moments. | Re-run in a minute. If it persists, qe_daemon reconcile owns that disagreement. |
Two more on adopt alone. It recomputes the split and refuses a
token that no longer matches, so a proposal that went stale while you
were in TWS is refused rather than applied — re-run propose and read
the new table. And it refuses if its journal row did not reach the disk,
because a claim that is not on disk is a claim that disappears at the
next restart.
baseline set FTNT=16 INTC=8 --yes takes the same refusals. It
derives nothing, so the arithmetic argument does not apply to it — but
the hazard does, and a ledger that has lost the book is exactly the
state in which you cannot know what to type. Its counts are absolute and
replace the whole baseline: a symbol you leave out is a symbol you own
none of.
What the cut-over leaves behind¶
The daemon's journalled position rows record what its position tracker
held when each fill landed. Rows written before the cut-over are
account-scoped; the tracker is strategy-scoped after it. Nothing
rewrites the old ones — the journal is append-only, and rescoping
history is a migration this release deliberately does not attempt.
What you will see, on the restart at the end of the procedure and on
every restart after it: the startup reconcile compares each journalled
row against broker − your baseline, so on a symbol whose last row
predates the cut-over the two disagree by exactly the quantity you
declared and that raises a reconcile block. With the numbers above,
FTNT's row says 21 against an attributed 5.
That is not drift and there is nothing to repair. qe_daemon reconcile
prints the venue's own quantity beside the strategy's, so you can see
the block is the declared split rather than a real difference; confirm
that, then qe_daemon reconcile-ack <id> --decision=broker_is_correct.
The window is per symbol and closes at that symbol's next fill, which
rewrites the row in the new scope — not at the next restart. A symbol
you declared and the strategy then never trades keeps its stale row
indefinitely, and because an ack clears the block rather than the
condition, it blocks again at every start until it trades. Know that
before you decide the cut-over did not take. After the ack, qe_daemon
status keeps the RECONCILE section at ⚠ and lists what you
adjudicated on its acked line — that is the condition still standing,
not a second finding.
One narrower case, for completeness: if list_positions() fails at
startup the daemon seeds from those same journal rows and rebuilds the
account book as strategy + baseline, which on a stale row
double-counts. It errs restrictive — the caps over-state the strategy
— and PreTradeRisk's 60-second reseed corrects it once the link is
back.
Reverting¶
Immediate, in-process, no restart, and it takes no preconditions —
account scope is the state the gate has always been able to run in, so
the escape hatch works when everything else is refusing. The caps go
back to measuring the whole account, which is the state above,
so expect the sell-only behaviour back with it. The cleared claim is
journalled as cleared and is not resurrected by a restart;
re-establishing goes back through propose / adopt.
The engine, again, does not move until it restarts. After a disable
that lag is in the safe direction — the engine goes on treating only
the strategy's shares as sellable — so there is no hurry.
When baseline_stale fires¶
Each 60-second reconcile pass compares account − your baseline against
the strategy's own ledger. Two consecutive disagreements on a symbol
raise a baseline_stale finding, which is a reconcile block: like
drift, it stops all new submissions — entries and exits — until you
acknowledge it. qe_daemon status's RECONCILE block shows how many
symbols are stale now alongside the rising-edge total since start —
the two are separate numbers, so the section returns to clean when the
audit is running and the books agree again. When the audit is not
running it says BASELINE AUDIT NOT RUNNING instead, and that is not the
same claim: see the two silent states at the end of this section.
qe_daemon reconcile prints the finding.
It cannot tell you which side is wrong, and it does not pretend to. An undeclared hand trade and a fill the strategy's ledger never booked produce an identical signature — same symbol, same delta, same direction — and no number this daemon holds separates them. The finding prints all four quantities (venue, claimed, implied strategy share, ledger) and then sends you to TWS, because the venue is the only evidence that decides it.
Once you know which side is wrong:
- you traded it by hand → restate the claim with
qe_daemon baseline set ... --yes, listing every symbol you hold, not only the one that fired:setreplaces the whole baseline rather than merging into it, so a symbol left out silently becomes one you own none of.qe_daemon baselinefirst, to read what is declared now. Or skip the typing and re-derive the whole split withpropose/adopt, once the ledger is right; - the ledger is behind →
qe_daemon bookto read it, thenqe_daemon book rebuild; - either way,
qe_daemon reconcile-ack <id> --decision=resolved_externallyonce the two agree again — or--decision=accept_riskto resume without deciding. An ack releases the block and changes neither book.
It stays silent in two states rather than guessing, and neither is a clean bill of health: when no baseline is declared (there is no claim to audit, and the comparison would degenerate into the empty-ledger signal, which has its own detector) and when the ledger has declared it lost track (a book that says it is not tracking cannot testify about anything else).
Both states drop the stale-symbol count to zero, so whenever a finding
still stands, status says BASELINE AUDIT NOT RUNNING rather than
audit clear now — a zero it did not measure is not a zero it found.
Reaching that state from a live finding is easy and is on the documented
path: ack the block to resume, then baseline disable (or let one
unbacked sell flip the ledger to lost-track), and the disagreement is
still there with nothing watching it. qe_daemon book rebuild if the
ledger is what stopped; propose + adopt to re-declare the split.
"Still stands" is answered by the reconcile gate, not only by this
process's alert counter, and the difference is a whole class of
incident. No block is carried across a restart: the new process
re-measures, comparing a fresh list_positions() against the replayed
journal, and raises a new block only if the disagreement is still
there. So an unacknowledged block returns when the condition does — but
if that startup query comes back inconclusive, the reconcile is skipped
and the block does not return at all, having been neither cleared nor
confirmed. Ack it at 09:05 instead and the ack clears the block, not
the condition. In every one of those paths no reconcile pass in the new
process ever raised an edge, so the counter reads 0 — and if the ledger
also came up lost-track, every field that existed before this check
reads exactly like a healthy daemon. The daemon publishes the join as
baseline_audit_blind on status, the RECONCILE section refuses to
read clean while it holds, and the dashboard's F6 SAFETY row lists it
under UNMEASURED.