algotradelib 0.0.0.9000
C++ header-only library for algorithmic trading
Loading...
Searching...
No Matches
Recovering after a crash

This is the operational half of the order journal: what to turn on before you leave a runner unattended, what the file looks like at four in the afternoon when something has gone wrong, and how to read the five verdicts without talking yourself into the wrong one.

Read it before the first unattended session, not after the first incident.

The sentence the whole thing rests on

An empty indeterminacy ledger does not mean nothing is owed. It means nothing is remembered.

runner::IndeterminacyLedger is what stops a runner sending a second order for an instrument whose first order might exist. It lives in memory, so a restarted process starts with an empty one — and an empty one is indistinguishable from a clean slate. Worse, an order that was in flight at the moment of a SIGKILL never reaches runner::outcome_of at all: no error path runs, so nothing was ever marked, not even in the ledger that then died.

The journal exists to make that difference visible on disk.

Turning it on

Four steps, and two of them are easy to get subtly and silently wrong:

// 1. Name the account. It goes in a header written before any order, so a journal whose
// file belongs to another account refuses to open even if its first record tore.
runner::FileJournal journal("/var/lib/mybot/orders.jsonl", "kis:live:12345678-01");
if (!journal.open()) {
std::cerr << journal.rejection() << '\n';
return 1; // a runner that cannot journal should not trade
}
// 2. Bind through the journal. `bind_journaled`, never `bind`.
auto live_runner = live::bind_journaled(session, journal, MyStrategy{}, limits);
// 3. Recover before the loop starts.
const auto recovery = runner::recover_runner(live_runner, journal);
// 4. Let the source sweep the ledger from the listings it reads.
live::SessionSource<Session, runner::FileJournal> source(session, {samsung()});
source.set_journal(journal);
The order journal on disk: one JSON object per line, appended and flushed to disk (fsync) before appe...
RecoveryReport recover_runner(Runner &runner_instance, const Journal &journal)
Recovers a journal into a runner at startup: marks each instrument left owed, restores a serialized r...

Step 2 is the one that fails silently. live::bind constructs its own SessionExecutor internally, so wrapping one yourself and leaving it in a variable beside the runner does nothing at all — the runner keeps sending through the plain executor, the journal stays empty, and nothing anywhere reports a problem. bind_journaled exists so that mistake is unexpressible.

Step 4 is the one people forget. Miss it and the journal records faithfully and resolves nothing: every recovered instrument stays withheld forever, and you will conclude the feature is broken when it is merely deaf.

Step 3 does more than mark the ledger. Three kinds of damage cannot be expressed as a mark on one instrument, so recover_runner halts the runner outright for each — local_failure() is set and the loop gates on it:

  • the torn line named no instrument, so there is nothing to withhold;
  • a complete line does not parse, which means something else edited the file or a repair was itself interrupted;
  • the file exists and could not be read. That is not the same as no journal at all: a missing file means a first start and nothing owed, while an unreadable one may hold every order a predecessor left in flight. Only ENOENT counts as a clean start.

Clear any of them with resume_after_local_failure() once you have read the broker's list yourself. That call also acknowledges the executor's failure count, which is what lets the runner tell a new storage failure from the one you just fixed — without it, the next send would re-read the first error and halt again immediately.

A backtest journals nothing, because it wraps nothing. There is no flag to remember to turn off.

What the file looks like

One JSON object per line, appended, flushed and fsynced before the call returns. tail is the intended interface:

$ tail -2 /var/lib/mybot/orders.jsonl
{"id":"run-a-7","action":"place","outcome":"pending","country":"KR","venue":"COMBINED","symbol":"005930","side":"BUY","type":"LIMIT","time_in_force":"DAY","session":"REGULAR","quantity":"10","price":"70000","at":1774832400000000,"at_text":"2026-03-30T01:00:00Z","crc":"…"}
{"id":"run-a-7","action":"place","outcome":"accepted","country":"KR",...,"order_id":"KRX-4412","crc":"…"}

At least two lines per instruction, never an edit of one. The first is written before the send is attempted; the second says what happened, and a serialized runner (the default with a journal) adds a third when the placement is accounted for. A pending line with no companion is the signal.

at is the record and at_text is the caption: the integer is exact on every platform and is what matching reads, the text is for you.

crc is always last. It is the CRC-32 of the line as it would read without it — the whole JSON object, closing } included — after which that brace is replaced by ,"crc":"…"}; to check a line by hand, cut the member and restore the brace. A line whose bytes changed after they were written — a bad sector, a partial overwrite, an editor — fails it and is not read at all: it counts as an unreadable line, and recovery halts the runner outright. That matters for the damage no reader of values could see, such as 005930 becoming 005931, a real instrument. So do not edit the file: a hand-edited line fails its checksum, and so does a key this library never wrote.

