Live (paper) trading
Live mode warms up the strategy with recent history, subscribes to the Alpaca real-time bar stream, and routes signals to the broker as bracket orders.
make live
# or
uv run python main.py live --strategy demo_trend --scanner demo_volume --symbols NVDA,META,TSLA
With PAPER_TRADE = True (the default) this trades the paper account. Set it
to False only when you intend to trade real money.
What happens on start
- The scanner picks the universe from your candidate symbols.
- The engine fetches enough history to make every indicator valid, and seeds the strategy's rolling buffers (warm-up).
- It subscribes to the live bar stream for every monitored symbol. The stream auto-reconnects with backoff if the socket drops.
- Each streamed OHLCV bar updates the strategy, which emits a signal.
- Actionable signals go to the
LiveTrader, which sizes the position and submits a bracket order (entry + stop-loss + take-profit) through the broker. - In parallel, the trade-update stream logs fills/cancels/rejects so you can see what the account is actually doing.
Press Ctrl-C to stop.
Order safety
- Entries are skipped while an order is pending for that symbol, so a repeated signal can't double-submit before the first fills.
- A discretionary close cancels the resting bracket legs first, so you're never left with an orphaned stop/take order.
- Orders are only sent during market hours (the clock is checked, with a short
cache; disable with
respect_market_hours=Falseif you need extended-hours).
Position sizing
By default each entry is sized by the strategy's risk-per-trade / stop-loss
config (RiskBasedSizer). Add --portfolio to instead size positions by
portfolio weights computed with the OR-Tools allocator — capital is shared
across the universe rather than sized per trade:
make live-portfolio
# or
uv run python main.py live --scanner demo_volume --symbols NVDA,META,TSLA \
--portfolio --max-positions 5 --max-weight 0.25
With --portfolio, only the symbols the allocator funds are traded; if OR-Tools
isn't installed or nothing is funded, it falls back to risk-based sizing. See
Portfolio allocation.
Or use --beta-sizing to scale each position inversely by its beta vs a
benchmark (default SPY) — higher-beta names get smaller positions, evening out
risk:
make live-beta
# or
uv run python main.py live --scanner demo_volume --symbols NVDA,META,TSLA --beta-sizing --benchmark SPY
Managing the account
make cancel-orders # cancel all open orders
make close-positions # liquidate all positions (also cancels orders)
The full real-time path is described in The Engine and Broker Abstraction.
Bar-quality guards
The live loop validates every bar before the strategy sees it. Guards are on by default — the live path is the only place a corrupt bar costs money.
| Check | Rejects |
|---|---|
| OHLC consistency | high < low, open/close outside the range, non-positive prices, negative volume |
| Ordering | a timestamp at or before the last accepted bar for that symbol |
| Staleness | a bar arriving more than ~3 intervals late |
| Spike | a single-bar move beyond --max-bar-return (default 35%) |
| Zero volume | no volume on a symbol that has traded before |
A guard rejects; it never repairs. Nothing is interpolated, gap-filled, or corrected. The moment the live path fixes its inputs it stops being the thing the backtest validated, and every historical result quietly stops describing what will happen. A rejected bar is skipped and logged with the offending values; the strategy simply never sees it.
The threshold is deliberately loose. A 35% single-bar move is news, and the strategy should act on it. The spike check exists to catch a decimal-point error or a crossed quote, not a violent day — a guard tight enough to catch every bad tick also removes the strategy's best opportunities.
At shutdown the loop reports what it discarded, and flags an elevated rejection rate loudly. A guard quietly eating a third of the feed looks, from the strategy's side, exactly like a quiet market.
python main.py live --symbols NVDA,AAPL # guards on
python main.py live --symbols NVDA --max-bar-return 0.15 # stricter
python main.py live --symbols NVDA --no-bar-checks # off (not recommended)
Position reconciliation
Orders used to be submitted and forgotten, so a partial fill, a rejection, or a position closed by hand in the broker's UI was discovered by reading the P&L and being surprised.
The live loop now keeps an append-only ledger of intent (what was submitted) and observation (what the broker reported), and sweeps it against the broker's actual account state on a timer. Check it any time:
python main.py reconcile # or: reconcile --json
RECONCILIATION FOUND 2 DIVERGENCE(S):
[quantity_drift] NVDA: ledger expects +10, broker holds +4 — likely a partial fill
[unexpected] TSLA: broker holds +7 that this ledger never ordered — opened manually
The broker's state is authoritative. Nothing has been corrected automatically.
Three rules govern it, and the first two are what keep it safe:
- The broker is authoritative, always. The ledger records what we believed so a difference can be noticed. When they disagree, the broker is right and the ledger is a question for a human.
- It reports; it never remediates. No corrective order is ever placed. An automated system that notices a missed fill and fixes it is one that can double a position at 3am while nobody is watching.
- Append-only. Entries are never edited or deleted, and the file is the state — a restarted process recovers its expectation by replaying it.
The sweep costs one list_positions call, never one per symbol, because it runs
inside the trade-clock loop. Exit code is non-zero when divergence is found, so a
scheduled reconcile can page you.
--no-ledger disables recording; --reconcile-every 0 disables the in-loop sweep.
Missed edges, and starting mid-trend
Signals are edge-triggered: an entry fires on the bar the score crosses and never
again. Live, that edge can be missed — a bar rejected by the quality guard, a dropped
stream, a restart, or a crossing that happened inside the warm-up history. The score
would still say "should be long" while every bar emitted HOLD, and the position was
simply never opened. The mirror case is worse: a missed exit leaves a real position
that nothing will close.
So the live loop compares the direction the score implies against the position book (kept in sync with broker truth) and re-states the difference. Where an edge says change, this says what should be true now.
One consequence worth knowing before you run it. If you start the engine while the score already implies a position — a trend-follower started mid-trend, say — it will open that position on the first live bar rather than waiting for the next fresh crossing. Stops and targets are computed from the current price, not the price at the original crossing. This is the default, on the view that a trend-follower started mid-trend should hold the trend rather than sit flat until the next crossing.
If you would rather wait for a fresh edge:
tradeflow live --strategy demo_trend --no-reaffirm-entries
or set reaffirm_entries: false in a strategy config. Exits are never gated by
it. Declining to open a position is a preference about what you trade; declining to
close one the strategy no longer wants is a stuck position, so a missed exit is always
re-stated whatever the flag says.
Backtests are unaffected either way: they derive the book from the same signals, so the two can never disagree there.
Why nothing happened
A signal that produces no order used to leave a log line and nothing else — and "no order" is the same outcome for the market is closed, we are halted, you already hold this, the size rounded to zero, and the broker refused. So the one question worth asking afterwards was answerable only from logs, if they still existed.
Execution now returns a decision for every signal, and the ledger records it:
{"event": "decision", "symbol": "NVDA", "signal": "BUY", "allowed": false,
"reason": "insufficient buying power: need $12400.00, have $9800.00",
"guards_consulted": ["hold", "market_hours", "halt", "existing_position",
"pending_order", "account", "sizing", "buying_power"]}
guards_consulted lists the guards that actually ran, not only the one that
fired — a list naming just the veto cannot distinguish a guard that passed from one
that never ran, which is how a check silently stops being applied and nobody
notices. Declined decisions are recorded precisely because they leave no other
trace.
Dry run: what would this trade?
tradeflow live --config configs/breakout.json --dry-run
tradeflow live --config configs/breakout.json --dry-run --json
Dry-run answers: what would this contract try to do right now? Small-real answers: what happens when the broker tries to do it?
Keeping those apart is the whole point. Observing execution used to mean running a paper session, and a paper session needs fills to observe — so the book's caps got shrunk until fills happened. A position ceiling small enough to guarantee fills biases the book toward low-priced names and turns every high-price signal into an invisible non-trade, so the sample you collect is not the strategy you validated. Measuring execution required trading a book nobody wanted to trade.
A dry run drives the real decision path — the same LiveTrader, the same guards, the
same sizing — against a stated capital with the caps exactly as configured, and reports
what would have happened.
=== DRY RUN — broker has no trading capability; no orders can be submitted ===
capital $8,000.00 (from config)
starting book flat
universe 4 symbol(s)
evaluated 3 (1 could not be evaluated)
WOULD SUBMIT — orders this contract would have sent: 1
AAA buy 25 @ 100.00 (stop 97.00 / target 106.00)
WOULD BIND — a configured cap refusing an order: 1
BBB book is full: 4 of 4
WOULD SKIP — no order, for a reason other than a cap: 1
CCC no signal
UNABLE TO EVALUATE — no decision was possible: 1
XYZ insufficient history: needs 102 bars, has 57
(Illustrative figures.)
The four buckets
They are the report's public contract, and they are distinct on purpose:
| Bucket | Meaning |
|---|---|
WOULD SUBMIT | The order path was reached. The plan shown is what would have been sent |
WOULD BIND | A configured cap refused it — the book is full, gross/net exposure or the risk budget is exhausted, or the order falls under the declared min_notional floor |
WOULD SKIP | Evaluated, and no order for some other reason: no signal, market closed, a position already open, or the size rounded to zero because the book cannot afford a whole share |
UNABLE TO EVALUATE | No decision was possible at all — usually too little history. Not a skip: a skip is an outcome the strategy reached |
That last distinction is why the summary line reports evaluated 3 (1 could not be evaluated). A symbol that vanished from the report is a symbol nobody notices was never
asked.
It cannot trade, structurally
The broker used here has no order methods that work — submit_bracket_order,
close_position and the rest all refuse. That is deliberately not a flag consulted on the
order path: a flag can be forgotten on one branch or inverted in a refactor, and an absent
capability cannot. It is the same guarantee the MCP server
gets from building only a data client.
The broker factory is never called at all, so a dry run needs no broker credentials — which is the point, since the mode is most useful before an account exists.
Capital must be stated
From the config being run, or --capital. There is no default and no fallback to a
broker's equity, and a dry run with neither refuses. The caps it reports are only
meaningful against the capital they bound, so inventing one answers a question about a
book nobody chose. The report names the source, so a reader can tell a validated capital
from one typed at the prompt.
What it does not cover
Fills, slippage, broker fees, queueing, and paper/live account effects. A dry run submits nothing, so it observes none of them — that is what a small-real session is for. Nothing is journaled either: a dry run measures nothing, so recording it would spend the family's multiple-testing budget on a rehearsal.
--dry-run refuses --live-money rather than ignoring it, and --json is refused
outside a dry run: a live session streams for as long as it runs and has no single report
to serialize.
Small-real: what happens when the broker tries to do it
tradeflow small-real --config configs/candidate.json --scale 0.05 --preflight
tradeflow small-real --config configs/candidate.json --scale 0.05
tradeflow execution-report --small-real
This is the one mode that places real orders. Everything else here reads or rehearses. A dry run is safe because trading is a capability its broker does not have; none of that transfers to a mode whose entire purpose is to reach a broker that really can trade, because broker fills, slippage and fees cannot be observed any other way.
It trades the validated contract at reduced capital, so the caps stay meaningful. That is the whole difference from the thing it replaces: measuring execution used to mean shrinking the book's caps by hand until fills happened, and a position ceiling small enough to guarantee fills biases the book toward low-priced names.
Which numbers move when the book shrinks
Fractions scale. Counts stay counts. Dollar strategy limits scale.
Venue floors stay absolute.
Each clause is a different unit, and applying one clause to another clause's limit is a distortion in one direction or the other:
| Limit | Unit | Under a shrink | Why |
|---|---|---|---|
max_gross_exposure, max_net_exposure, max_total_risk | fraction of capital | unchanged | they already scaled, because capital did — scaling them again turns a 0.80 gross cap into 0.04 |
max_positions | count | unchanged | this is the book's shape: how many names compete for one budget is what is being measured |
max_position_size | dollars | scaled | left alone it sits above the whole run and binds nothing, so the validated contract had a ceiling and the run would have none |
min_notional | dollars (venue) | not scaled | a broker's minimum does not get smaller because this run chose to |
The floor not scaling is a cost, taken deliberately. It refuses more orders at small capital, expensive names first — and share granularity refuses more still, because a book with a few hundred dollars a position cannot buy one share of a four-figure stock. Rather than pretend otherwise, the preflight reports the price above which a name cannot be traded at all, and both refusals carry a reason code so they can be counted. The bias becomes a number in the report instead of a silence in the sample.
The preflight, which cannot be skipped
=== SMALL-REAL PREFLIGHT — this run can place orders ===
broker mode PAPER
account equity $100,000.00 cash $97,000.00
validated capital $200,000.00 (from config)
this run deploys $10,000.00 (scale 0.05)
stated by --scale 0.05
Fractions scale. Counts stay counts. Dollar strategy limits scale. Venue floors stay absolute.
limit validated this run treatment
max_gross_exposure 0.8 0.8 fraction of capital — unchanged
max_net_exposure 0.3 0.3 fraction of capital — unchanged
max_position_size $10,000.00 $500.00 dollar ceiling — scaled
max_positions 8 8 count — unchanged
max_total_risk 0.05 0.05 fraction of capital — unchanged
min_notional $50.00 $50.00 venue floor — not scaled
a position gets about $500.00
so a name priced above about $500.00 cannot be traded here
at all. Those refusals are counted, not silent.
max loss envelope $500.00
if every open position stops out *at its stop price*.
A gap through a stop fills below it, so this is a floor on the
loss and not a ceiling on it.
universe 3 symbols (replayed from the config)
...
research journal untouched — this run records no trial and no search
This can place orders. Nothing below is a rehearsal.
(Illustrative figures.)
Every limit appears beside the validated one it came from, because the claim this mode
makes is that the proportions survived, and a column of scaled figures cannot be checked
against a claim nobody printed. --preflight prints all of it and starts nothing.
The size of the run is stated, never chosen for you
Exactly one of --scale (a fraction of the validated capital) and --capital (an
amount). There is no default: a mode that places real orders must not deploy an amount
nobody chose. Passing both is refused even when they agree — two sources for one number
is a thing to keep in step, and the arithmetic that checks they agree is the arithmetic
that would be wrong.
--scale needs a config that records the capital it was validated at, since otherwise
there is nothing to take a fraction of; the refusal names --capital as the way out. And
where no ratio is known, a dollar ceiling cannot be restated, so a config recording
no capital but declaring a max_position_size is refused rather than run with a limit
gone quietly inert.
It cannot be reached by composing live flags
small-real is a separate command, and its parser carries no cap override at all —
--max-position-size and the rest are not flags here, so argparse refuses them. An
absent flag cannot be forgotten on one branch or inverted in a refactor. There is no
--no-ledger either: a run whose purpose is to record what execution did has nothing
left if it does not record.
--config is required. Without it there is no validated contract, and scaling the
strategy class's defaults would preserve proportions nobody validated.
Paper by default; real money said twice, then confirmed
Paper is the allowed path and needs nothing extra. Real money needs PAPER_TRADE=false
and --live-money on the command line, because a default nobody set is
indistinguishable from a decision somebody made. --live-money against a paper
environment is refused rather than ignored — for the one mode that can lose money,
"you asked for real capital and quietly got paper" is not a state to enter.
Real money then needs --confirm, and the gate deliberately comes after the preflight:
agreeing to a contract you have not been shown is a formality, not a check. Paper needs
no confirmation — a gate on the run that cannot lose anything teaches the reflex that
makes the real gate stop working.
Telemetry, not a trial
Execution evidence goes to a small-real ledger of its own, separate from the live one. Every roll-up over a ledger is an average, and averaging a full-size book's fills with the same book's fills at a twentieth of it produces a number describing neither.
Each session writes a header first, recording the scaled contract, the capital and its
source, the broker mode, the account and what the run inherited. Without it a fill is a
number with no denominator — 40 basis points of slippage against what book, at what
size? execution-report --small-real prints that line before any number derived from
it, and warns when one file holds more than one session.
Nothing is journaled as a trial. A run that measures its own execution has searched nothing, so it must never count toward the multiple-testing total that the deflated Sharpe deflates against.
The universe is part of what was validated
--symbols and --scanner still work, and narrowing to one name is a reasonable thing
to want. But the symbols a config records are part of what its evidence covers, so the
preflight names where this run's universe came from and says plainly when it is not the
validated one:
universe 1 symbols (OVERRIDDEN by --symbols)
these are not the symbols the config records as validated,
so this run's evidence does not carry over to them
Allowed, never silent — the "validated contract" claim on the lines above would otherwise be read as covering the names too.
The account has to be able to fund it
Sizing caps at whatever the account holds rather than failing, so an account that cannot fund the scaled contract would quietly trade something smaller while the telemetry recorded the larger capital. Every field the cap applies to is checked — equity, cash and buying power — because the sizer sizes off buying power, and an account with ample equity and restricted buying power is the case that otherwise slips through.
A book this run did not open
The engine adopts whatever the broker already holds — a process that believes it is flat cannot exit a position it owns. But positions carried over from a full-size session are not this contract's book, and their exits land in this session's telemetry at the size they were opened at. Worse, if the adopted count already fills the scaled book, no entry can be admitted and the session measures nothing while looking like it is running.
The preflight says both, and the session header records what was inherited. It is reported rather than refused: on a restart those positions are this run's own, and nothing can tell the two cases apart.
It is deliberately not available over MCP
The MCP server builds only a data client, so it physically
cannot trade — and small_real is named in the forbidden list as well, because a list of
forbidden capabilities that omits the newest one reads as a list somebody checked.
Deciding to spend real capital is not a research step an agent takes on somebody's
behalf. An agent that thinks execution telemetry is worth gathering should say so and let
a person start the run.
Preflight: the contract before the order path
Every live run prints what it is about to do, before any order logic runs:
=== Live preflight ===
broker mode PAPER
account equity $100,000.00 cash $100,000.00
capital this run $25,000.00
universe 40 symbols (replayed)
data feed iex
max positions 8
max position size $2,500.00
max gross exposure 0.8 (80% of capital = $20,000.00)
max net exposure 0.3 (30% of capital = $7,500.00)
max total risk 0.05 (5% of capital = $1,250.00)
min notional $50.00
entries re-affirmed
bar guards on
reconcile every 300s
ledger ~/.tradeflow/logs/positions.jsonl
journal ~/.tradeflow/logs/research_journal.jsonl
halt state ~/.tradeflow/logs/halts.json
warm-up coverage 40 of 40 symbols have history
39 of 40 have the full 120-bar lookback (1 short)
Under --preflight the last line runs the same warm-up the live path runs and
reports what came back, so the one number that decides whether a run is viable can be
confirmed before dropping the flag. A lighter probe would not do: a preflight that
fetches differently from the run it precedes confirms nothing about that run. It
reports rather than refuses — the refusal belongs to the start path, and a preflight
that raised would lose the rest of the contract it exists to print.
Both counts are printed because presence is not sufficiency: a symbol can warm up with too few bars for its indicators to be valid, and a line counting only bars-or-not would read as a pass on a book that is not ready. A short warm-up still trades — it is a warning, not a refusal — so it is worth knowing which names are running on thin history before the flag comes off.
--preflight prints it and exits without starting anything. It is printed on every run
regardless, because a check you have to remember to ask for is one that gets skipped
exactly when it matters.
--capital — what this run may deploy
The two most important lines above are adjacent on purpose: a paper account arrives with whatever equity the venue handed out, and sizing against that trades a different book from the one that was validated. It does not merely flatter the result — it invalidates the execution telemetry, because fills, slippage and share rounding are all properties of a book at a size.
--capital (or the capital a saved config
carries) caps what the sizer may use. It is a ceiling, never a claim: a $25,000 config
on a $9,000 account deploys $9,000. Position limits expressed as fractions —
max_total_risk, max_gross_exposure — are fractions of that capital, not of the
account balance. Without it, sizing uses the whole account, which is the historical
behaviour.
--feed — which market data this run reads
The SDK's two halves default differently, and this is worth knowing before it bites:
a historical request resolves to the full consolidated tape, while the live stream
defaults to IEX alone. An account entitled to one and not the other warms up on nothing
and then streams perfectly happily — which looks like an empty market, not a wrong feed.
The symptom is subscription does not permit querying recent SIP data followed by
Fetched bars for 0/40 symbols, and then a stream that connects.
--feed (or ALPACA_DATA_FEED) pins both halves to one feed. Unentitled keys —
most paper accounts on the free tier — generally need --feed iex.
It is deliberately unset by default, and must stay that way. Pinning a feed
globally would mean an entitled account silently reading a single venue, or a tape
delayed by fifteen minutes, with nothing in the output to say so — the same class of
failure inverted, and far more expensive on real money. The preflight prints
SDK default (full tape for history, IEX for the stream) when nothing is pinned, so
the mismatch is visible before it costs a session.
Refusing to start blind
A run whose warm-up returned no history for any symbol now exits instead of
streaming. Every indicator would start from nothing, and from inside the bar loop that
is indistinguishable from a strategy that is not triggering — so the run looks healthy
for as long as you let it go. The refusal names the likely feed cause and both
remedies. --allow-blind-start overrides it.
Partial warm-up is not treated as blind: one symbol without history is logged per symbol and counted in a summary line, but does not stop a book that is otherwise valid.
Stating the book limits for this run
--max-positions, --max-position-size, --max-gross-exposure, --max-total-risk
and --min-notional set the limits the live book is held to, overriding both the strategy's declared limits and any a saved
config carries. They map one-to-one onto the preflight lines above, so what you typed
and what the run will enforce can be compared directly.
Only a flag you actually type applies. --max-positions carries a default, and letting
an untyped default overrule a frozen config would silently shrink the very book the
capital freeze exists to pin.
--max-gross-exposure and --max-net-exposure are not interchangeable, and a
long/short book wants both. Gross bounds long plus short; net bounds long minus
short. A book that is $4,000 long and $4,000 short has $8,000 gross and $0 net; one
that is $8,000 long has the same gross and $8,000 net, and only the second is a bet on
direction. Bounded by gross alone, a long/short config is either throttled or unhedged
and never neither.
The net cap is judged on the resulting |net|, so an entry that moves the book toward flat is admitted even when the book is already over the cap — refusing a hedge for being a trade would leave the tilt it corrects in place. It bounds magnitude, so 90% net short is capped exactly as 90% net long is. Off unless configured, like gross.
Two limits are stated in different units on purpose. --max-position-size is a dollar
ceiling on one position; --max-gross-exposure is a fraction of deployable capital, and
the preflight prints the dollars it works out to, because the fraction on its own is the
one number that cannot be sanity-checked by eye. A per-position ceiling larger than the
whole book is marked as not binding rather than printed as though somebody chose it.
--max-weight is not one of these. It is the largest weight the
allocator may give one name, so it does nothing without --portfolio.
A run that types it without the allocator is refused rather than started, with the
book-limit equivalent worked out for you — at --capital 8000, --max-weight 0.15
is --max-position-size 1200. --benchmark is refused the same way without
--beta-sizing: a flag that cannot reach anything stops the run instead of being
parsed and discarded, which is what makes a preflight worth reading.
Real money must be said twice
PAPER_TRADE defaults to true, which is the right default and precisely why the
check exists: a default nobody set looks identical to a decision somebody made, right
up until it is wrong. With PAPER_TRADE=false, live refuses to start unless
--live-money also says so on the command line — two independent statements of the
same intent, because one of them can be inherited from a shell nobody remembers
exporting.
Stopping a session
Ctrl-C and SIGTERM take the same path, and one of either is enough. That matters
beyond the terminal: SIGTERM is what a supervisor, a container runtime and plain
kill send, and a shutdown reachable only by a human at a keyboard does not exist
under any of them.
No bound scheduled on the event loop can be trusted on its own. Third-party code
called from a coroutine can block the loop, and when that happens every loop-scheduled
timeout fails at once — including the signal handlers themselves, which asyncio
delivers as loop callbacks. That is not hypothetical: alpaca-py's synchronous
stream.stop() submits a coroutine to the running loop and then blocks the calling
thread waiting for it, so calling it from inside that loop deadlocks until its own
timeout expires. Streams are therefore closed by awaiting the coroutine that wrapper
wraps, and a daemon watchdog thread — armed the moment a stop begins — force-exits if
shutdown has not completed in time. The watchdog is what makes "a stop signal always
ends the process" true rather than usually true.
A stream the drain gave up on is still a live object, and whatever collects it later —
possibly at interpreter exit, after everything has been reported — asks for a loop that
no longer exists. Python prints that as Exception ignored in: ..., which is alarming
punctuation at the end of an orderly shutdown, so those two specific finalizer errors
are dropped on the way out. Anything else still surfaces: suppressing more would hide
genuine faults in objects that happen to be torn down late.
Stopping is signal-driven rather than exception-driven. A bare KeyboardInterrupt
unwinds from wherever the interpreter happened to be, which is not necessarily a point
where the running coroutine's cleanup can finish; a loop signal handler delivers
cancellation at an await instead, so every finally on the way out actually runs. A
second signal restores the default handler, so an operator who wants out immediately
still gets out immediately. The streams are cancelled and given a
bounded moment to close; anything still running after that is reported as a count and
the process exits regardless. The budget covers the session's own unwinding too, not
only the tasks it started — a cleanup that blocks would otherwise hold the process
open exactly as an unbounded wait did, one level further in. A shutdown that can hang is one people learn to interrupt
twice, and the second interrupt lands during cleanup where it can interrupt anything.
The exit reports what was held from the ledger, not from the strategy's in-memory book. That book is a cache the reconciliation sweep rebuilds wholesale, and a sweep landing between an entry's submission and its fill leaves it short a position — which is how a stop summary came to list seven positions on a run whose every reconciliation agreed with the broker at eight. The line names which source it read, because a count with no provenance is what made the wrong number credible.
The exit does not claim anything about what it did not do. Nothing is flattened on the way out — positions stay open at the broker, and closing them is a decision, not a shutdown step.
Paper runs are never asked to confirm anything. Making them would train the reflex the guard depends on you not having.
One loop, one order at a time
The broker SDK is synchronous, and a single entry makes several blocking HTTP round trips. Those run in a worker thread rather than on the event loop, because running them inline stalls everything else the loop is carrying: other symbols that signalled on the same bar, the trade-update stream that delivers fills, and the reconciliation sweep.
There is still exactly one loop and one order at a time. Everything that reads the position book and then acts on it — entries, and the reconciliation sweep that replaces the book wholesale — is serialized behind a single semaphore. That is a correctness requirement, not a performance one: two entries checking the book at once can both pass a gross-exposure limit that only one of them fits inside, and a book assembled in a different order from the signals that produced it is not the book that was validated.
The threads exist only to keep blocking I/O off the loop. The trade clock's determinism comes from doing one thing at a time, and that has not changed.
What the ledger counts
The ledger replays fills to a per-symbol expectation and reconciles it against the broker, which is always authoritative. Two properties of that replay matter, and both were wrong once:
A fill quantity is the order's running total, not this event's increment. Alpaca re-reports the cumulative filled quantity on every partial fill and again on the final fill. Those events are therefore resolved to the last report per order id rather than summed — summing counts the same shares repeatedly, and an order that filled 8 across three reports arrives as 21. Collapsing per order also makes the replay idempotent: a duplicated event, a stream reconnect that repeats history, or a missed intermediate partial all land on the same answer.
The side comes from the broker and is never defaulted. A fill whose event carries no side is logged and dropped rather than guessed, because guessing records a short as a long and puts the ledger out by twice the position. A dropped record shows up as a visible divergence; a wrongly-signed one does not.
Bracket legs need no special handling: the entry and each protective leg carry their own order id, so a stop that fills nets against the entry it closes.
A fill teaches the book, not just the ledger. The same mistimed sweep that shortened the stop summary also left the strategy believing it was flat in a symbol it held — and a strategy that believes it is flat cannot emit an exit, so the position would have been closed only by its bracket legs. A fill now re-reads that one symbol from the broker and corrects the book. One symbol, on a fill: not a sweep, because this runs on the trade clock and must not scale with the universe.
A resumed session records what it adopted. Start-up hydrates the strategy's book from the broker, and that adoption is written to the ledger as a baseline — it replaces whatever the ledger believed about the symbol rather than adding to it, because the broker's holding at that moment is the whole truth about it. Without it the durable record disagrees with the book the process just adopted, and the next sweep reports every resumed position as one nobody ordered, which is noise exactly where a real divergence has to stand out. Trading continues from the baseline normally: later fills add to it, and an exit nets against it.
A ledger containing fill records written before this accounting existed is reported at reconciliation rather than silently reinterpreted — those records also defaulted every side to buy, so their numbers cannot be recovered. Archive the file and start fresh.
Execution quality
The ledger records enough to reconstruct the whole life of an order: what the strategy
decided, what was submitted, and what the venue did with it. tradeflow execution-report rolls that up; --orders lists every lifecycle, --json emits it
whole. Both are read-only.
=== Execution quality ===
orders 2 submitted, 0 never filled, 0 ended short, 2 filled across several prints
notional $458.73 submitted, $458.71 filled (100.0%)
slippage 2 of 2 fills measured
median +4.3 bps, mean +4.3 bps (positive = worse)
worst +4.3 bps (BBB), best +4.2 bps
decision to fill 2 measured, median 1,845 ms, worst 2,239 ms
modelled cost $0.16 over 2 orders (commission $0.05 + spread $0.11; excludes impact)
broker fees $0.03 over 2 fills
Signals that produced no order:
4 gross_exposure_capped
e.g. gross exposure capped: $21,140.00 of $20,000.00
1 book_full
e.g. book is full: 8 of 8 positions already open
There are no thresholds here, deliberately. What counts as bad slippage for a given strategy is not knowable from one session, so this reports numbers and declines to grade them. Collect a few sessions first.
How the numbers are built, and why:
Slippage is signed so positive is always worse. A buy that paid above its reference price and a sell that received below it both come out positive. An unsigned measure would let a good sell cancel a bad buy in any average taken over it. The reference is the bar close the signal fired at — the price the strategy actually decided on.
The join is derived on read, not written at fill time. Decision to intent by
decision_id, intent to fill by order_id. A process that dies between submitting and
filling still reconstructs, and nothing in the order path carries state to make the
arithmetic work.
"Ended short" and "filled across several prints" are different facts. An order that
filled completely across a partial print and a final one is not a problem; one that ended
short of what was asked for is. Counting only the second and calling it "partial" reports
0 partial for a session where every order took several prints — true of the outcome and
silent about the route.
Refusals written before codes existed are recognised by their message. The ledger is append-only, so its history stays on disk; a report that grouped only the rows carrying a code would show one throttle as two — a tidy family beside a scatter of one-off messages saying the same thing. A message the map does not recognise keeps its own text rather than being forced into a family it may not belong to.
Refusals group by kind, not by message. A message embeds the numbers that caused it —
gross exposure capped: $21,140.00 of $20,000.00 — so counting messages turns sixteen
refusals of one kind into sixteen rows of one, which hides a throttle rather than showing
it. Each family keeps one example, because the code alone does not say what the limit was
or how far over the book had got.
A modelled cost is never added to an observed fee. They are separate lines. A paper
account reports no fees at all, and None there means "not reported" — not zero, and it
must not be averaged as though it were.
live takes --gross, --commission-bps, --impact-eta and --borrow-bps, the same
flags the research commands take, and a saved config's cost block fills them in the
same way. It prices nothing — the venue does that — but recording what the model
expected a fill to cost beside what it actually cost is meaningless unless both sides
came from the same parameters. The preflight prints them, marked (recorded, not charged), because a cost model that silently was not configured is exactly what that
line exists to catch.
The estimate comes from the same cost model the research clock charges, built from the
same --commission-bps / --impact-eta / --borrow-bps a config carries, so the
modelled number in a live report is comparable with the one a backtest was judged on. A
second, live-only cost formula would make that comparison meaningless in a way nobody
would notice. Market impact is excluded and said to be excluded: it is a function of how
much of a day's volume the order demands, and the trade clock has no ADV for a symbol at
the moment it sizes one.
Every summary says what it could not measure. A fill with no recorded price counts as unmeasured rather than as zero slippage, and the count leads the line so a tidy average over two of twenty fills cannot be read as a verdict. A fill timestamped before its own decision is reported as clock skew rather than as negative latency.
Portfolio limits are enforced live
The position_limits in a strategy's config — max_positions, max_total_risk,
max_gross_exposure — are checked against the whole book before every entry, under
the position_limits guard. This is newer than the rest of live trading. Sizing
clamps one position at a time and has no view of what is already open, so before
this guard existed each entry could consume the entire risk budget on its own and
nothing capped the count at all. A config validated in a backtest at five positions
could run unbounded live, against a margin account whose buying power is a multiple
of equity.
Two things to know before your next run:
- The defaults now bite. A strategy that declares no
position_limitsgetsmax_positions: 5— the same default the backtest has always applied. If you are running more names than that, set the limit to what you actually intend. - A portfolio-weight deployment must reconcile its two caps, and
liverefuses to start until it does.--portfoliolets the allocator choose the book, butposition_limits.max_positionsstill bounds what the book holds. When the allocator funds more names than the book can hold, the surplus are funded and never traded, and which ones survive is decided by signal arrival order rather than by the allocation — solive --portfolioexits with both numbers and both remedies instead of starting. Typing--max-positionsnow sets both, so the two cannot disagree; the refusal still applies when the allocator's untyped default of5meets a smaller limit declared by the strategy or a saved config. Every strategy shipped here declaresmax_positions: 1, so a barelive --portfoliois one of the configurations that gets refused: raise the declared limit or pass--max-positions 1.
Every refusal is logged with the numbers that caused it and recorded as a decision, so a limit that is quietly throttling a run is visible rather than inferred.
The live count differs from the backtest's in two ways, both deliberate. It reads the strategy's position book — broker truth at start-up and at each reconciliation — rather than querying the broker per entry, because several symbols can signal on one bar and a round trip on each is exactly what the bar loop must not do. And it measures exposure at entry price rather than marking it, because the trade clock has no price for a symbol it is not currently handling. A book that has run up is carrying more market exposure than this counts.
Stopping
Ctrl-C stops the process, but it records nothing — restart the engine and it trades
again. To stop trading in a way that sticks, and to close everything in an
emergency, see Stopping trading.
tradeflow halts # what is currently halted
tradeflow halt all --reason "why" # refuse new entries
tradeflow flatten --confirm --reason "why" # halt, cancel, close everything
Halts block entries and never block exits, so pulling the switch can never trap the book.