"""Paper/stub broker — deterministic async double (REQ-011, dcy-003 OQ-2). Follows the same submit -> poll contract as the real KIS adapter (no synchronous shortcut), but lets tests program WHEN/whether fills happen: immediate, after N polls, partial, or reject. This exercises the same execution code path that production (KIS) takes. """ from __future__ import annotations import threading from decimal import Decimal from datetime import datetime from typing import Optional from trex_core.adapters.broker.seq_store import SeqStore from trex_core.application.broker_port import ( BalanceItem, CancelOutcome, CancelState, FillStatus, OrderAck, OrderRequest, calculate_fee, calculate_tax, ) class _Order: def __init__(self, oid: str, req: OrderRequest) -> None: self.oid = oid self.side = req.side self.symbol = req.symbol self.qty = req.qty # Deterministic SOR simulation: paper routes SOR to KRX while retaining # req.venue on the acknowledgement as submission provenance. self.execution_venue = "KRX" if req.venue == "SOR" else req.venue self.price = float(req.limit_price) if req.limit_price is not None else 1000.0 self.polls = 0 self.filled = 0 self.cancelled = False class PaperBroker: """Deterministic async paper broker. - fill_after_polls: number of poll_fill calls before fills begin (0 => fills on first poll) - qty_per_poll: filled quantity per poll once filling (None => fill remaining at once) - reject_symbols: symbols whose orders are rejected on first poll """ def __init__( self, *, fill_after_polls: int = 0, qty_per_poll: Optional[int] = None, reject_symbols: Optional[set[str]] = None, seq_store: Optional[SeqStore] = None, ) -> None: self._fill_after = fill_after_polls self._qty_per_poll = qty_per_poll self._reject = reject_symbols or set() self._orders: dict[str, _Order] = {} self._seq = 0 # REQ-011 CHG-20260612-01 (issue #4): when injected, order seq is minted # from a persistent monotonic store so it survives daemon restarts and a # post-restart sell does not collide its correlation_id with a prior buy. # Unset (no Redis configured) keeps the byte-for-byte in-memory counter. self._seq_store = seq_store self._lock = threading.Lock() # REQ-038: programmable current price for safety-sell scenario tests. self._prices: dict[str, Decimal] = {} def name(self) -> str: return "paper" def submit_order(self, req: OrderRequest) -> OrderAck: with self._lock: if self._seq_store is not None: seq = self._seq_store.next_seq() else: self._seq += 1 seq = self._seq oid = f"PAPER-{seq:06d}" self._orders[oid] = _Order(oid, req) return OrderAck(accepted=True, broker_order_id=oid, venue=req.venue) def poll_fill( self, broker_order_id: str, *, side: str, symbol: str, order_date: Optional[str] = None, submitted_at: Optional[datetime] = None, ) -> FillStatus: with self._lock: o = self._orders.get(broker_order_id) if o is None: return FillStatus(state="unknown") if o.cancelled: return FillStatus(state="cancelled") o.polls += 1 if symbol in self._reject: return FillStatus(state="rejected", raw={"reason": "paper_reject"}) if o.polls <= self._fill_after: return FillStatus( state="accepted", filled_qty=o.filled, avg_price=o.price, raw={"order_open_confirmed": True}, ) step = self._qty_per_poll if self._qty_per_poll is not None else (o.qty - o.filled) o.filled = min(o.qty, o.filled + step) if o.filled >= o.qty: # Deterministic execution_id (dcy-004 OQ-3): # paper:{broker_order_id}:{filled_qty} execution_id = f"paper:{o.oid}:{o.filled}" amount = o.filled * o.price fee = calculate_fee(o.side, amount) tax = calculate_tax(o.side, amount) return FillStatus( state="filled", filled_qty=o.filled, avg_price=o.price, execution_id=execution_id, fee=fee, tax=tax, execution_venue=o.execution_venue, ) return FillStatus( state="partially_filled", filled_qty=o.filled, avg_price=o.price, execution_venue=o.execution_venue, raw={"order_open_confirmed": True}, ) def cancel_order( self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX", ) -> CancelOutcome: """Cancel a paper order. venue is accepted for BrokerPort protocol conformance (REQ-118) but ignored — paper has no exchange concept. Existing behavior preserved. """ with self._lock: o = self._orders.get(broker_order_id) if o is None or o.filled >= o.qty: return CancelOutcome(CancelState.REJECTED, "order_not_cancellable") o.cancelled = True return CancelOutcome(CancelState.REQUESTED) def get_balance(self) -> list[BalanceItem]: """Deterministic per-symbol fill aggregate (REQ-031 AC-3). Aggregates buys − sells across all filled (or partially filled) paper orders, drops zero-qty rows (Design Decision 7). """ with self._lock: balances: dict[str, int] = {} for o in self._orders.values(): if o.filled <= 0: continue sign = 1 if o.side == "buy" else -1 balances[o.symbol] = balances.get(o.symbol, 0) + sign * int(o.filled) return [BalanceItem(symbol=sym, hldg_qty=qty) for sym, qty in balances.items() if qty != 0] # --- REQ-038: programmable current price (safety-sell scenario double) --- def set_current_price(self, symbol: str, price: Decimal) -> None: """Program the current price returned by get_current_price(symbol).""" with self._lock: self._prices[symbol] = Decimal(str(price)) def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]: """Return the programmed current price for symbol, or None if unset. market accepted for BrokerPort protocol conformance (REQ-118) but ignored — paper has no market distinction concept. """ with self._lock: return self._prices.get(symbol)