A journal this release begins says so in its header ("integrity":"crc32"), so every entry in it must carry a checksum. A journal begun by an earlier release keeps its checksum-less lines readable as they always were; once a checksummed line appears, a checksum-less one after it is damage. The one line this cannot protect is the first checksummed line of such a file, which could lose its whole crc member and pass as an older release's. It is a checksum against corruption, not a signature against someone rewriting the file on purpose.

Rolling back across this release is not supported on a live journal. An earlier release reads a checksummed line (it ignores crc) and appends checksum-less ones; this release then halts on them, as it should on a line it cannot vouch for. To roll back, or to roll forward again afterwards, start the other release on a new journal file, and only once the old journal may be given up. A resolved instruction is not enough for that: a sweep that found nothing writes resolved while a serialized runner still owes the order, and one that found the order names it while it may still be working. Every order the journal tracks must have ended (filled in full, or cancelled with its history row saying so) and holdings must show its final fills, as for rotation under "What this does not do yet".

Reading it under pressure

What you see What it means
pending then accepted the order exists, and order_id names it
pending then rejected the instruction was refused — by the broker, by the binding or client before it sent, or by local machinery that failed. The order does not exist. Nothing owed
pending then indeterminate the outcome is unknown (a failure in transit, an unreadable reply, an HTTP 5xx, or a broker refusal reported as PROTOCOL, which is most of them; Binding a broker names the refusals that are rejected instead). Still owed
a later confirmed a serialized runner accounted for the placement: holdings showed its fill, or its history showed it ended with nothing filled, or an operator released it. The placement is no longer owed; an operator's release does not clear a ledger hold, and the next order on the instrument waits for a holdings read received after it
a later held evidence contradicted the placement (holdings moved past what it can explain); only an operator can account for it, with Runner::release_obligations (see the operator procedure below)
pending then resolved a sweep read the broker's lists and closed the question. order_id says what it saw: a number, or nothing found. A second resolved line for the same id, with a number, is a later sweep finding the order
pending alone, at the end of the file the process died between the write and the answer
a last line with no newline the process died during the write
the runner halted on an unreadable line a line changed after it was written — its checksum or an unknown key says so — or a checksum-less line follows a checksummed one

The last two are the reason the format is line-oriented. A line and its newline are written in one call, so the newline is a commit marker: a torn write loses the tail of the buffer, and the missing newline says so. A file that was rewritten in place could instead be corrupt as a whole.

A torn line is not discarded. Where the symbol is still legible it is salvaged into an entry carrying requires_operator, which no sweep may decide — its id is rewritten so it pairs with no terminal line, its outcome is forced to pending whatever the text said, because outcome may itself be the field that was cut off, and the flag stops the matcher running window arithmetic on a timestamp that was never recorded. (Without the flag that arithmetic resolves it instantly: the missing timestamp reads as the epoch, so the window is long closed and no real row can fall inside it, and the first sweep would report not_placed.) Where the symbol is gone, recover_runner halts the runner outright, because there is no instrument to withhold and the startup gate is not enough — that gate lifts after one order-list read, and a read cannot answer a question nobody can pose.

Opening the journal repairs a torn tail before writing anything. Appending straight onto a line with no newline would glue the next record to the fragment; the combined text would then carry a newline, so the next replay would see one unreadable complete line and lose both. So opening terminates the fragment and writes the salvaged record beside it — and if that repair itself fails, open() is false.

The five verdicts

After recovery, each owed instruction is matched against the broker's own order list on every listing the source reads. runner::match_verdict_name prints these:

Verdict What it means What happens
matched exactly one row corresponds resolved; the ledger lets the instrument go, and the runner counts the order until holdings account for it
not_placed no row, and the window has closed resolved: no order was found. A runner that overlaps orders forgets the placement; a serialized runner keeps it owed (below)
ambiguous more than one row could be it, a row that fits is one the journal records for another of your placements, or a row that fits lacks the evidence to be told from another order nothing is resolved; the next sweep asks again, and you decide if it stays
undecided no row yet, window still open wait for the next sweep
needs_operator the entry came from a damaged line, or its line cannot be matched (a cancellation with no order id, a placement whose price does not fit its type) only you can resolve it

A resolution is written back to the journal before the ledger is cleared — the same discipline as writing before a send, applied to the other end. Without it the instruction would stay open on disk and every restart would withhold the instrument again for a question already answered. If that write-back fails, the hold deliberately stands and the runner halts.

