Order gateway reference: idempotent order placement in Python
A small trading bot architecture example you can download and run. It places orders idempotently, retries only when that is safe, and reconciles after a crash, all against a local mock exchange. It is a teaching reference that shows how Fincode thinks about failure. It is not production software, it is not a product, and it has not been used for a client.
One order, from request to a known state
An order gateway sits between the code that decides and the exchange. This one is about 2,000 lines of Python 3.11, counting the mock exchange and the demo, plus about 1,000 lines of tests, standard library only, under the MIT license. It accepts a typed order request, applies risk limits outside the rule code, assigns an idempotent client order ID, writes a journal, sends the order to a local mock exchange and logs every step as JSON lines.
The mock exchange is deterministic. A test can tell it to answer with a 5xx, a 429 with Retry-After, a timeout, a partial fill or a lost response. The tests script those failures, and kill a real process in the middle of an order, to check what the gateway does next.
A mock exchange is not a live market. The results on this page show how this code behaves when the scripted failures happen. They say nothing about trading results, and trades can lose money.
What it is not
- Production software or a product
- Used for a client: it was written for this page
- Connected to anything: the client refuses any host but this machine, and there are no keys
- A strategy: nothing here decides what to trade
- Evidence about trading results
The path of an order
- 1
Typed request
A caller builds an OrderRequest. Amounts are Decimal, floats are refused, and limit orders are the only kind.
- 2
Checks under one lock
Duplicate request, kill switch, a clean reconciliation, no unknown orders, then the risk limits. A refusal is journaled and nothing is sent.
- 3
Journal, then send
The journal row is committed first. The POST carries a client order ID derived from the request ID.
- 4
A known state
Each result becomes a typed outcome. If the result is unclear, the gateway reads the order's status before it does anything else.
How to run it
You need Python 3.11 or newer and nothing else. Everything listens on 127.0.0.1, and no account or key is involved. Use python3 where python is an older version.
Download the zip, unzip it and run:
cd fincode-order-gateway-reference
python -m unittest -v
python demo.py
python demo.py lost_response --raw
python demo.py walks through ten failure cases and prints the same text on every run, so you can compare it line by line. --raw shows the JSON lines as written, with timestamps. The README lists the files and the order states. The same zip also holds flawed_demo/, a small bot written wrong on purpose for the sample code review report; its own README explains how to reproduce each defect.
The decisions behind the code
Rule code cannot reach the exchange
The code that decides gets a gateway and calls submit(). It is never given the exchange client, so it cannot skip the journal, the kill switch or the risk limits by accident.
The journal comes first
A SQLite row is committed before the first send, with
synchronous=EXTRA, because SQLite's documentation (opens in a new tab), read on November 5, 2026, says FULL is not necessarily durable across a power loss in the rollback-journal mode used here. If that write fails, nothing is sent. After a crash, the journal lists every order that could exist at the exchange, including the one the process died on.The client order ID is derived, not generated
It comes from a SHA-256 of a namespace and the caller's request ID, so the same request gets the same ID in any process after any restart. On a platform that enforces unique client order IDs, a repeat cannot become a second order.
One request ID, one outcome
A repeat gets the stored result, including a refusal. A refused order needs a new decision and a new request ID, because the reason behind the old one has gone stale. The same request ID with different contents raises an error, since that is a bug in the caller.
Unknown is a state, not a failure
After a timeout or a 5xx the order may exist, and treating it as failed is how a retry places it twice. The gateway asks the exchange. With no usable answer the order stays UNKNOWN, and new orders are refused until it is resolved. A send cut short by an error on this machine, such as a failed log write, blocks new orders the same way.
Absence is checked twice
A read just after a write can lag, so one 404 is not believed. Even then, a re-send carries the same client order ID, so an order that was only delayed gets a 409 and not a twin.
Fills are cumulative and only go up
The exchange reports a running total, and the journal stores each amount exactly as reported. Reading it twice, or reading an old report, cannot count a fill twice. A partial fill is not an error: the remainder stays at the exchange until it fills or is canceled, and it is never re-sent.
Limits assume the worst
Limits run outside the rule code, and the check and the journal write happen under one lock. The position limit counts every unfilled and unknown order as filled, on the side the new order pushes, so an order that reduces a position can still go. The limits have no default values, and the numbers in EXAMPLE_LIMITS exist only so the tests and the demo have something to exercise. They are not recommended settings.
The gateway starts closed
New orders are refused until a reconciliation run comes back clean, because a restart does not clear doubt. The run reads each live order by client order ID, then lists working orders to find any the journal does not know.
A crashed order is not sent later
If reconciliation finds that a journaled order never reached the exchange, it becomes NOT_PLACED and is not sent. The decision behind it is old, and making a new one is the caller's job. If the order turns up at the exchange later after all, the next run cancels it.
The kill switch is a file
Any program or person can create it, and it survives a restart. New orders stop at once and a retry in progress stops before its next send. Working orders are canceled by the gateway's own call, or by the next reconciliation run while the file exists, so a file created from outside cancels nothing by itself. An order on its way when the switch trips is canceled as soon as its answer is known. If the file cannot be checked, the switch counts as on.
When a retry is safe
RFC 9110, section 9.2.2 (opens in a new tab) says a client should not automatically retry a request with a non-idempotent method, such as POST, unless it can tell that the request is idempotent anyway or that the original was never applied. Here the client order ID covers the first condition and the status read covers the second.
| What came back | May the exchange have acted? | What the gateway does |
|---|---|---|
| 2xx with a readable order | Yes, and the answer says how | Records it. |
| Other 4xx | No | REJECTED. Never retried. If an earlier send may have landed, it reads the status first. |
| 429 | Treated as no | Waits for Retry-After, in seconds or as a date, then sends the same client order ID, within a deadline. If it gives up, it reads the status first. |
| 5xx, timeout, lost connection, unreadable 2xx | Maybe | UNKNOWN. Reads the status by client order ID. Found: records it. Absent twice: sends the same client order ID again. No usable answer: stays UNKNOWN. |
| 409 on a send | Yes | UNKNOWN, then reads the status. Never sends again. |
| Connection never opened | No | Backs off and tries again. |
The Retry-After forms come from RFC 9110, section 10.2.3 (opens in a new tab), and RFC 6585, section 4 (opens in a new tab) defines the 429 status; both read on November 5, 2026. Section 9.2.2 also says a client should not automatically retry a failed automatic retry. The default policy goes past that, up to four POSTs, because each re-send follows a status read that found nothing, a 429 or a connection that never opened. max_send_attempts=2 allows one retry and no more.
A real platform documents its own behavior. Re-sending is only safe where the platform enforces unique client order IDs, and only for the period it enforces them. Where that does not hold, max_send_attempts=1 ends an unclear send with a status read and sends nothing more.
Key excerpts
Copied from the download without changes.
The idempotent client order ID (gateway/models.py)
def client_order_id_for(namespace: str, request_id: str) -> str:
"""Derive the client order ID. Same input, same ID, in any process, after any restart."""
digest = hashlib.sha256(f"{namespace}\0{request_id}".encode("utf-8")).hexdigest()
return CLIENT_ORDER_ID_PREFIX + digest[:30]
Why an order is refused before it is sent (gateway/core.py)
This runs under the same lock as the journal write. The limit check knows nothing about why the order was requested.
def _refusal(self, request: OrderRequest) -> str | None:
if self._kill_switch.engaged():
return "kill_switch"
if not self._reconciled:
return "not_reconciled"
if self._journal.has_state(OrderState.UNKNOWN):
return "unresolved_orders"
if self._has_abandoned_send():
return "unresolved_orders"
return check_limits(request, self._limits, self.exposure(request.symbol))
def _has_abandoned_send(self) -> bool:
# SUBMITTING with no send in progress: the send ended in an error on this machine,
# such as a failed log write, so the order's fate is unknown until reconcile().
return any(
record.client_order_id not in self._in_flight
for record in self._journal.in_states((OrderState.SUBMITTING,))
)
The send loop and the retry rule (gateway/core.py)
def _send_until_known(self, cid: str) -> OrderRecord:
policy = self._policy
deadline = self._monotonic() + policy.send_deadline_seconds
may_have_reached_exchange = False
for attempt in range(1, policy.max_send_attempts + 1):
if self._kill_switch.engaged():
return self._stop_sending(cid, "kill_switch", may_have_reached_exchange)
# Write-ahead: if the process dies during the call, recovery knows to look.
record = self._update(
cid, state=OrderState.SUBMITTING, send_attempts=attempt, reason=None
)
self._log.event("send_attempt", client_order_id=cid, attempt=attempt)
result = self._client.place(record)
self._log_call("send_result", cid, result, attempt=attempt)
self._crash_point("after_send_before_ack")
if result.order is not None:
return self._apply_view(cid, result.order)
if result.outcome in (CallOutcome.REFUSED, CallOutcome.NOT_FOUND):
reason = result.detail or f"http_{result.http_status}"
if may_have_reached_exchange:
# An earlier send may have landed, and this refusal says nothing about it.
return self._stop_sending(cid, reason, may_have_reached_exchange)
return self._update(cid, state=OrderState.REJECTED, reason=reason)
if result.outcome is not CallOutcome.NOT_SENT:
may_have_reached_exchange = True
if result.outcome is CallOutcome.CONFLICT or result.outcome in AMBIGUOUS:
# The call may have worked. Ask before doing anything else.
self._update(cid, state=OrderState.UNKNOWN, reason=result.outcome.value)
answer = self._read_status(cid)
if answer.order is not None:
return self._apply_view(cid, answer.order)
if not answer.absent or result.outcome is CallOutcome.CONFLICT:
# No usable answer, or the exchange contradicts itself. Do not guess.
self._log.event("order_unknown", client_order_id=cid)
return self._journal.require(cid)
# Confirmed absent. The next POST carries the same client order ID, so if
# the last one was only delayed, the exchange answers 409, not a second order.
if attempt == policy.max_send_attempts:
break
wait = self._wait_after(result, policy.backoff(attempt))
if self._monotonic() + wait > deadline:
return self._stop_sending(cid, "deadline", may_have_reached_exchange)
self._log.event("retry_wait", client_order_id=cid, seconds=wait, because=result.outcome)
self._sleep(wait)
return self._stop_sending(cid, "attempts_exhausted", may_have_reached_exchange)
Order reconciliation example (gateway/core.py)
The first half of reconcile(): every order that may be live is read by client order ID. The second half lists working orders, to catch orders the journal does not know and orders reported as not placed that arrived late.
for record in targets:
cid = record.client_order_id
answer = self._read_status(cid)
if answer.order is not None:
updated = self._apply_view(cid, answer.order)
elif answer.absent and record.exchange_order_id is None:
updated = self._update(
cid, state=OrderState.NOT_PLACED, reason="absent_at_exchange"
)
elif answer.absent:
# The exchange acknowledged this order once and now denies it. Nothing here
# can explain that, so it stays open for a person to look at.
updated = self._update(
cid, state=OrderState.UNKNOWN, reason="acknowledged_order_missing"
)
else:
unresolved.append(cid)
A lost response, settled by reconciliation (tests/test_gateway.py)
The answer to the POST is lost and every status read fails. The order stays UNKNOWN and new orders are refused. The next reconciliation run finds the order, and nothing is sent a second time.
def test_lost_response_then_reconciliation_finds_the_order(self) -> None:
# The answer to the POST is lost and every status read fails, so the order stays
# UNKNOWN. A later reconciliation run finds it, and nothing is sent a second time.
self.exchange.script("place", lost_response())
self.exchange.script("status", *[server_error(503)] * 4)
gateway = self.gateway()
result = gateway.submit(order())
self.assertIs(result.order.state, OrderState.UNKNOWN)
self.assertEqual(gateway.submit(order("req-2")).order.reason, "unresolved_orders")
report = gateway.reconcile()
self.assertEqual(report.resolved, (cid_of(),))
self.assertTrue(report.clean)
record = gateway.get_order("req-1")
self.assertIs(record.state, OrderState.OPEN)
at_exchange = self.exchange.orders()[0]
self.assertEqual(record.exchange_order_id, at_exchange["exchange_order_id"])
self.assertEqual(self.posts(), 1)
self.assertOneOrderAtExchange()
A real crash in a test (gateway/crash_child.py and tests/test_gateway.py)
The child process submits an order and calls os._exit() at the named point, with no cleanup. The test then opens a new gateway on what survived on disk.
def die_at_the_named_point(point: str) -> None:
if point == args.point:
os._exit(CRASH_EXIT_CODE)
def test_crash_after_send_order_is_found_and_adopted(self) -> None:
self.crash_at("after_send_before_ack", order())
self.assertEqual(self.posts(), 1)
with self.opened() as gateway:
before = gateway.get_order("req-1")
self.assertIs(before.state, OrderState.SUBMITTING)
self.assertEqual(before.send_attempts, 1) # On disk before the POST left.
report = gateway.reconcile()
self.assertEqual(report.resolved, (cid_of(),))
record = gateway.get_order("req-1")
self.assertIs(record.state, OrderState.OPEN)
at_exchange = self.exchange.orders()[0]
self.assertEqual(record.exchange_order_id, at_exchange["exchange_order_id"])
self.assertEqual(self.posts(), 1)
self.assertOneOrderAtExchange()
The real test output
Run on November 5, 2026 with Python 3.11.3 on Windows 11 (version 10.0.26300), from a fresh unzip of the download. Pasted unedited. Times will differ on your machine.
> python --version
Python 3.11.3
> python -m unittest -v
test_concurrent_duplicates_send_one_order (tests.test_gateway.DuplicateRequest.test_concurrent_duplicates_send_one_order) ... ok
test_duplicate_request_sends_one_order (tests.test_gateway.DuplicateRequest.test_duplicate_request_sends_one_order) ... ok
test_same_amount_written_differently_is_the_same_request (tests.test_gateway.DuplicateRequest.test_same_amount_written_differently_is_the_same_request) ... ok
test_same_request_id_with_different_order_is_refused (tests.test_gateway.DuplicateRequest.test_same_request_id_with_different_order_is_refused) ... ok
test_5xx_then_status_unavailable_is_unknown_not_failed (tests.test_gateway.ExchangeServerError.test_5xx_then_status_unavailable_is_unknown_not_failed) ... ok
test_5xx_with_order_absent_resends_same_client_order_id (tests.test_gateway.ExchangeServerError.test_5xx_with_order_absent_resends_same_client_order_id) ... ok
test_5xx_with_order_present_does_not_resend (tests.test_gateway.ExchangeServerError.test_5xx_with_order_present_does_not_resend) ... ok
test_one_attempt_policy_ends_with_a_status_read_and_never_sends_again (tests.test_gateway.ExchangeServerError.test_one_attempt_policy_ends_with_a_status_read_and_never_sends_again) ... ok
test_repeated_5xx_stops_at_the_attempt_limit (tests.test_gateway.ExchangeServerError.test_repeated_5xx_stops_at_the_attempt_limit) ... ok
test_retry_after_on_503_is_honored (tests.test_gateway.ExchangeServerError.test_retry_after_on_503_is_honored) ... ok
test_unknown_order_blocks_new_orders_until_reconciled (tests.test_gateway.ExchangeServerError.test_unknown_order_blocks_new_orders_until_reconciled) ... ok
test_kill_file_created_from_outside_blocks_at_once (tests.test_gateway.KillSwitchBehavior.test_kill_file_created_from_outside_blocks_at_once) ... ok
test_kill_switch_before_the_first_send_sends_nothing (tests.test_gateway.KillSwitchBehavior.test_kill_switch_before_the_first_send_sends_nothing) ... ok
test_kill_switch_blocks_new_orders_and_cancels_working_ones (tests.test_gateway.KillSwitchBehavior.test_kill_switch_blocks_new_orders_and_cancels_working_ones) ... ok
test_kill_switch_during_a_retry_finds_and_cancels_an_order_that_landed (tests.test_gateway.KillSwitchBehavior.test_kill_switch_during_a_retry_finds_and_cancels_an_order_that_landed) ... ok
test_kill_switch_fails_closed_when_it_cannot_be_read (tests.test_gateway.KillSwitchBehavior.test_kill_switch_fails_closed_when_it_cannot_be_read) ... ok
test_kill_switch_stops_a_retry_in_progress (tests.test_gateway.KillSwitchBehavior.test_kill_switch_stops_a_retry_in_progress) ... ok
test_kill_switch_survives_a_restart (tests.test_gateway.KillSwitchBehavior.test_kill_switch_survives_a_restart) ... ok
test_order_on_its_way_when_the_switch_trips_is_canceled (tests.test_gateway.KillSwitchBehavior.test_order_on_its_way_when_the_switch_trips_is_canceled) ... ok
test_reconcile_cancels_working_orders_while_the_switch_is_on (tests.test_gateway.KillSwitchBehavior.test_reconcile_cancels_working_orders_while_the_switch_is_on) ... ok
test_release_opens_new_orders_but_not_old_requests (tests.test_gateway.KillSwitchBehavior.test_release_opens_new_orders_but_not_old_requests) ... ok
test_every_step_is_one_json_line (tests.test_gateway.Logging.test_every_step_is_one_json_line) ... ok
test_log_written_before_a_crash_survives_it (tests.test_gateway.Logging.test_log_written_before_a_crash_survives_it) ... ok
test_conflict_with_no_readable_order_stays_unknown (tests.test_gateway.LostResponse.test_conflict_with_no_readable_order_stays_unknown) ... ok
test_garbled_200_is_not_treated_as_success (tests.test_gateway.LostResponse.test_garbled_200_is_not_treated_as_success) ... ok
test_lost_response_then_reconciliation_finds_the_order (tests.test_gateway.LostResponse.test_lost_response_then_reconciliation_finds_the_order) ... ok
test_lost_response_then_status_read_finds_the_order (tests.test_gateway.LostResponse.test_lost_response_then_status_read_finds_the_order) ... ok
test_order_visible_late_is_not_resent (tests.test_gateway.LostResponse.test_order_visible_late_is_not_resent) ... ok
test_refused_resend_after_false_absence_reads_before_deciding (tests.test_gateway.LostResponse.test_refused_resend_after_false_absence_reads_before_deciding) ... ok
test_resend_after_false_absence_cannot_make_a_second_order (tests.test_gateway.LostResponse.test_resend_after_false_absence_cannot_make_a_second_order) ... ok
test_timeout_with_order_accepted_does_not_resend (tests.test_gateway.LostResponse.test_timeout_with_order_accepted_does_not_resend) ... ok
test_timeout_with_order_not_accepted_resends_same_id (tests.test_gateway.LostResponse.test_timeout_with_order_not_accepted_resends_same_id) ... ok
test_same_script_gives_the_same_behavior (tests.test_gateway.MockExchangeBehavior.test_same_script_gives_the_same_behavior) ... ok
test_cancel_keeps_the_partial_fill (tests.test_gateway.PartialFill.test_cancel_keeps_the_partial_fill) ... ok
test_cancel_racing_a_fill_ends_filled (tests.test_gateway.PartialFill.test_cancel_racing_a_fill_ends_filled) ... ok
test_cancel_with_lost_response_learns_the_result_by_reading (tests.test_gateway.PartialFill.test_cancel_with_lost_response_learns_the_result_by_reading) ... ok
test_fill_with_more_digits_than_decimal_precision_is_kept_exactly (tests.test_gateway.PartialFill.test_fill_with_more_digits_than_decimal_precision_is_kept_exactly) ... ok
test_partial_fill_in_the_order_response (tests.test_gateway.PartialFill.test_partial_fill_in_the_order_response) ... ok
test_partial_fill_is_tracked_and_not_resent (tests.test_gateway.PartialFill.test_partial_fill_is_tracked_and_not_resent) ... ok
test_reading_the_same_fill_twice_does_not_count_it_twice (tests.test_gateway.PartialFill.test_reading_the_same_fill_twice_does_not_count_it_twice) ... ok
test_stale_status_does_not_lower_the_fill (tests.test_gateway.PartialFill.test_stale_status_does_not_lower_the_fill) ... ok
test_stale_status_does_not_reopen_a_closed_order (tests.test_gateway.PartialFill.test_stale_status_does_not_reopen_a_closed_order) ... ok
test_429_asking_past_the_deadline_stops_and_sends_nothing_more (tests.test_gateway.RateLimit.test_429_asking_past_the_deadline_stops_and_sends_nothing_more) ... ok
test_429_past_the_deadline_with_status_unavailable_is_unknown_not_failed (tests.test_gateway.RateLimit.test_429_past_the_deadline_with_status_unavailable_is_unknown_not_failed) ... ok
test_429_waits_retry_after_seconds_then_sends_same_id (tests.test_gateway.RateLimit.test_429_waits_retry_after_seconds_then_sends_same_id) ... ok
test_429_with_http_date_waits_until_that_time (tests.test_gateway.RateLimit.test_429_with_http_date_waits_until_that_time) ... ok
test_429_without_retry_after_uses_backoff (tests.test_gateway.RateLimit.test_429_without_retry_after_uses_backoff) ... ok
test_parse_retry_after_forms (tests.test_gateway.RateLimit.test_parse_retry_after_forms) ... ok
test_status_read_asked_to_wait_too_long_gives_up_and_stays_unknown (tests.test_gateway.RateLimit.test_status_read_asked_to_wait_too_long_gives_up_and_stays_unknown) ... ok
test_acknowledged_order_missing_stays_unknown (tests.test_gateway.RestartRecovery.test_acknowledged_order_missing_stays_unknown) ... ok
test_crash_after_send_order_is_found_and_adopted (tests.test_gateway.RestartRecovery.test_crash_after_send_order_is_found_and_adopted) ... ok
test_crash_before_send_order_is_not_placed_and_not_resent (tests.test_gateway.RestartRecovery.test_crash_before_send_order_is_not_placed_and_not_resent) ... ok
test_fills_during_downtime_are_picked_up (tests.test_gateway.RestartRecovery.test_fills_during_downtime_are_picked_up) ... ok
test_late_order_that_does_not_match_keeps_the_run_unclean (tests.test_gateway.RestartRecovery.test_late_order_that_does_not_match_keeps_the_run_unclean) ... ok
test_late_order_the_cancel_cannot_stop_keeps_the_run_unclean (tests.test_gateway.RestartRecovery.test_late_order_the_cancel_cannot_stop_keeps_the_run_unclean) ... ok
test_listing_failure_keeps_the_gateway_closed (tests.test_gateway.RestartRecovery.test_listing_failure_keeps_the_gateway_closed) ... ok
test_order_arriving_after_not_placed_is_found_and_canceled (tests.test_gateway.RestartRecovery.test_order_arriving_after_not_placed_is_found_and_canceled) ... ok
test_order_that_does_not_match_the_journal_stays_unknown (tests.test_gateway.RestartRecovery.test_order_that_does_not_match_the_journal_stays_unknown) ... ok
test_orphan_order_keeps_the_gateway_closed_until_handled (tests.test_gateway.RestartRecovery.test_orphan_order_keeps_the_gateway_closed_until_handled) ... ok
test_other_software_orders_are_not_called_orphans (tests.test_gateway.RestartRecovery.test_other_software_orders_are_not_called_orphans) ... ok
test_reconcile_leaves_an_order_in_flight_alone (tests.test_gateway.RestartRecovery.test_reconcile_leaves_an_order_in_flight_alone) ... ok
test_restart_refuses_new_orders_until_reconciled (tests.test_gateway.RestartRecovery.test_restart_refuses_new_orders_until_reconciled) ... ok
test_restart_with_exchange_unavailable_stays_closed_then_recovers (tests.test_gateway.RestartRecovery.test_restart_with_exchange_unavailable_stays_closed_then_recovers) ... ok
test_same_client_id_with_a_new_exchange_order_id_stays_unknown (tests.test_gateway.RestartRecovery.test_same_client_id_with_a_new_exchange_order_id_stays_unknown) ... ok
test_same_request_after_a_crash_replays_and_sends_nothing (tests.test_gateway.RestartRecovery.test_same_request_after_a_crash_replays_and_sends_nothing) ... ok
test_same_request_after_crash_before_send_stays_not_placed (tests.test_gateway.RestartRecovery.test_same_request_after_crash_before_send_stays_not_placed) ... ok
test_blocked_request_stays_blocked_when_repeated (tests.test_gateway.RiskLimitsOutsideTheRules.test_blocked_request_stays_blocked_when_repeated) ... ok
test_concurrent_orders_cannot_overrun_the_position_limit (tests.test_gateway.RiskLimitsOutsideTheRules.test_concurrent_orders_cannot_overrun_the_position_limit) ... ok
test_limits_have_no_default_values (tests.test_gateway.RiskLimitsOutsideTheRules.test_limits_have_no_default_values) ... ok
test_notional_limit (tests.test_gateway.RiskLimitsOutsideTheRules.test_notional_limit) ... ok
test_order_reducing_a_position_passes_when_over_a_lowered_limit (tests.test_gateway.RiskLimitsOutsideTheRules.test_order_reducing_a_position_passes_when_over_a_lowered_limit) ... ok
test_oversized_quantity_rejected_before_the_exchange (tests.test_gateway.RiskLimitsOutsideTheRules.test_oversized_quantity_rejected_before_the_exchange) ... ok
test_position_limit_applies_to_sells_too (tests.test_gateway.RiskLimitsOutsideTheRules.test_position_limit_applies_to_sells_too) ... ok
test_position_limit_counts_open_orders_as_filled (tests.test_gateway.RiskLimitsOutsideTheRules.test_position_limit_counts_open_orders_as_filled) ... ok
test_symbol_not_on_the_allowed_list (tests.test_gateway.RiskLimitsOutsideTheRules.test_symbol_not_on_the_allowed_list) ... ok
test_answer_nested_too_deep_is_unreadable_not_a_crash (tests.test_gateway.SafetyOfTheClient.test_answer_nested_too_deep_is_unreadable_not_a_crash) ... ok
test_clear_refusal_is_not_retried (tests.test_gateway.SafetyOfTheClient.test_clear_refusal_is_not_retried) ... ok
test_client_refuses_any_host_but_this_machine (tests.test_gateway.SafetyOfTheClient.test_client_refuses_any_host_but_this_machine) ... ok
test_exchange_down_means_not_sent_and_no_order (tests.test_gateway.SafetyOfTheClient.test_exchange_down_means_not_sent_and_no_order) ... ok
test_journal_failure_stops_the_send (tests.test_gateway.SafetyOfTheClient.test_journal_failure_stops_the_send) ... ok
test_journal_uses_the_sync_setting_meant_to_survive_a_power_loss (tests.test_gateway.SafetyOfTheClient.test_journal_uses_the_sync_setting_meant_to_survive_a_power_loss) ... ok
test_log_failure_after_the_journal_write_leaves_the_order_to_reconcile (tests.test_gateway.SafetyOfTheClient.test_log_failure_after_the_journal_write_leaves_the_order_to_reconcile) ... ok
test_send_cut_short_by_an_error_blocks_new_orders_until_reconciled (tests.test_gateway.SafetyOfTheClient.test_send_cut_short_by_an_error_blocks_new_orders_until_reconciled) ... ok
test_startup_with_exchange_down_stays_closed (tests.test_gateway.SafetyOfTheClient.test_startup_with_exchange_down_stays_closed) ... ok
test_bad_amounts_are_refused (tests.test_gateway.TypedRequest.test_bad_amounts_are_refused) ... ok
test_bad_side_and_request_id_are_refused (tests.test_gateway.TypedRequest.test_bad_side_and_request_id_are_refused) ... ok
test_client_order_id_is_derived_and_stable (tests.test_gateway.TypedRequest.test_client_order_id_is_derived_and_stable) ... ok
test_data_that_is_not_a_mapping_is_refused (tests.test_gateway.TypedRequest.test_data_that_is_not_a_mapping_is_refused) ... ok
test_float_amount_is_refused (tests.test_gateway.TypedRequest.test_float_amount_is_refused) ... ok
test_unknown_or_missing_field_is_refused (tests.test_gateway.TypedRequest.test_unknown_or_missing_field_is_refused) ... ok
test_valid_mapping_becomes_a_typed_request (tests.test_gateway.TypedRequest.test_valid_mapping_becomes_a_typed_request) ... ok
----------------------------------------------------------------------
Ran 91 tests in 11.915s
OK
The same zip also passed on Linux, with Python 3.12.13 on Debian 13, in a Docker container started with --network none, so loopback was the only network interface. It has not been run on macOS or on Python 3.13 and later.
One failure in the JSON lines log
The mock takes the order and the response is lost. The gateway records UNKNOWN, reads the status, finds the order and records OPEN, after one POST. Also pasted unedited, from the same run.
> python demo.py lost_response --raw
== lost_response: The exchange takes the order and the response is lost
{"ts":"2026-11-05T19:15:53.552Z","seq":1,"event":"reconcile_started","orders_to_check":0}
{"ts":"2026-11-05T19:15:53.566Z","seq":2,"event":"working_orders_listed","outcome":"OK","count":0}
{"ts":"2026-11-05T19:15:53.566Z","seq":3,"event":"reconcile_finished","clean":true,"resolved":0,"unresolved":0,"orphans":0,"late_orders":0}
{"ts":"2026-11-05T19:15:53.577Z","seq":4,"event":"request_received","request_id":"demo-5","client_order_id":"gw1b17e0de431d9bba9a01b13aaa4e4d","symbol":"DEMO-USD","side":"BUY","quantity":"1","limit_price":"20"}
{"ts":"2026-11-05T19:15:53.583Z","seq":5,"event":"order_journaled","client_order_id":"gw1b17e0de431d9bba9a01b13aaa4e4d","state":"SUBMITTING"}
{"ts":"2026-11-05T19:15:53.588Z","seq":6,"event":"send_attempt","client_order_id":"gw1b17e0de431d9bba9a01b13aaa4e4d","attempt":1}
{"ts":"2026-11-05T19:15:53.591Z","seq":7,"event":"send_result","client_order_id":"gw1b17e0de431d9bba9a01b13aaa4e4d","outcome":"CONNECTION_LOST","detail":"RemoteDisconnected","attempt":1}
{"ts":"2026-11-05T19:15:53.596Z","seq":8,"event":"state_changed","client_order_id":"gw1b17e0de431d9bba9a01b13aaa4e4d","from_state":"SUBMITTING","to_state":"UNKNOWN","reason":"CONNECTION_LOST"}
{"ts":"2026-11-05T19:15:53.599Z","seq":9,"event":"status_read","client_order_id":"gw1b17e0de431d9bba9a01b13aaa4e4d","outcome":"OK","http_status":200,"read":1}
{"ts":"2026-11-05T19:15:53.603Z","seq":10,"event":"state_changed","client_order_id":"gw1b17e0de431d9bba9a01b13aaa4e4d","from_state":"UNKNOWN","to_state":"OPEN"}
result: state=OPEN filled=0 reason=None POST calls=1 orders at exchange=1
1 of 1 scenarios behaved as expected.
A mock exchange is not a live market. This output says nothing about trading results.
A process that dies after the POST
Lines 1 to 7 come from the process that died, before it could record the answer. A new order is refused in line 9. The new process finds the order at the exchange in line 11, and the repeat in line 15 sends nothing.
> python demo.py crash_after_send
== crash_after_send: The process dies after the POST, before recording the answer
1 reconcile_started orders_to_check=0
2 working_orders_listed outcome=OK count=0
3 reconcile_finished clean=True resolved=0 unresolved=0 orphans=0 late_orders=0
4 request_received request_id=demo-10 side=BUY quantity=1 limit_price=20
5 order_journaled state=SUBMITTING
6 send_attempt attempt=1
7 send_result outcome=OK http_status=200 attempt=1
8 request_received request_id=demo-11 side=BUY quantity=1 limit_price=20
9 order_blocked reason=not_reconciled
10 reconcile_started orders_to_check=1
11 status_read outcome=OK http_status=200 read=1
12 state_changed from_state=SUBMITTING to_state=OPEN
13 working_orders_listed outcome=OK count=1
14 reconcile_finished clean=True resolved=1 unresolved=0 orphans=0 late_orders=0
15 request_received request_id=demo-10 side=BUY quantity=1 limit_price=20
16 duplicate_request state=OPEN
result: state=OPEN filled=0 reason=None POST calls=1 orders at exchange=1
1 of 1 scenarios behaved as expected.
A mock exchange is not a live market. This output says nothing about trading results.
What reviewing this code found
The first version passed its own tests and still had faults. Reading the code again, checking it against the documentation it relies on and running failures the tests did not script found these. Each code fix has a test in the download.
- A reconciliation run could report clean when an order arrived late and did not match the journal, or when the cancel for a late order did not land. Those runs now report the order as unresolved.
- When a client order ID came back with a different exchange order ID, the answer was merged into the old record. It now counts as a mismatch, and the order stays UNKNOWN.
- An order on its way when the kill switch tripped could land and keep working, because the gateway's cancels only see orders the exchange has acknowledged. It is now canceled as soon as its answer is known.
- If the log write failed just after the journal write, the order stayed marked as in flight, so reconciliation skipped it and could still report clean. Reconciliation now picks it up.
- The journal used SQLite's FULL setting, which SQLite's documentation says is not necessarily durable across a power loss in this journal mode. It now uses EXTRA. A test checks the setting, and no test cuts the power.
- The helper that opens a gateway fell back to the example limits, while this page said the limits had no default values. A caller must now pass them.
- Data that was not a mapping raised a TypeError instead of being refused as an invalid request.
- The test named for a lost response followed by reconciliation never called the reconciliation code. It was renamed, and a test that does call it was added.
- The README told readers to give each gateway on one account its own namespace. Two gateways would have stayed closed, each reporting the other's orders. That advice is gone, and the limit is stated below.
- A re-send refused for another reason, such as an insufficient balance, was recorded as REJECTED even when an earlier send of the same order might have landed. The gateway now reads the status before it decides.
- A send cut short by an error on this machine, such as a failed log write after the POST, left the order SUBMITTING while new orders were still accepted. New orders are now refused until reconciliation settles it.
- An answer nested too deeply to parse raised an exception out of the client instead of counting as an unreadable answer.
- Amounts with more than 28 significant digits were rounded on their way into the journal, so the same report read again could look older than the journal and be ignored. Amounts are now stored exactly.
The tests were checked too. In a scratch copy, faults were put into the code one at a time, such as ignoring Retry-After or skipping the kill switch check before a send. The tests caught 33 of the first 35. The two they missed showed real gaps: an amount passed as a float with an exact binary form, such as 0.5, and a journal update before a send that was never committed. Tests for both were added. A longer list of 65 faults, including the reverse of each later fix, was then run against the final code, and the tests caught all 65. That list was a scratch script and is not in the download.
What it leaves out
A reference this size has to stop somewhere. These gaps are deliberate, and real software needs each of them closed.
What a mock cannot show
Real latency, a platform's real error codes, how it treats a repeated client order ID and for how long, its maintenance windows and rate-limit weights, or anything about prices, liquidity and fills.
Platform details
Request signing, API keys, clock checks, a platform's own error codes, symbol rules, and lot and price rounding. For how Fincode software treats keys, see security and API key handling.
Market orders
A market order has no price, so the notional limit would need a live reference price.
Balance checks
Reconciliation compares orders, not balances. If an order is declared absent, then arrives late and closes before the next run, it is not in the list of working orders. Only a balance check would show it.
More limits
A daily loss limit and a limit on orders per minute belong in the same layer as the limits here.
Streaming, schedules and alerts
Fills are read by polling, and reconciliation runs only when the caller asks for it. An unknown order, an order the journal does not know or a tripped kill switch reaches the log, when it should reach a person.
Power loss
The crash tests kill a process. They do not cut power or fail a disk, so the journal's durability rests on SQLite's sync setting and is not tested here. The log and the kill switch file are written without a sync, so a power cut can lose the last of either.
A kill switch for the whole account
This one cancels the orders the journal knows to be working. An order of unknown fate waits for the next reconciliation run, and an order the journal has never heard of stays where it is. Real software should also use the platform's own cancel-all, if it has one.
More than one gateway
Two gateways on one account would report each other's working orders as unknown to the journal, and both would stay closed. That fails safe, but it is not a way to run.
A way to clear a stuck order
When the exchange acknowledged an order and now denies it, or an order does not match the journal, new orders stay refused until a person has looked. There is deliberately no button.
Questions about this reference
What is idempotent order placement?
Sending the same order request more than once still results in one order. In this Python example, the caller's request ID maps to one client order ID through a hash, the journal answers repeats, and the mock exchange refuses a second order with a known client order ID.
What is order reconciliation?
Comparing what your records say with what the exchange says, then resolving any difference. Here, a restarted gateway reads each live order by client order ID, then lists working orders to find any its journal does not know. New orders are refused until that run is clean.
Why not retry every failed request?
Because a failed call does not prove a failed order. A timeout or a 5xx can arrive after the exchange accepted the order, so a blind retry can place it twice. A retry is safe when the request is idempotent or when the original is known not to have been applied.
Can I use this code in production?
The MIT license lets you copy it, but it is not production software. It has no request signing, no balance checks, no alerts and no support, and it refuses to connect to anything but this machine. Use it to learn from, or to compare with your own code.
Has Fincode used this gateway for a client?
No. It was written for this page. It shows how Fincode thinks about order failures, and it is not a record of past work.
Do the passing tests say anything about trading results?
No. A mock exchange is not a live market: it has no order book, no prices and no liquidity. Tests show how the software behaves, not future results. Trades can lose money.
Does it work with my exchange?
No exchange is involved. The client only talks to the mock on this machine. Connecting software to a real platform means reading that platform's API documentation and terms, and which platforms can be used depends on the law of your country and the platform's terms.
Related reading
Have order-handling code you want checked?
Fincode reviews existing trading software and builds custom software from rules you have written. See how we work, then tell us the platform, what the software should do and your timeline. Fincode is a software company and does not provide investment advice, portfolio management or crypto asset services. Trades can lose money.