![]() |
algotradelib 0.0.0.9000
C++ header-only library for algorithmic trading
|
This is the operational half of runner::PollingLoop: how to start one, how to stop one on purpose, and which of the ways to stop it will cost you money. The programming half is in the header. Read this before the first time you leave a strategy running unattended, because two of the things below cannot be discovered from the API and one of them is a supervisor setting on a machine you may not have configured yourself.
Runner::feed(event) runs the pipeline once. Everything in this library is built so that call is the same in a backtest and in production, and it is genuinely all you need for one decision.
What you must not do is write the obvious wrapper:
At that moment you own four things you probably did not mean to: a cadence, a rate-limit interaction, a reconciliation obligation, and a stop control. The loop exists to own them for you.
The whole production path — journal, recovery, loop, stops and the report — is examples/example-live-program.cpp with examples/live_program.hpp; copy those, with the two credential headers the program includes (examples/kis_credentials.hpp, examples/kiwoom_credentials.hpp). The loop at its centre:
tick_ is not a poll. It is how often the loop wakes to check whether anything is due and whether a stop has been requested, so it is also the floor on how quickly an operator gets a response. A loop trading daily bars with a one-second tick wakes 86,400 times a day and polls once — which is the point.
| Intent | Stops sending | Cancels resting orders | Closes positions |
|---|---|---|---|
| QUIESCE | yes | no | no |
| WITHDRAW | yes | yes | no |
| FLATTEN | yes | yes | yes |
QUIESCE is the default and is what both signals mean. That is deliberate: SIGTERM arrives for entirely routine reasons — a deploy, a host reboot, docker stop — and flattening on a reboot would turn routine maintenance into a liquidation.
SIGHUP is ignored, so a dropped SSH connection cannot stop a process holding positions.
A WITHDRAW or a FLATTEN may wait before it acts. Both are built from the holdings and the order list in hand, and right after one of your orders went out neither shows it yet: the order has filled and left the listing, and holdings still say what they said before. A flatten built at that moment withdraws an order that is gone and closes nothing. So when the account in hand does not account for what this runner sent, the loop reads both again, every five seconds, and acts as soon as they do, or after thirty seconds whichever comes first (PollingLoop::set_closing_wait; Settings::closing_wait_ in the example program). Nothing a strategy asks for is sent while it waits.
Signals, which already reach a detached process every way you would want:
Both quiesce. Note the tmux form: a Ctrl+C typed in some other terminal does not reach the pane, which is the property you wanted when you ran it in tmux in the first place.
A control file, which is the only channel that can name an intent other than quiesce:
The loop reads it once per tick, so the response time is one tick. A word it does not recognize is reported, not guessed at — report.notes_ will say so — because picking the nearest intent would mean choosing how much of your position to close on your behalf. Check the notes if a stop seems not to have taken.
The file is never deleted by the loop, so "did my stop get read?" stays answerable afterwards. It also means a word left in it is carried out at every start: a process started with withdraw or flatten written reads the broker's order list and holdings, does that to the whole account — every working order the account lists, every position it holds, not only this strategy's — and exits without letting the strategy trade. Remove the file before you start trading again.
The fastest stop is the worst one. In increasing order of damage:
Levels 4 and 5 are the emergency doors. They are not faster in any way that helps you, because what they save in seconds they cost in having to read the broker's order list by hand before trading can resume.
This is the one thing here you cannot find out from the code, and it silently converts level 1 into level 5.
systemd sends SIGTERM, waits TimeoutStopSec, then sends SIGKILL. docker stop does the same with a ten-second default. If that grace period is shorter than one loop iteration plus one request timeout, then every clean shutdown you ever perform ends with a process killed mid-request — and you will believe you shut down cleanly, because you asked nicely and it exited.
A signal quiesces, and a quiesce does not wait. But a supervisor that stops the process while a withdraw or flatten from the control file is pending has to outlast that stop: the closing wait (thirty seconds unless you set it), the reads in flight when it ends (a paginated order list, a paced request), and the closing orders themselves.
So make the grace period generous:
And size it against your actual HTTP timeout and your closing wait, not against how long a shutdown usually takes.
The order list must be read before anything is sent. A process that has just started cannot know whether a previous process died mid-POST. An empty indeterminacy ledger does not mean nothing is owed — it means nothing is remembered. So the loop reads the broker's own order list once before it will send anything, and retries until it succeeds. While that is pending, report.final_gate_ is order_list_unread.
This is the cheapest half of reconciliation and it needs no persistence at all. The durable half is the journal: bind_journaled, recover_runner and set_journal, in Recovering after a crash.
A stale or absent holdings snapshot blocks sizing, and stating a policy is not optional. A placement-enabled loop calls either set_maximum_portfolio_age or allow_unbounded_portfolio_age; one that states neither holds and the report names the missing call. The library picks no default duration, because how stale a position may be before it must not be sized against is a risk policy only the operator can state. A snapshot that was never read is a separate hold, portfolio_absent, which no age limit can express — an empty portfolio is the permissive answer to every ceiling and to every target subtraction, so a run that has never read the account would otherwise size an entry as though it were flat. allow_absent_portfolio is the deliberate way out for a run that genuinely has no holdings to read.
Within an iteration, the loop polls the order list, then holdings, then bars and quotes — whichever of them are due. A resting buy that fills between the two account reads is the reason. Read holdings first and the listing second, and the fill lands in neither: holdings still flat, the order already gone, and a target strategy buys again on the next bar. Read the listing first and the same fill lands in both — the target reads as met, and at worst a cancellation of an order that already filled is refused. Bars and quotes come after both, because they can send, so on a cold start the first bars meet an account already read.
A plan sized against holdings older than the listing is withheld (portfolio_superseded), which no opt-out reaches: it happens when a holdings read failed after a listing, when working orders are polled more often than holdings (the loop notes it once per run), and for a target an on_order_update handler states during the listing poll. A withheld TargetPosition — withheld for this or because the account was never read or is too old — is retained and planned again at the end of an iteration — after its polls and on_phase — once the account can be trusted and nothing holds; a later target or order for the same instrument replaces it, and a stop discards it. An OrderIntent is dropped, because a fixed quantity decided against the wrong picture should not be replayed later.
These holds stop a placement, not everything. A cancellation naming a row you chose still goes out under them, because an order that cannot be withdrawn keeps resting and can fill. What does not go out is a plan the library derived from the snapshot it cannot stand behind: a target of zero against an account that merely looks flat would withdraw the orders protecting a real holding, so such a plan is withheld whole and reported in withheld_plans_ rather than half-sent.
A checked sell also needs to know what the broker's figure means. With enforce_sellable_quantity_ on, as it is by default, the guard measures a sale against the quantity the broker said may be sold — and it refuses, with sellable_basis_unestablished, when nobody has established what that quantity covers. That is a different refusal from quantity_not_sellable, deliberately: one means resize the order, this one means declare a basis.
KIS and Toss declare one, so an ordinary cash holding there passes. A declared basis is not the same as a coherent reading, though. KIS can report one instrument as several rows (its pages can repeat a code), and when those rows state different sellable figures, or different bases, the guard cannot tell which lot a sale draws from: it refuses every sell of that instrument, a one-share reduction and a flatten's closing sell alike (sellable_basis_unestablished). The library does not add the rows' figures up and does not pick a lot, and a smaller order does not help. With the check on by default this now applies to every KIS program, not only to those that asked for it: if your account holds an instrument in more than one row, read its holdings (example-kis-account) before relying on the runner to exit it, and sell such a holding yourself, or turn the check off knowing what that gives up. Kiwoom does not by default — its coverage is something you assert, because Kiwoom's own specification does not say whether dmst_stex_tp filters the positions or only values them, so a Kiwoom run refuses every sell, a flatten's included, until you set KiwoomConfig::holdings_sellable_scope_. You will also meet it if you write your own source or your own adapter: fill HoldingsSnapshot::sellable_basis_ (or, for a figure that arrived per instrument, that row's HoldingPosition::sellable_basis_) with the coverage and the resting treatment your broker actually supports, and the guard enforces. Leave it unstated and the refusal names the declaration to add rather than being measured against a number of unknown meaning.
With a policy stated, the loop refuses to place while the snapshot is older than it, reporting portfolio_stale. Reconciling a target against stale positions is how a position gets double-sized by a route that working_quantity does not close. Staleness is measured against Portfolio::as_of_, and that is when the reply arrived here, not when the positions were true at the broker. No adapter here gets a time from the broker for a holdings answer, so the adapters stamp HoldingsSnapshot::received_at_ with the local clock as they parse, and SessionSource copies it straight into as_of_. The gate therefore understates the age of the figures by however long the request took: a snapshot the broker computed slowly can pass an age limit it should have failed. Set the limit with that headroom in mind.
A run that sends to a broker needs the order listing too. A holdings poll refreshes positions and keeps the working orders it had; only a working-order poll replaces them. And an order the runner sends stays counted until holdings, a listing or the order history account for it (below), and the history is read only after a listing, so without a listing an order that expired or was refused would stay "working" for the rest of the run. So a runner that sends to a broker (Runner::keeps_sent_orders_until_listed) holds with portfolio_stale while the source publishes no listing, while PollSchedule::working_orders_ is not set, and while the last listing is older than set_maximum_portfolio_age — whatever the age policy, because opting out of a holdings age is not opting out of a listing. The report names the missing piece once. Poll the listing more often than the age by at least a tick: a cadence equal to the age reaches the limit a tick before each poll. A dry run and an observer are not held.
A price band is a per-trading-day value, and one nobody has aged is not applied. PriceLimits is "the highest price allowed today" and for KRX it moves with the previous close, so a band registered once at startup (Runner::set_price_limits) describes the day the process started on, and would otherwise be clamped into every limit price for as long as the process lived. So:
Withholding a band does not refuse an order and does not hold anything. It returns the order to the state every run that registers no band is already in: the strategy's own price on the wire. That is not free, and it is worth knowing what it costs before choosing the policy:
Read both as co-occurrence, not causation: orders are refused for many reasons, and a count of one is one order that happened to be carrying an unclamped price. They are a reason to look at the policy, not proof about any single order — and neither can see the case with no refusal in it, an order accepted at the raw price that then never filled. LoopReport::price_bands_withheld_ counts the decisions rather than their cost. The reason withholding is still the default: a band whose age nobody has bounded is not evidence about today's band, and this library does not alter an order's price on the strength of something that is not evidence. The alternative is not "clamp correctly", it is "clamp to a number from an unknown day", and that failure is an order resting at a price nobody chose which may never fill. One line of policy overrides it either way.
Runner::feed returns a report describing what became of every instruction — fourteen vectors of it — and live::SessionSource discards that report on every poll. Without the loop report's own figures below, a run in which the broker refused every order would look like one in which it accepted every order: same iterations, same poll counts, no failures. Apart from those figures, a refusal is recorded only in the order journal, if you bound a journaling executor whose terminal write landed. live::bind does not journal at all: journalling is live::bind_journaled, so a run built with live::bind has no record of a refusal beyond the report. And bind_journaled is not itself a promise of durability — it takes whatever journal you hand it, including a MemoryJournal that is gone when the process is, and its terminal append can fail after the broker has already answered, leaving the PENDING line and no outcome.
LoopReport carries thirteen figures and two records (last_broker_refusal_, last_unbuildable_), all describing the polling loop — the stop handler and the closing pass keep their own families, stop_handler_* and closing_*, so a refused flatten is reported once rather than twice.
| Figure | What happened |
|---|---|
| orders_unbuildable_ | reconciliation could not build the instruction: no reference data, no price, an unknown grid — or a quantity that rounded to zero, which is a deliberate drop rather than a fault, so a non-zero figure is not by itself a reason to go looking for missing data |
| orders_reconcile_withheld_ | the indeterminacy ledger is holding that instrument: until a listing settles an unknown outcome, or, after a confirmation timeout, until you clear it (or a restart, below). A serialized runner keeps the target and counts it again at each later holdings read |
| orders_unconfirmed_timed_out_ | an order this run sent was not confirmed by holdings, a listing or its history within the confirmation timeout, or, once its history settled it, a holdings read a timeout later did not show the fill. A serialized runner goes on owing it: its instrument waits for holdings or the order's history, or for you if no sweep ever named it. An overlapping runner withholds the instrument until you clear the ledger or the process restarts (below) |
| orders_guard_refused_ | RiskGuard refused it — a hold as well as a limit, since it checks the active holds first, so a halted run reports every order here. Nothing was sent |
| orders_binding_refused_ | the binding would not send it, or could not reach the broker (a DNS, connect or TLS failure; such an order is kept and retried, see the kept-instructions rules); no request went out |
| orders_broker_refused_ | the broker was asked and said no; a request was spent. At KIS, Toss and Kiwoom most refusals are counted in the next row instead (counted here: on the KIS and Kiwoom demo servers a sell with nothing to sell and an order sent before the market opens or after it has closed; on the real servers a KIS order of the wrong type for the after-market session and a Kiwoom sell with nothing held) |
| orders_indeterminate_ | the outcome is unknown, so the order may or may not exist: the request failed in transit or was cancelled, the reply could not be read, the broker answered with an HTTP 5xx, or (at KIS, Toss and Kiwoom) its refusal arrived as PROTOCOL |
| cancels_rejected_ | a withdrawal refused outright — by the broker or by the binding; RejectedCancel::by_broker_ distinguishes them and this figure does not. On the KIS and Kiwoom demo servers and on Kiwoom's real server, a withdrawal of an order with nothing left to cancel (one that has filled or already ended) is counted here; on a real KIS account it is still in cancels_indeterminate_ |
| cancels_refused_ | a hold refused the withdrawal before anything was sent |
| cancels_unresolved_ | the reference named an order the snapshot does not hold |
| cancels_indeterminate_ | the withdrawal's outcome is unknown, for the same reasons; it may still have withdrawn the order |
| plans_withheld_ | a whole plan held back before any of its parts was judged — for one of four reasons this figure does not distinguish: the account was never read, it is older than your stated maximum, it was read before the working-order listing it would be reconciled with (portfolio_superseded), or a hold is refusing placements for a plan that also wanted to withdraw something. The last two happen on a perfectly fresh portfolio, so do not read this as "the account is unknown". And final_gate_ does not always show it: with allow_absent_portfolio the gate is open while a plan that would withdraw something is still withheld |
| instructions_unavailable_ | local machinery was unavailable, so it was not sent — a pre-send journal write that failed is the usual cause and not the only one, since any executor answering UNAVAILABLE lands here. Placements and withdrawals together. It leaves a sticky local_failure hold, which final_gate_ may name — a higher-priority hold can be reported while that one is still in force — so read this as how many instructions it cost |
And last_broker_refusal_, which is the one worth reading first. A count tells you orders are being refused; this tells you which instrument, under which of your own tags, with the broker's own code and message. One record, overwritten — a run refused ten thousand times keeps the last one that came with an error, and a refusal that says nothing clears it rather than leaving the previous order's words behind. Empty when this run's polling saw no refusal, even though the runner may still hold one from an earlier run; and empty, with a non-zero count beside it, when the refusals themselves were silent. It is empty on a dry runner and in a replay, which have no broker to be refused by, and it misses the KIS, Toss and Kiwoom refusals counted as unknown outcomes (most business refusals; Binding a broker names the refusals that are not).
last_unbuildable_ does the same for orders_unbuildable_: the reconciliation's reason (RejectReason), the instrument and your tag, for the last instruction this run's polling could not build, and empty when it built them all. live_program prints it as last unbuildable. A common live answer is no_portfolio_value: a weight-sized instruction (SizeSpec::target_weight, which EqualWeightRebalance states) needs a portfolio value, which a replay supplies and a live runner does not unless you set Runner::set_portfolio_value, so it is refused on every pass.
An unknown buying power blocks buying. Only if you asked for the check: set RiskLimits::enforce_buying_power_ and the loop refuses to send while it has no answer from the broker about what may be spent, reporting buying_power_unread. Set a cadence with PollSchedule::buying_power_ and the currencies with SessionSource::set_buying_power_currencies, and optionally an age with set_maximum_buying_power_age. A loop that never asked for the check is never held by it.
Every currency you configure has to answer. The gate holds until each one the source was told to poll has a reading the age rule accepts — not merely until one of them does. An account trading two currencies where only one answers is a budget that never arrived, and one currency's figure is not permission to spend another's.
The reason it is a hold rather than a refusal per order: sizing a buy against a budget nobody has stated is the thing the check exists to stop, and a runner that has never been told cannot make a per-order decision about it. Once a reading exists, an order too large for it is an ordinary refusal — insufficient_buying_power, carrying the budget and the attempt.
All three gates work by holding the guard, which is the library's one primitive for "stop sending without withdrawing or flattening". A hold names who took it, and is released only by them: the loop takes portfolio_stale, order_list_unread and buying_power_unread, you take operator when you call halt() by hand, and the runner takes local_failure when local machinery cannot send — usually a journal write that failed, and not only that. Trading resumes when the last hold is released, so the loop opening its gate cannot lift your halt, and your resume() cannot lift the loop's.
Your strategy's on_stop cannot trade during a stop. It is dispatched first, while the loop's hold stands, so anything it submits is refused and counted in stop_handler_refused_. A stop belongs to the operator; an order the strategy adds in that window would be planned against the same snapshot as the loop's own closing orders and could close the same position twice.
A flatten states its instructions directly rather than expressing them as a target position. It asks once for the withdrawal of every listed working order — including one on the same side as the close, which ordinary reconciliation would keep as progress toward the target, and except an order of this runner whose withdrawal the broker has already accepted — and then sends a market order for the position. An order the broker does not list cannot be withdrawn or sized around: at Toss, an order of a type the API does not list (such as an after-hours closing-price order placed in the app).
It closes only the part no working order was already trading, and that subtlety is worth the paragraph. Ten held against a resting sell for ten: closing all ten alongside it could sell the position twice, because a cancellation is a request and the resting sell can fill while its withdrawal is still pending. Ten long would become ten short. So the close is sized to the holding minus whatever is still working on the closing side — nothing at all, in that example — and closing_contended_ counts the positions where that happened. Whichever way it resolves is safe: if the withdrawal completes, part of the position is still held and the report says so; if it fails because the order filled, the account is flat. Neither is complete, so closing_complete() is false, and a later holdings snapshot is what tells you which one you got.
And when the picture itself is unverified, no close is sent at all. That deduction can only subtract orders the loop can see, so an order sent after the last listing — or a source that cannot enumerate orders, like the history fallback — would slip past it and the full-size close would be the same double sell by a different route. The withdrawals still go, because cancelling only reduces exposure; the position stays, held and reported. A stop pending when the process starts (the control file already says flatten) makes the startup read itself before it closes anything, so this case is one of those listed under closing_source_unverified_ below: a failed order-list or holdings read, holdings or a listing that are too old, a source with no working-order inquiry, or an order sent after the last listing.
A flatten beside your own protective stop therefore takes two runs. The first withdraws the stop and leaves the position, contended, and the program exits CLOSING_INCOMPLETE. Start the program again with flatten still in the control file: it reads the order list before anything else, sends nothing for the strategy (the stop's hold is in force from the first iteration, so the strategy cannot place its stop again), and closes what is held once nothing is resting on the closing side. Between the two runs the position has no stop. If the cancellation is still pending at the broker, that run is contended too, sends nothing (its second cancellation may be refused, which the report counts) — run it again; the same if the first run never saw the stop. Then remove the file.
An operator's halt refuses cancellations too, including a stop's own. Note that this is halt() specifically, and not the uninformed holds described above — those let a withdrawal you named go out, which is the distinction that paragraph turns on. halt() means "this runner sends nothing", and a cancellation is a send — an order-writing request that can itself fail indeterminately. So a withdraw or flatten arriving while you are halted cancels nothing, reports closing_refused_, and says "the position is still held" in the notes. That is deliberate: a stop reaches the process from a signal and from a control file, so a cancellation exempt from the halt would let echo withdraw > /var/run/atl/stop send an instruction your own halt forbade. To get out after halting, resume() first — one deliberate act, and closing_complete() then tells you whether every instruction was accepted.
report.final_gate_ is an optional, and names the most fundamental hold in force rather than only the loop's own — so a run that sent nothing because you halted it says operator, instead of showing you an open gate while nothing goes out. guard().holds() lists every hold when more than one stands, and guard().hold_detail(HaltReason::LOCAL_FAILURE) carries a local-failure detail — whatever the binding said it could not do, or whatever a caller of halt_locally supplied, such as a source that could not journal a reconciliation decision. A failed journal write is the usual answer and not the only one. Recorded through the runner it is the first such detail, kept while the hold stands; a program that reaches past it and calls guard().halt(LOCAL_FAILURE, …) itself replaces the detail, so if you take that hold directly the value is yours, not the first cause.
Ask, because it is not always yes — and note that this is a narrower question than "am I flat", for the reasons below:
withdrawn_ and flattened_ count what the loop asked for. closing_placed_ counts what actually went out. They differ, and the most common reason is the one below.
closing_complete() is false if any closing instruction failed to go, and there are six ways that happens — closing_rejected_ (reconciliation could not build it, the broker refused it, or the binding refused to send it), closing_refused_, closing_withheld_, closing_indeterminate_, closing_unavailable_ (the local machinery was unavailable, so no broker was asked — usually a journal write that failed, and not only that) and closing_unresolved_ (a cancellation named an order the snapshot does not hold). Cancellations count in these as well as placements: a withdrawal that failed in transit may have withdrawn the order or may not, and that is not the same as one that went.
A seventh, closing_contended_, counts something different: a close the pass deliberately made smaller, or did not send at all, because another order might close the same position. See the flatten note above.
And one flag covers the case before any of those, closing_source_unverified_. A stop builds its instructions from the portfolio snapshot — working orders for a withdraw, positions for a flatten — so it can only be complete if that picture is current. Six ways it is not:
All but the last are what the closing wait reads for, and usually cures. When it cannot, the pass still sends what it can see, because withdrawing some beats withdrawing none, but it will not call the result complete, and the note says which of these it was.
Three more keep closing_complete() false. closing_unsizable_ counts positions whose close could not be sized: holding rows that do not add up, a closing quantity that cannot be represented, or a same-side working quantity that cannot be subtracted. closing_unconfirmed_ counts orders this runner sent that neither holdings nor a listing accounted for when the pass ran: exposure it could not withdraw or size around. And closing_interrupted_ says the run was abandoned for failing polls while a withdraw or flatten was pending: the pass did not run at all, and the runner is left unable to send.
It is not a confirmation of anything at the exchange. true means every instruction the loop asked for was accepted by the broker, and that is where the library's knowledge stops. An accepted closing order may not have filled, which only a later holdings snapshot shows. An accepted cancellation may not have completed: cancellation is asynchronous — Toss, for one, publishes PENDING_CANCEL while it is in flight and CANCEL_REJECTED when it fails, returning the order to its previous state — so a withdraw that reports complete can still have orders working.
So after any stop, read the broker's own order list and holdings before believing anything about the state. closing_complete() answers one question only: did anything fail to go out.
RiskLimits::permitted_phases_ defaults to {MarketPhase::OPEN}, and a flatten outside market hours still goes out. The phase limit governs what an order opens, not what it closes: an order reducing a held position toward flat is permitted in any phase, because a limit that cannot be de-risked past turns "the market is shut" into "you may not reduce risk". So an operator who asks to be flat at 22:00 gets a request sent, and very likely a broker's rejection rather than the library's — which is the better answer, since it is the venue's own, and recorded beyond the report if you bound a journaling executor and its terminal write landed: live::bind does not journal, live::bind_journaled does, and a failed terminal append leaves the PENDING line with no answer beside it. Whether a recorded answer then outlives the process is a property of the journal you handed in — a MemoryJournal is gone when the process is. LoopReport::sends_outside_permitted_phase_ counts how often that happened. When that request came from a strategy's TargetPosition — vanilla::TimedLiquidation's POST_CLOSE flatten is one — and the broker rejected it, the target is kept and the exit is sent again at the next session's open (below). The operator's own flatten is an order, not a target, and ends with the rejection.
Every other limit still applies to that flatten, and each of them is silent in the same way. A sellable check with no broker reading, a per-order ceiling, a position ceiling, a halt: any of these refuses the exit, the report says closing_refused_, and a note ends "the position is still held". An operator who asks to be flat and hears nothing would believe they are. This is why the counts are separate rather than one number, and why closing_complete() exists.
An opening order after the close is refused exactly as before — PHASE_NOT_PERMITTED — but a target, or an order, is kept for the next session. This is the case every daily strategy meets: each shipped adapter stamps a daily bar at 00:00 KST, SessionSource feeds it once its day has ended (at the next midnight), and the strategy decides while the market is shut. A TargetPosition whose opening order the phase limit refused, or whose exit it admitted and the broker (or the binding) then refused (a broker not taking orders at midnight does), is kept (Runner::targets_retained_for_phase) and planned again in the first iteration whose phase the limit admits — against that iteration's holdings, listing and prices. That is what a replay does by filling at the next bar's open. The rules around it:
An instruction reaches a broker only when it was decided on the newest closed bar the source held for that instrument at that poll. A cold start's first read is a history — live_program::Settings:: bar_count_ bars per instrument — and SessionSource shows all of it to the strategy, in period-end order across the universe as a replay would, so indicators and a strategy's own state are warm. But everything decided on the older bars is discarded (Runner::feed_warmup), counted (Runner::warmup_instructions_discarded, SessionSource::bars_fed_as_warmup) and noted once in the run's report; only the newest bar's decision can be sent, or kept for the phase. The same holds for a backlog after an outage: two missed daily bars are both shown, and only the newer one acts.
Why it matters: otherwise a restarted process would act on every signal in its history, at today's price. An event-triggered strategy — vanilla::RsiThreshold enters on "not holding and oversold" — would meet an oversold bar months back against a flat book and buy again a minute after the previous process had sold. A level strategy (SmaCrossover) would be right only by accident, because only its last target survives. So a process started overnight and one started during the session take the same decision: the newest bar's. Four consequences to design for:
A source of your own must do the same: feed a read's new bars in period-end order across instruments, and feed live only each instrument's newest bar whose period ended strictly after every bar of the read fed as history; everything else goes through feed_warmup.
The loop derives the phase each iteration from two things:
on_phase fires on a change, once, with this iteration's prices and portfolio already installed. A strategy written on it — vanilla::TimedLiquidation is the shipped one — behaves the same live as in a replay of intraday bars. A daily replay's clock visits only bar ends, so there it fires about once in the whole run (see "Reading a backtest honestly").
If you write your own source, one rule is yours to keep, in two halves: write the trading calendar during PollKind::CALENDAR, nowhere else, and do not feed the runner from that poll. The loop re-derives the phase before every call it makes that could send, but nothing of its runs between your set_calendar and your Runner::feed, so a poll that does both settles an order against the phase as it stood when that poll began. SessionSource::poll_calendar installs a calendar and feeds nothing, which is the shape with no edge.
The same rule applies to a strategy: on_phase and on_stop receive a mutable context, and Context::set_calendar is not theirs to call — read calendar() instead, which is what it is published for. A calendar revised by on_stop is the one case the loop can still answer, because the closing pass is a separate call and the phase is re-derived before it.
Where it cannot, it witnesses: LoopReport::sends_under_a_revised_phase_ counts the calls where the phase moved and a request went out, with a note naming the call. It is a contract diagnostic rather than proof that a particular order was mis-checked, and it says so.
When the phase cannot be established, the run says so: final_phase_ is unknown and a note names the reason — no cadence configured, no calendar published by the broker, every poll failed, or the date is uncovered. It is deliberately not a halt: a halt would also stop cancellations, which Runner::send_cancel exempts from the phase limit because withdrawing only ever reduces exposure. RiskGuard already refuses every placement that opens a position with PHASE_NOT_PERMITTED, and permits one that closes a held position — which is the case a halt would wrongly stop, for the same reason it would wrongly stop a cancellation. So there is nothing a halt would add and something it would take away.
A poll that fails sent nothing, so nothing needs reconciling and no ledger entry is owed. A quote that did not arrive is an absence of information, and the loop carries on without it.
What it does bound is repetition: FailurePolicy::consecutive_limit_ consecutive failures of one kind and the loop quiesces rather than spinning against a broker that is refusing it, reporting final_gate_ == poll_failing with stopped_by_ empty — so you can tell the loop's own decision from an operator's stop. Counted per kind, so a broken working-order inquiry does not mask a healthy bar feed, and consecutive rather than cumulative, so one failure in three is degraded rather than down.
A rate-limit refusal is specifically not a failure. It is the limiter working, and the controller already holds capacity back from polling so that an order is never the request that waits.
Two checks, each the broker's answer rather than a limit you chose. The sellable check is on by default; the buying-power check is off until you turn it on.
The sellable check is on because the alternative is worse than a refusal. A sale the account cannot make is refused by the broker in its reply, which at KIS, Kiwoom and Toss reads as an unknown outcome (the KIS and Kiwoom demo servers' refusal and Kiwoom's real server's are recognized as a rejection; the real KIS server's is not): a serialized runner then owes an order nobody can find, and the instrument stays withheld until you release it (Runner::release_obligations). Refused by the guard instead, no placement is sent or journaled and no new obligation is created (at Toss the read-only sellable inquiry may still have been made, and an order owed from earlier stays owed). Turning the check off is for an account that can borrow, or for a simulation; it borrows nothing by itself.
What they cost differs by broker, and BrokerCaps::sellable_source says which you have. At KIS the sellable figure arrives on every holdings row, so the check costs nothing at all. At Toss it takes a request per instrument you actually sell, spent at the moment the sell is planned and kept with the position until the next holdings poll replaces it.
At Toss the check is a request, and a request can fail. If the inquiry is refused or does not answer (Toss rate-limits), the figure stays absent and that pass's sell is refused, a flatten's closing sell included. Nothing retries it: the sell goes out when the strategy states its target again, or the flatten is asked again, and the inquiry answers. This is the one network call the default adds to an exit.
The observer at Toss cannot ask. live::bind_observer records and sends nothing, and it cannot make Toss's per-instrument inquiry, so it reports a sell as refused where a live run would ask and could send it. At KIS, and at Kiwoom once the scope is set, the figure is on the holdings and the observer sees what a live run sees.
Absent is not zero. A figure the broker has not stated is unknown, and unknown refuses. Neither check falls back to what you hold or to cash_in() — a position is not evidence of a sellable position, and settlement, pledging and locking are exactly the gap being reported.
A pass sizes against what it has already sent. Two sells of sixty against a hundred sellable do not both go out, and neither do two buys the budget can only fund once. The reservation outlives the pass, so a second bar before the next poll cannot re-spend the same money.
Every order sent to a broker counts as working until the broker shows what became of it, whatever the limits — on a broker that lists working orders, which all three shipped ones do. Any later pass therefore sees the order an earlier one sent and does not send it again: two bars of a minute strategy between listings, a runner driven by hand, a quote and a bar in one iteration, with or without the checks above (and SessionSource feeds a cold start's history as history, above, so only its newest bar can send at all). A dry run and live::bind_observer send to no broker, so their rows carry no broker reference — a later opposing target would ask to withdraw an order identified by nothing — and for them an order stays counted across passes only when enforce_buying_power_, enforce_sellable_quantity_, max_position_quantity_ or max_gross_exposure_ reads it, or it is a stop; otherwise they report a restated target again on every pass.
A listing alone does not end it. A market order that filled at once is in no listing, a resting order may not be listed yet, and a listed one can fill and leave; in each case a holdings answer that lags the fill does not show it either, and a target would send the order again. So the runner remembers each accepted order with the instrument's holding when it was sent (Runner::unconfirmed_orders), and before every pass shows it as a reserved row of whatever nothing explains yet: the order's quantity, less what holdings have moved since in its direction, less what a listing still shows for it. It ends when holdings show the whole quantity. An order that left the listing, or that the runner withdrew, is looked up in the order history after the next listing (SessionSource reads it on demand), and its filled quantity becomes what holdings must show; one that ended with nothing filled is forgotten. For a runner that overlaps orders, on a loop with a market calendar, a DAY order's history row is taken as final once the session closed. An order whose quantity stays unexplained for ten minutes (set_confirmation_timeout) is looked up once more; if the history says what it filled, holdings get one more timeout (judged by a holdings read taken that much later) to show it. One still unexplained then is counted as LoopReport::orders_unconfirmed_timed_out_, and what follows depends on the runner:
A timeout too short costs trading, never a second order. Either way the count waits for a holdings read taken a full timeout after the quantity became unexplained (or after the history settled it), so a slow holdings cadence delays it, never makes it early.
An order whose reply was lost is counted the same way, from the send. Its instrument is withheld in the ledger until a sweep of the broker's lists says what became of it, and meanwhile the order is tracked but not shown: no placement on the instrument can go out, and a listing that already carries the order would otherwise count it twice and have a target withdraw the real one. It does not time out while the ledger holds the instrument. When the sweep finds it (MATCHED), the order is counted on, by the number the sweep found, until holdings show it, and the ledger lets the instrument trade again once the sweep has decided everything open on it. (Otherwise a market order that had filled would be in no listing, holdings lagging the fill would not show it, and the next bar's target would buy it again.) SessionSource does this for every recorded resolution; a source of your own that calls reconcile_against should call Runner::resolve_indeterminate_placement the same way, and so must you after clearing the ledger by hand: an order nothing names once the ledger is clear shows as a reserved row with no broker reference (a resting one is then counted beside its own listed row), waits for holdings, and past the confirmation timeout withholds its instrument again, counted.
When the sweep finds no order (NOT_PLACED), the two kinds of runner part. The finding is that no row of the order list or of the order history of its send matched within the window, and the journal line says that and no more: a list can lag an order the broker accepted, and nothing the brokers publish says "every order accepted so far is listed".
The first sweep's rule is narrower, in either kind of runner: a row carrying the order number the journal records for another of its placements, inside that placement's own send, is not the lost order's, and makes the verdict ambiguous (an order of the same shape that this runner sent just before is not taken for it). A row of that shape whose number the journal does not record for anyone is still a candidate, and two rows that both fit read as ambiguous.
One instrument held back does not hold back the rest of a strategy. A pair goes on trading its other leg against one that cannot move; a rotation buys the next name beside one it could not sell. Every order stays inside its own limits while the account holds what the strategy never asked for. RiskLimits::max_gross_exposure_ bounds what such a run can buy on top; nothing in the library keeps two legs together.
So a loop stops placing by itself, in two steps, for an order a serialized runner owes that has no name (UNNAMED) or is held for an operator (OPERATOR):
So ten minutes, and whatever the loop was doing when they passed, bounds how long a run places beside such an order, not when it ends: a loop that reads the order list every fifteen minutes stops at that read, and later still while reads fail. The ages are measured on the loop's wall clock against the journal's stamps, as everything about a serialized order is; a clock set forward shortens them and one set back lengthens them. A start with such an order from an earlier day places nothing at all, on any instrument, and stops again at every start until the order is accounted for.
What it does not do:
To go on, read the broker's order list, order history and holdings for the instrument yourself and decide what the account should hold (cancel the order if it is resting). Then, in a program of your own: Runner::release_obligations(instrument, reason), ledger().resolve_after_reading_orders(instrument) if the ledger still holds the instrument (a release leaves the ledger alone), StopSource::clear and guard().resume(strategy::HaltReason::STOPPING); the next order on the instrument waits for a holdings read received after the release. Orders the journal owes on other instruments stay owed.
example-live-program has no command for a release, and its only way on gives up the whole journal: moving it aside, keeping it, so that the next start begins a new one. That forgets every order the journal still owes, on every instrument, and a new journal lets such an order be sent again: a resting order shows in the new run's order list, but once it fills and leaves the list before holdings show the fill, nothing remembers it. The program lists the orders when it exits 8. Move the journal aside only once none of them can execute any more: each has ended (filled in full, or cancelled with its history row saying so) and holdings show its final fills. A cancellation only requested, an order list that no longer shows the order, and the close of the session do not show that. An order you mean to leave working keeps its place in the journal; the example offers no safe way on in that case, and a program of your own releases the one instrument instead. The same condition holds when the program starts nothing because a journal line cannot be read (exit 3): its way on is also a new journal, and a damaged journal may not name every order it tracks, so the orders to check are all those sent on the account since the journal began, on every day of the order history, and those still working. set_owed_order_limit(0) turns the stop off, for a program that watches Runner::obligations itself.
A serialized runner over a broker that lists no working orders places nothing (Runner::serialized), so the only such order it can owe is one a journal restored; the stop then comes at the startup read of the order list.
A target never re-places what it only reserved. A reserved row has often filled already, so when a target finds more working on its side than it wants, it still withdraws every row there, but sizes the replacement net of the reserved ones: withdrawing a finished order is refused, and a whole remainder sent beside it would buy again every bar while holdings lagged. A reserved order that was still resting is withdrawn all the same.
In serialized mode the target is then kept (Runner::targets_retained) until every order on the instrument is accounted for, and planned again against holdings that show what the order did: the rest is placed, or nothing if the target is already met. A reversal is the plainest case. Flat, stated right after a buy that holdings do not show yet, withdraws the buy (refused, if it has filled), waits, and sells what was bought once holdings show it; a strategy that states a change once does not lose it to the unconfirmed order. A kept target passes every limit again when it is planned, though, and a guard's refusal then ends it as it ends any target (only a phase refusal is kept): if the sellable check refuses that sell, because the figure is absent, lower than the holding or unanswered at Toss, the exit is gone until the strategy states it again. The withdrawal is attempted once and not repeated while the target waits: on the KIS and Kiwoom demo servers a second one was refused, and outside the recognized replies (those two servers', and Kiwoom's real server's) such a refusal reads as an unknown outcome that holds the instrument. So if the first withdrawal failed or could not be sent (an order restored after a restart has no reference to withdraw by until a listing shows it) and the order is really resting, the kept target does nothing more by itself; a target stated again withdraws again. A target whose order the ledger withholds (orders_reconcile_withheld_) is kept the same way, and planned again at each later holdings read until a listing has settled the instrument. Keeping a target promises neither the withdrawal nor the position, and it does not survive a restart. In overlapping mode nothing is kept, and the rest is placed the next time the target is stated, once the order's history says what it filled or holdings show it.
The cost is waiting, on exits as on entries: a sell target whose reserved sells exceed the need sends nothing until holdings credit them (or the confirmation timeout withholds the instrument), and at Kiwoom, whose cancelled-order rows are not yet verified, a withdrawn order may wait for that timeout. A replay or a dry run does not do this.
A trade made outside the runner on the same instrument never doubles an order either: one in the same direction ends the wait early, one in the other direction keeps it until the timeout withholds the instrument. A source of your own that reads no history never times one out: the row stays for the rest of the run, which under-trades the instrument.
On Kiwoom, an order the runner withdrew may end in that mark. Kiwoom gives a cancellation its own order number, and whether the original order's history row then reads as ended has not been observed; if it does not, its instrument is withheld ten minutes after the withdrawal, until you clear it. KIS's was observed and is read as ended.
Passing is not a promise. These are readings, and a fill against a resting order moves them without telling anyone. The check is the difference between sizing against the broker's last answer and sizing against nothing.
A TargetPosition is signed, and a replay will short if you let it. Live, no adapter this library ships can open a short: KIS, Kiwoom and Toss are bound to their cash orders. A target below zero becomes a sale of shares the account does not hold, and the sellable check refuses it (quantity_not_sellable; at Kiwoom with no sellable scope set, sellable_basis_unestablished, as for any sell). A target that crosses zero, from five held to minus one, is refused whole: the runner does not sell the five and call it done, because turning a reversal into an exit is your strategy's decision. State flat if flat is what you want on a cash account. (Stated while the instrument still owes an order or is marked, such a target is first kept and counted withheld at each read, and refused once the instrument clears.)
The refusal is of the order, not of everything the target implied. A target's withdrawals go out before the guard sees its orders, so a target below zero behind a resting protective stop withdraws the stop, waits for it, and is then refused: the holding is left long and unprotected. The same holds for any target whose sell the check refuses, a flat target at Kiwoom with no sellable scope included. "A refused short sends no placement" is always true; "it sends nothing" only where no working order stood in the target's way.
What a cash account can do is buy a fund that moves against an index. examples/example-inverse-fund.cpp is a strategy that reads one instrument for its signal and states an ordinary long target for an inverse fund, by the fund's own name, with the fund's own prices, lot and bars. The library does not translate a negative target into such a purchase, and should not be asked to: the two are different positions.
Korean brokers do offer real shorts through other doors (a stock loan order at KIS, futures), on real accounts and with agreements this library knows nothing about. Neither is implemented.
LoopReport::charged_costs_ carries the commission and tax the broker stated on the orders this run saw, per currency. Toss states both on every order; at KIS the order snapshots state neither, and that absence is reported as an absence rather than as a charge of zero.
It exists because a replay reports a modelled commission and a live run reported nothing at all, so there was no direction in which the two could be compared.
Read it as a subtotal of unknown coverage, not as a total, and the field's own documentation says so first. The loop polls working orders: an order that fills and leaves that listing between two polls is never seen carrying its final commission, and one placed and filled between two polls is never seen at all. Neither is detectable from a listing, so:
A total with known coverage needs a deliberate order-history poll, which this loop does not make.
RiskLimits::max_gross_exposure_ caps the exposure the account may reach: the exposure if this order and every outstanding one filled, valued at the highest price that could apply, so a ten-million ceiling stops a strategy reaching twenty million two million at a time.
Three things to know before turning it on.
It is a ceiling per currency. The same number is applied independently to each currency, in the currency the order is priced in, as max_order_notional_ reads it: the library does not add currencies without a rate you can see. So an account holding ten million of exposure in won and ten million in dollars passes a fifteen-million ceiling. If you need a converted account-wide cap, this is not it; the library has no exchange rates yet.
It measures what would happen if everything outstanding filled, not what the last poll reported — the same standard max_position_quantity_ uses. Working orders count, so two orders in one pass cannot each fit and together breach it, and the reservation carries across a bar so a second bar before the next poll cannot either. An instrument is valued at the most expensive price that could apply to it, so a limit buy above the current quote is not under-counted.
It governs what an order adds, so it never blocks the way out. An order that does not raise reachable exposure is permitted however far over the ceiling the account already sits — a sell against a long position up to flat, or a buy covering a short up to flat. The corollary is a real loosening: such an order is allowed even when it is larger than the ceiling itself.
It is not true that any order opposing a position is exempt. Selling 300 against a long of 100 reaches a short of 200, which is more reachable exposure than the 100 it started from, and the same rule refuses it. The test is what the order does to the projection, not which way it points. If you wanted a cap on a single order, that is max_order_notional_.
An account it cannot value refuses everything. Every instrument the account holds or has working needs registered reference data, and a mark. One that has neither is not quietly left out of the sum — it would under-count, which is the direction a risk limit must not err in — so the order is refused with GuardReason::VALUE_NOT_KNOWN until you register it.
Surviving a restart is not on this list, except for the lag above. Bind a runner::FileJournal and the ledger becomes durable: every instruction is recorded before it is sent, a restarted runner recovers what its predecessor left owed, and the listings this source already reads resolve it. See the recovering after a crash vignette — including why not_placed and undecided are the pair to get right, and why an ambiguous verdict is a stop rather than a retry.
examples/example-live-loop.cpp runs the real loop against a source that invents its data — no credentials, no network, and it finishes instantly because it uses ManualClock. It is also the shortest worked example of writing a source for a broker this library does not support: implement poll and read_order_list, and the cadence, the stop intents, the gates and the failure policy all come along unchanged.
That is enough only because the example's runner sends nothing (runner::DryRunner). A source that feeds a runner which sends to a broker owes more, and live::SessionSource is the reference for each of these: