Changelog
Release notes for the PaperBroker Python Client
Cancel-lifecycle correctness patch on v0.2.7. A cancel the venue refused used to leave the order at PendingCancel with nothing able to clear it — is_order_done() never became True and a loop waiting for the order to settle waited forever. The status now rolls back to the order's real live state, each cancel attempt carries its own ClOrdID, and the client can ask the venue what it actually thinks via OrderStatusRequest (35=H). Drop-in from v0.2.7 — no public signature changed; one wire-visible change to the cancel ClOrdID format (see Migration).
- A rejected cancel rolls the status back — the order returns to its live state (New, PartiallyFilled, …) instead of a permanent PendingCancel. Sticky cum_qty / leaves_qty survive, so a partial fill keeps its count.
- Cancel retry is no longer a FIX duplicate — every attempt gets a unique ClOrdID. Reverse resolution prefers OrigClOrdID(41) from the wire over stripping the suffix.
- Clock skew no longer discards real ExecutionReports — a client host running ahead of the matching engine could out-rank the server's own report and drop it, freezing the order. Optimistic local writes are now always superseded by the next server report; server-vs-server ordering still uses the timestamp.
- Outbound FIX is logged at INFO, symmetric with inbound — “did we actually send that cancel?” is answerable from the logs.
- The paperbroker logger stops hijacking yours — handlers your app attached before constructing the client survive.
- client.request_order_status(cl_ord_id) -> bool — sends FIX 35=H to reconcile a disputed state. Fires automatically after a cancel reject that didn't settle the order; opt out with client.orders.auto_status_request_on_cancel_reject = False.
- client.get_last_cancel_reject(cl_ord_id) → {code, reason, at, cancel_cl_ord_id} or None — tells a refused cancel from one still in flight, without string-matching Text(58).
- fix:order:cancel_reject now carries orig_cl_ord_id and status, so consumers never have to strip the cancel suffix themselves.
- PaperBrokerClient(log_propagate=True) — route package log records into your own logger tree (default False, so nobody gets surprise duplicate output).
- PaperBrokerClient(fix_message_log=False) — turn off QuickFIX's raw wire log, now written by default to <log_dir>/FIX.4.4-<sender>-<target>.messages.current.log.
- Cancel ClOrdIDs changed shape: <order>-CXL → <order>-CXL1, -CXL2, … Anything matching the old literal suffix — log greps, dashboards, reconciliation keyed on the string — needs to allow the trailing attempt number.
- A rejected cancel no longer leaves PendingCancel. If you gated a retry on that status, the guard was doubling as your retry limiter and the loop will now genuinely re-send. Give it an explicit bound.
- Two new log destinations are on by default — [TO APP] at INFO, and the QuickFIX raw message log under log_dir.
- Nothing else in the v0.2.7 surface changed — the Alpha API, Signal / OrderRequest / AlphaContext types, and all examples are untouched.
is_terminal, status = client.cancel_order(cl_ord_id, timeout=2.0)
# status is the order's real live state — never a stranded "PendingCancel"
rej = client.get_last_cancel_reject(cl_ord_id)
if rej:
# {"code": "1", "reason": "Order not found or already cancelled.",
# "at": datetime(...), "cancel_cl_ord_id": "a1b2c3d4-CXL1"}
log.info("cancel refused (%s): %s", rej["code"], rej["reason"])
if not is_terminal:
client.request_order_status(cl_ord_id) # 35=H — already sent for you
Alpha framework refactor. Multi-instrument-first SignalDrivenAlpha with explicit Signal / OrderRequest split, AlphaContext object, joint trigger across all subscribed bars, and exit semantics via CloseSignal / TakeProfitSignal / StopLossSignal subtypes. Breaking vs v0.2.6 (no shim) plus one client-surface default change: place_order default TIF is now "DAY" (was "GTC") — Vietnam venues only accept DAY.
- Multi-instrument-first SignalDrivenAlpha — instruments: list[str], per-symbol qty, joint trigger across all subscribed bars
- Signal / OrderRequest value objects — one signal can fan out to N orders or trade a different symbol (cross-asset)
- AlphaContext — bars / quotes / positions / open_orders / signals / account / now / triggered_by per trigger; signature stable for future additions
- plan_orders hook — default 1:1, override for iceberg, cross-asset, multi-leg execution
- CloseSignal / TakeProfitSignal / StopLossSignal — framework auto-sizes to abs(position) and routes MARKET
- OrderState / SignalState rich lifecycle tracking; framework subscribes to all 5 order-lifecycle FIX events
- trigger_on_quote = True + should_evaluate_on_quote() — quote-driven alphas (market making, scalping)
- Fail-loud validation — AlphaConfig.__post_init__, from_paper() env-strict, declared_params typo guard
- Auto-start MD backend — alpha.start() calls market_data.start() (Kafka requires; Redis no-op)
- Examples consolidated 18 → 14 (+ 15_alpha_mm_basic.py); covers single-instrument, pair-spread, cross-asset, iceberg, exit patterns, market making
- place_order(tif=...) default "GTC" → "DAY". HSX/HNX/HNXDS reject GTC as GTC_UNSUPPORTED_DAY_ONLY.
- Unknown tif= string raises ValueError (was silently routed to IOC). "FOK" now correctly mapped to TimeInForce_FILL_OR_KILL.
- VNSignalDrivenAlpha → SignalDrivenAlpha; VNTradingSession → TradingSession. No shim.
- Hook signatures: all take ctx: AlphaContext. get_signal(...) -> str → get_signals(...) -> list[Signal].
- AlphaConfig(instrument="X", ...) → AlphaConfig(instruments=["X"], ...).
- ctx.open_entries: dict[str, list[str]] → ctx.open_orders: dict[str, list[OrderState]] (rich state).
- Framework no longer blocks signals when entries are open — alpha owns concurrency policy. Add an explicit if ctx.open_orders.get(sym): return [] guard for single-entry semantics.
class SpreadPairAlpha(SignalDrivenAlpha):
declared_params = {"window", "entry_z", "exit_z"}
def get_indicators(self, ctx):
a, b = self.config.instruments
spreads = [ba.close - bb.close
for ba, bb in zip(ctx.bars[a], ctx.bars[b])]
return {"z": _rolling_z(spreads, self.config.params["window"])}
def get_signals(self, ind, ctx):
# ... emit a list of Signals (multi-leg atomic) ...
alpha = SpreadPairAlpha.from_paper(
instruments=["HNXDS:VN30F2606", "HNXDS:VN30F2609"],
sub_account="main", timeframe="1m",
qty={"HNXDS:VN30F2606": 1, "HNXDS:VN30F2609": 1},
params={"window": 30, "entry_z": 2.0, "exit_z": 0.5},
)
alpha.run()
- 5-min pair-spread pilot on F2606 + F2609 (Kafka MD): 10 joint triggers, 0 unhandled exceptions, atomic 2-leg dispatch verified.
- Redis pub/sub observed ~0.1 tick/s front-month, ~0 back-month. Use Kafka for multi-instrument alphas.
Alpha framework v1. SignalDrivenAlpha base class (single-instrument), 6 execution + risk primitives, M1 client facade closure, and YAML-driven CLI paperbroker run --config X.yaml. Also introduces the cancel_order tuple return.
- SignalDrivenAlpha sealed-lifecycle base class with 4 abstract hooks — complete RSI alpha in ~40 lines of strategy code
- Execution primitives — BarAggregator, TradingSession (HCM exchange calendar), StateStore (JSON K/V), PositionTracker
- Risk plugin — KillSwitch (equity floor; halt or halt_and_flatten)
- M1 facade closure — wait_for, is_order_done, cleanup_order, get_order_cumqty / leavesqty / text, last_logout_reason
- CLI — paperbroker run --config X.yaml and paperbroker diagnose (optional extras [cli])
- 2 new examples — 13_alpha_rsi_1m.py (Gate 1 pilot), 14_alpha_bollinger_bands.py
- cancel_order(cl_ord_id, timeout) -> tuple[bool, str] — waits for server confirmation, returns (is_terminal, status). Most callers ignoring the return value still work; truthiness checks (if not client.cancel_order(...)) break.
- Legacy fire-and-forget restored via client.cancel_order_v1(cl_ord_id) (DeprecationWarning, removed in v0.3.0).
is_terminal, status = client.cancel_order(cl_ord_id, timeout=5.0)
if is_terminal:
print(f"Cancel confirmed: {status}")
else:
print(f"Still pending — status={status} (may need manual retry)")
# Or restore pre-v0.2.6 behavior:
client.cancel_order_v1(cl_ord_id) # DeprecationWarning
Pure architecture refactor — zero new public API. Cleans up internal layout so subsequent tracking releases can ship into a clean structure. session/ → fix/; REST split into account.py / metrics.py / transactions.py; client.py shrunk 679 → 356 lines via facade unification. All v0.2.4 imports still work via deprecation shims.
- PaperBrokerClient(enable_fix=False) — REST-only mode (macOS arm64 / no QuickFIX). Lazy QuickFIX import; enable_fix=None auto-detects.
- Flat facade attrs — client.orders (OrderManager or None), client.accounting (AccountClient), client.is_fix_enabled.
- Order routing 4 hops (3 pass-through) → 1 hop. Tests now mock 1 layer instead of 3.
- New module layout — paperbroker/fix/ (engine + order_manager + order_store), paperbroker/execution/recovery.py (RecoveryManager), paperbroker/alpha/ and paperbroker/risk/ reserved namespaces.
- paperbroker.session.* → paperbroker.fix.*. Old imports emit DeprecationWarning.
- paperbroker.rest.account_client → paperbroker.rest.account.
- paperbroker.rest.rest_session → paperbroker.rest.session.
- PaperBrokerRESTClient retained as shim over PaperBrokerClient(enable_fix=False).
Introduces SQLite-based order persistence — solving a critical issue where active order IDs are lost if the program crashes or is terminated unexpectedly. Orders are now saved to SQLite on every lifecycle event and can be recovered on restart. Outdated — no longer offered for download. Upgrade to v0.2.8.
- order_store_path constructor parameter — enables SQLite persistence (default: "orders.db", set None to disable)
- recover_pending_orders() method — reloads active orders from SQLite and rehydrates in-memory state so cancel_order() works immediately after restart
- New OrderStore module with WAL journal mode, atomic commits, and thread-safe access
- Order state persisted at 3 critical moments: place_order(), execution report received, and every status change
- Fail-safe design — SQLite errors are logged but never crash the trading flow
- 100% backward compatible — no breaking changes, no new dependencies (sqlite3 is stdlib)
client.connect()
client.wait_until_logged_on(timeout=10)
# Recover orders from previous session
pending = client.recover_pending_orders()
if pending:
for order in pending:
print(f" {order['cl_ord_id']}: {order['side']} "
f"{order['qty']}x {order['symbol']} @ {order['price']}")
client.cancel_order(order["cl_ord_id"])