The record's outcome is resolved, not accepted or rejected. Those are answers the broker gave to the instruction; a sweep learns something different — what the listing shows now. On a cancellation they can be opposites: a matched row means the target order was in the lists read, and still resting when no history was read with them, so accepted would claim a withdrawal that may plainly not have happened. What was observed is in order_id (set when a row was found, empty when none was) and spelled out in detail.

not_placed is a finding, not the broker's word. It says that no row of the lists read matched within the window. A list can lag an order it accepted, and no broker here publishes "every order accepted so far is listed". A serialized runner (Runner::serialized, the default for one that journals) therefore keeps a placement found not_placed owed, unnamed: the journal's question is closed, the instrument still places nothing, live::SessionSource looks for the order's dated row again at every order-list read, and failing that only Runner::release_obligations ends it. A loop stops placing altogether once such an order has been owed for ten minutes (PollingLoop::set_owed_order_limit; running a strategy live has the procedure). A runner that overlaps orders forgets the placement at not_placed, which is why its window must be generous.

not_placed and undecided are the pair to get right. The absence of a row says anything about the order only after enough time has passed for the broker's listing to reflect a write it accepted; read too early, it says nothing at all, and even then it is the finding described above and not proof. That is what the window is for, and it is three durations rather than one tolerance because it absorbs three unrelated things:

  • clock_skew_ — the journal's stamp is your clock, ordered_at_ is the broker's. Default 30s, and generous on purpose: too narrow does not fail loudly, it silently reports not_placed for an order that exists. For a runner that overlaps orders that is the worst outcome in this design, since it forgets the placement; a serialized runner keeps the order owed whatever the finding.
  • maximum_request_ — how long the bytes could have been in the air, for an instruction nobody answered. Default 60s. Where both lines exist the span is exact and this is unused.
  • listing_delay_ — how long the broker's listing may lag a write it accepted. Default 30s.

Which clock says the window has closed. The later of the runner's decision instant (the period end of the last bar fed, or a quote's time) and the loop's last reading of its own clock. So under a loop a restart can resolve not_placed at its first read instead of waiting for a bar, and on daily bars, where the decision instant is the previous session's end, a placement left in doubt is decided as the loop's clock passes the window, not at the next bar. A source driven without a loop has only the decision instant, and the wall clock stands in before anything was fed. Both have to be wall time on the journal's clock: one that runs ahead shortens the window.

ambiguous is a stop, not a retry. Nothing is written for it, and every sweep asks again, so an ambiguity that comes from missing evidence can end by itself (a Kiwoom order listed before its history row arrives matches once the row does). Two rows that could both be your order stay ambiguous however many times you sweep; no heuristic turns them into one answer. Meanwhile the instrument stays withheld, and a serialized run stops at the owed-order limit. Go and look, then clear the ledger entry yourself with ledger().resolve_after_reading_orders(instrument) — named for what it requires, because calling it without having read that list defeats the entire mechanism. If the order was this run's own, you must also tell the runner what you found: Runner::resolve_indeterminate_placement(instrument, side, order_id) with the broker's number, or with no number if it was never placed (a serialized runner takes a number only with the order's journal id, and ignores "never placed": use Runner::release_obligations). Otherwise the run keeps counting it as a reserved row with no reference until holdings show it (a resting order is then counted beside its own listed row, and a target may withdraw it), and if they never do, it withholds the instrument again once it is past the confirmation timeout from its send, at the next holdings read (counted in orders_unconfirmed_timed_out()).

What matching can and cannot do

It cannot manufacture idempotency. A journal narrows the window and makes ambiguity machine-visible. Only a broker can promise that a duplicate is discarded, and none of the three supported brokers offers that on a recovery path:

  • Toss accepts a clientOrderId on the order POST and echoes it on the acknowledgement — but its order listing rows carry no such field. The key is an idempotency guarantee on the write path, not a handle you can find an order by afterwards.
  • KIS has no equivalent outside gt_uid, documented corporate-only ([법인 전용]).
  • Kiwoom documents no caller-supplied order identifier; an order carrying one is refused before sending.

So placements are matched on attributes — instrument, side, type, quantity, price, and a timestamp inside the window — at every broker. That is a heuristic, and ambiguous is how it tells you so.

A row the journal already gives to another placement is not this one's. Two orders of the same shape sent within the window — the first accepted with its number, the second's request lost — leave the first order's row inside the second's window. The sweep does not name the lost order with that number: the row is the other placement's by its accepted line (or by an earlier sweep's name), so the verdict is ambiguous, never matched and never not_placed, and a serialized run stops for the order at the owed-order limit. A number counts as the other placement's only on a row inside that placement's own send window, since some brokers reuse order numbers on another day. A row with no time (every KIS real-account working order) cannot be placed outside that window, so where a number is reused, an older placement of the same shape that recorded it makes such a row ambiguous: you reconcile it.

A row whose day nobody stated proves no day. Kiwoom's working-order listing gives a time of day and no date; the adapter dates it today and says so (OrderSnapshot::ordered_date_assumed_), and the demo server has listed the previous day's order after midnight. Where the history a sweep reads has the order's own row (the same number at the same time of day, nothing either row states disagrees, and the price stated alike on both), that dated row stands for it, on its own evidence. Until then the listed row neither matches an order nor lets one be found absent (ambiguous), and the later search for an order still owed ignores it.

A placement is matched against order history, not against the open-orders listing. This is the subtlest thing in the whole design. Each broker's working-order inquiry returns only what is still resting: Toss maps it to its OPEN scope, KIS to the modifiable set, Kiwoom to its unfilled-order list (ka10075). A market or aggressive limit order can fill and leave that listing within seconds — well before the first sweep — so matching against open orders alone would find nothing, and once the window closed that absence would read as not_placed. The hold would clear for an order that existed and executed, and the runner would place it again. So any sweep with a placement owed reads the history too, with the calendar range KIS requires, the cursor paging Toss offers and one date at a time for Kiwoom, which refuses a range, and deduplicates by order id because a still-open order appears in both listings.

History is read only when something is owed, so an ordinary sweep costs no request of its own. And a history read that fails concludes nothing at all: absence from a listing that never arrived is not evidence, so every instruction stays owed.

A cancellation needs no history. It names an order id, and whether that order is still resting is exactly what the open listing answers (a sweep that also owes a placement reads history too, and then a matched row says only that the order was listed).

Cancellations are matched by identity, because a cancellation names an order that already exists, so its journal line carries the broker's own order_id from the moment it is written. ambiguous comes only from two rows carrying that id (an order number a broker reused on another day).

Two identical orders in the same second are already prevented, which is why second-granularity timestamps (KIS reports ord_tmd as HHMMSS) are not the problem they look like. Once an instrument is marked, the ledger withholds everything for it, so a second identical order cannot be sent while the first is unresolved.

What this does not do yet

  • Tell apart two of your own lost orders of one shape. Two placements of the same instrument, side, type, quantity and price whose replies were both lost can both be matched to one row. Only a runner that overlaps orders (Runner::allow_overlapping_orders) can have two such orders open at once.
  • Compaction or rotation. The file is append-only and grows. One trading day is small; a year is not. Rotate it yourself, and note that recovery reads what it is given — a journal rotated away is a journal that remembers nothing. The end of a session is not enough: rotate only once every order the journal tracks has ended (filled in full, or cancelled with its history row saying so) and holdings show its final fills, or an order still working can be sent again (Running a strategy live, "An order nobody can account for stops the run").
  • More than one writer, beyond one file. A second writer on the same file is refused on POSIX: opening takes an exclusive flock and holds it while the journal is open, so the same program started twice, or a supervisor starting a new copy while the old one still runs, stops at the journal with the reason in rejection() (example-live-program exits 2, nothing bound). The lock dies with its process (a child forked without exec shares it while it lives), and belongs to the file, not the path: rotating or deleting the journal while a runner still has it open leaves a new file the next process takes. Two runners on one account through different journal paths are not caught – that is the account, not the file – and Windows takes no lock. Keep the journal on a local disk: on NFS, Linux turns flock into a lock the journal's own reads release. The journal does check the account: it is opened with a scope such as "kis:live:12345678-01", written into a header before the first order, and a file whose header names a different account refuses to open — because symbol, side, quantity and price can all agree across two accounts trading the same instrument. A non-empty file with no header is refused too: its ownership cannot be established, and that is not a question to guess at while holding a file that may name another account's live orders.
  • Repairing a repair. Terminating a torn fragment and recording its stand-in are two writes, so a process killed between them leaves the fragment as a complete unreadable line. That is detected — any unreadable line halts recovery outright — but it is detected rather than repaired. A sidecar or a two-phase protocol would give stronger guarantees.
  • Survive a power cut on macOS. fsync there returns once the data reaches the drive, not once the drive has committed it. A killed process — which is the threat this exists for — is covered on every platform.

Trying it without a broker

examples/example-order-journal.cpp runs the whole story with no credentials, no network and no signal raised: a send that never returns, a file holding one pending line, a fresh ledger that would happily trade until recovery stops it, and two sweeps that turn undecided into matched.