Skip to content

Commit 27c9cbd

Browse files
Pigbibicodex
andcommitted
feat: add isolated Firstrade paper command consumer
Co-Authored-By: Codex <noreply@openai.com>
1 parent fd4fa77 commit 27c9cbd

9 files changed

Lines changed: 706 additions & 1 deletion

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ FIRSTRADE_ACCOUNT=
1616
# Shared US equity strategy runtime.
1717
STRATEGY_PROFILE=
1818
FIRSTRADE_DRY_RUN_ONLY=true
19+
# Default-disabled verifier for durable paper commands. It requires
20+
# RUNTIME_TARGET_ENABLED=false, FIRSTRADE_DRY_RUN_ONLY=true,
21+
# RUNTIME_TARGET_JSON.execution_mode=paper, CASH_ONLY_EXECUTION=true, and an
22+
# explicit FIRSTRADE_ACCOUNT. It never calls the order API.
23+
FIRSTRADE_PAPER_EXECUTION_COMMAND_CONSUMER_ENABLED=false
24+
FIRSTRADE_EXECUTION_COMMAND_CLOUD_URI=
1925
FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS=
2026
ACCOUNT_PREFIX=FIRSTRADE
2127
ACCOUNT_REGION=US

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ uv run --no-sync python scripts/check_qpk_pin_consistency.py
5454

5555
## Useful docs
5656

57-
- No separate `docs/` directory yet; start with this README and the workflow files.
57+
- [Isolated paper command consumer](docs/paper_execution_command_consumer.md)
5858

5959
## Community and security
6060

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""Firstrade read-only reconciliation adapter for shared paper commands.
2+
3+
This module deliberately has no order-request, execution-port, or order-client
4+
import. It consumes only normalized current portfolio and quote snapshots from
5+
the isolated endpoint after the shared command binding passes.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import math
11+
from collections.abc import Callable, Mapping, Sequence
12+
from datetime import date
13+
from typing import Any
14+
15+
from quant_platform_kit.common.execution_commands import ExecutionCommand, ExecutionCommandStore
16+
from quant_platform_kit.common.paper_execution_command_consumer import (
17+
PaperExecutionProposal,
18+
PaperExecutionReconciliation,
19+
consume_due_paper_execution_commands as consume_shared_paper_execution_commands,
20+
)
21+
from quant_platform_kit.common.runtime_command_gate import RuntimeCommandExposureEffect
22+
from quant_platform_kit.common.strategy_release import StrategyReleaseIdentity
23+
24+
25+
FIRSTRADE_PAPER_EXECUTION_INTENT_SCHEMA_VERSION = "firstrade.paper-execution-intent.v1"
26+
_NOTIONAL_TOLERANCE = 0.01
27+
28+
29+
def resolve_paper_execution_command_consumer_enabled(*, env_reader, dry_run_only: bool) -> bool:
30+
"""Enable only the isolated local-dry-run command consumer."""
31+
32+
enabled = str(
33+
env_reader("FIRSTRADE_PAPER_EXECUTION_COMMAND_CONSUMER_ENABLED", "") or ""
34+
).strip().lower() in {"1", "true", "t", "yes", "y", "on"}
35+
if enabled and not dry_run_only:
36+
raise RuntimeError("Firstrade paper command consumer requires FIRSTRADE_DRY_RUN_ONLY=true")
37+
return enabled
38+
39+
40+
def _symbol(value: object) -> str:
41+
return str(value or "").strip().upper()
42+
43+
44+
def _symbols(value: object) -> set[str]:
45+
if not isinstance(value, (list, tuple, set)):
46+
return set()
47+
return {_symbol(item) for item in value if _symbol(item)}
48+
49+
50+
def _finite(value: object, *, field_name: str) -> float:
51+
try:
52+
number = float(value)
53+
except (TypeError, ValueError) as exc:
54+
raise ValueError(f"{field_name} must be numeric") from exc
55+
if not math.isfinite(number):
56+
raise ValueError(f"{field_name} must be finite")
57+
return number
58+
59+
60+
def _cash_balance(portfolio: Any) -> float:
61+
metadata = getattr(portfolio, "metadata", {})
62+
if not isinstance(metadata, Mapping):
63+
raise ValueError("portfolio metadata is unavailable")
64+
return _finite(metadata.get("market_currency_cash"), field_name="portfolio.market_currency_cash")
65+
66+
67+
def _reconcile(
68+
command: ExecutionCommand,
69+
*,
70+
portfolio: Any,
71+
quote_loader: Callable[[str], Any],
72+
managed_symbols: Sequence[str],
73+
) -> PaperExecutionReconciliation:
74+
intent = command.intent
75+
if str(intent.get("schema_version") or "") != FIRSTRADE_PAPER_EXECUTION_INTENT_SCHEMA_VERSION:
76+
return PaperExecutionReconciliation(proposals=(), integrity_findings=("data_artifact_invalid",))
77+
if str(intent.get("target_mode") or "") != "value":
78+
return PaperExecutionReconciliation(proposals=(), integrity_findings=("data_artifact_invalid",))
79+
raw_targets = intent.get("targets")
80+
if not isinstance(raw_targets, Mapping):
81+
return PaperExecutionReconciliation(proposals=(), integrity_findings=("data_artifact_invalid",))
82+
try:
83+
targets = {
84+
_symbol(symbol): _finite(value, field_name=f"targets[{symbol!r}]")
85+
for symbol, value in raw_targets.items()
86+
if _symbol(symbol)
87+
}
88+
except ValueError:
89+
return PaperExecutionReconciliation(proposals=(), integrity_findings=("data_artifact_invalid",))
90+
strategy_symbols = _symbols(intent.get("strategy_symbols"))
91+
expected_symbols = {_symbol(symbol) for symbol in managed_symbols if _symbol(symbol)}
92+
if (
93+
not strategy_symbols
94+
or strategy_symbols != expected_symbols
95+
or set(targets) != strategy_symbols
96+
or any(value < 0.0 for value in targets.values())
97+
):
98+
return PaperExecutionReconciliation(proposals=(), integrity_findings=("data_artifact_invalid",))
99+
100+
findings: list[str] = []
101+
quantities: dict[str, float] = {}
102+
current_values: dict[str, float] = {}
103+
for position in tuple(getattr(portfolio, "positions", ()) or ()):
104+
symbol = _symbol(getattr(position, "symbol", ""))
105+
if symbol not in strategy_symbols:
106+
findings.append("position_reconciliation_mismatch")
107+
continue
108+
try:
109+
quantity = _finite(getattr(position, "quantity", None), field_name=f"position[{symbol}].quantity")
110+
recorded_value = _finite(
111+
getattr(position, "market_value", None),
112+
field_name=f"position[{symbol}].market_value",
113+
)
114+
quote = quote_loader(symbol)
115+
price = _finite(getattr(quote, "last_price", None), field_name=f"quote[{symbol}].last_price")
116+
if price <= 0.0:
117+
raise ValueError("quote price must be positive")
118+
except Exception:
119+
findings.append("position_reconciliation_mismatch")
120+
continue
121+
quote_value = quantity * price
122+
tolerance = max(1.0, abs(quote_value) * 0.005)
123+
if quantity < -_NOTIONAL_TOLERANCE or recorded_value < -_NOTIONAL_TOLERANCE:
124+
findings.append("position_reconciliation_mismatch")
125+
if abs(recorded_value - quote_value) > tolerance:
126+
findings.append("position_reconciliation_mismatch")
127+
quantities[symbol] = quantities.get(symbol, 0.0) + quantity
128+
current_values[symbol] = current_values.get(symbol, 0.0) + quote_value
129+
130+
try:
131+
cash_balance = _cash_balance(portfolio)
132+
total_equity = _finite(getattr(portfolio, "total_equity", None), field_name="portfolio.total_equity")
133+
tolerance = max(1.0, abs(total_equity) * 0.005)
134+
if abs(cash_balance + sum(current_values.values()) - total_equity) > tolerance:
135+
findings.append("position_reconciliation_mismatch")
136+
except ValueError:
137+
findings.append("position_reconciliation_mismatch")
138+
139+
proposals: list[PaperExecutionProposal] = []
140+
for symbol in sorted(strategy_symbols):
141+
current_value = current_values.get(symbol, 0.0)
142+
target_value = targets[symbol]
143+
delta_value = target_value - current_value
144+
if abs(delta_value) <= _NOTIONAL_TOLERANCE:
145+
continue
146+
try:
147+
quote = quote_loader(symbol)
148+
price = _finite(getattr(quote, "last_price", None), field_name=f"quote[{symbol}].last_price")
149+
if price <= 0.0:
150+
raise ValueError("quote price must be positive")
151+
except Exception:
152+
findings.append("position_reconciliation_mismatch")
153+
continue
154+
if abs(target_value) < abs(current_value) - _NOTIONAL_TOLERANCE:
155+
effect = RuntimeCommandExposureEffect.REDUCES
156+
elif abs(target_value) > abs(current_value) + _NOTIONAL_TOLERANCE:
157+
effect = RuntimeCommandExposureEffect.INCREASES
158+
else:
159+
effect = RuntimeCommandExposureEffect.NEUTRAL
160+
proposals.append(
161+
PaperExecutionProposal(
162+
symbol=symbol,
163+
exposure_effect=effect,
164+
details={
165+
"side": "buy" if delta_value > 0.0 else "sell",
166+
"quantity": round(abs(delta_value) / price, 8),
167+
"reference_price": round(price, 8),
168+
"current_value": round(current_value, 8),
169+
"target_value": round(target_value, 8),
170+
"target_notional_delta": round(delta_value, 8),
171+
"current_quantity": round(quantities.get(symbol, 0.0), 8),
172+
},
173+
)
174+
)
175+
return PaperExecutionReconciliation(
176+
proposals=tuple(proposals),
177+
integrity_findings=tuple(dict.fromkeys(findings)),
178+
)
179+
180+
181+
def consume_due_paper_execution_commands(
182+
*,
183+
store: ExecutionCommandStore | None,
184+
as_of_session: date | str,
185+
claimant: str,
186+
portfolio_loader: Callable[[], Any],
187+
quote_loader: Callable[[str], Any],
188+
managed_symbols: Sequence[str],
189+
runtime_release_receipt: Mapping[str, Any] | None,
190+
expected_strategy_release: StrategyReleaseIdentity | Mapping[str, object] | None,
191+
expected_command_binding: Mapping[str, object] | None,
192+
) -> dict[str, object]:
193+
"""Consume paper commands after shared release and delivery binding checks."""
194+
195+
return consume_shared_paper_execution_commands(
196+
store=store,
197+
as_of_session=as_of_session,
198+
claimant=claimant,
199+
reconcile_command=lambda command: _reconcile(
200+
command,
201+
portfolio=portfolio_loader(),
202+
quote_loader=quote_loader,
203+
managed_symbols=managed_symbols,
204+
),
205+
runtime_release_receipt=runtime_release_receipt,
206+
expected_strategy_release=expected_strategy_release,
207+
expected_command_binding=expected_command_binding,
208+
)
209+
210+
211+
__all__ = (
212+
"FIRSTRADE_PAPER_EXECUTION_INTENT_SCHEMA_VERSION",
213+
"consume_due_paper_execution_commands",
214+
"resolve_paper_execution_command_consumer_enabled",
215+
)

application/runtime_broker_adapters.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,66 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot:
327327
},
328328
)
329329

330+
def build_reconciled_paper_portfolio_snapshot(self) -> PortfolioSnapshot:
331+
"""Read complete, current account evidence for delayed paper commands.
332+
333+
The normal strategy snapshot intentionally filters to its managed
334+
symbols. A delayed-command consumer must instead see every position:
335+
an unmanaged or malformed holding is a reason to reject the command,
336+
not a reason to silently omit it. This method is read-only and lives
337+
beside the normal snapshot so no ordinary execution behavior changes.
338+
"""
339+
340+
balances = self.client.get_balances(self.account)
341+
positions_payload = self.client.get_positions(self.account)
342+
positions: list[Position] = []
343+
for row in iter_position_rows(positions_payload):
344+
raw_symbol = get_first(row, "symbol", "ticker", "security_symbol")
345+
if not raw_symbol:
346+
raise ValueError("Firstrade reconciliation received a position without a symbol.")
347+
symbol = self.normalize_symbol(raw_symbol)
348+
quantity = float_or_none(get_first(row, "quantity", "shares", "qty"))
349+
market_value = float_or_none(
350+
get_first(row, "market_value", "marketValue", "value", "current_value")
351+
)
352+
if quantity is None or market_value is None:
353+
raise ValueError(
354+
f"Firstrade reconciliation requires quantity and current market value for {symbol}."
355+
)
356+
if quantity == 0.0:
357+
continue
358+
positions.append(
359+
Position(
360+
symbol=symbol,
361+
quantity=quantity,
362+
market_value=market_value,
363+
average_cost=float_or_none(
364+
get_first(row, "average_cost", "avg_cost", "cost_basis", "averagePrice")
365+
),
366+
currency="USD",
367+
account_id=mask_account_id(self.account),
368+
)
369+
)
370+
cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS)
371+
if cash_balance is None:
372+
raise ValueError("Firstrade reconciliation requires a current cash balance.")
373+
total_equity = float(cash_balance) + sum(float(position.market_value) for position in positions)
374+
return PortfolioSnapshot(
375+
as_of=self.clock(),
376+
total_equity=total_equity,
377+
cash_balance=float(cash_balance),
378+
buying_power=float(cash_balance),
379+
positions=tuple(positions),
380+
metadata={
381+
"broker": "firstrade",
382+
"account_hash": self.account_hash or mask_account_id(self.account),
383+
"api_kind": "unofficial-reverse-engineered",
384+
"cash_only_execution": True,
385+
"market_currency_cash": float(cash_balance),
386+
"reconciliation_source": "firstrade_current_balances_and_positions",
387+
},
388+
)
389+
330390
def build_portfolio_port(self) -> PortfolioPort:
331391
return CallablePortfolioPort(self.build_portfolio_snapshot)
332392

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Firstrade isolated paper command consumer
2+
3+
`POST /paper-command-consumer` manually verifies delayed paper commands. It is
4+
not called by `/run`, `/dry-run`, scheduler workflows, or the normal strategy
5+
cycle, and it never constructs an execution port or order request.
6+
7+
The endpoint uses the shared QuantPlatformKit paper lifecycle: approved release
8+
receipt, exact platform/account-scope/strategy-profile binding, create-only
9+
claim and events, paper-risk receipt, and an enforced command gate. Only after
10+
those checks pass does it open a read-only Firstrade session for current
11+
balances, all positions, and quotes.
12+
13+
## Required isolation
14+
15+
- `FIRSTRADE_PAPER_EXECUTION_COMMAND_CONSUMER_ENABLED=true`
16+
- `RUNTIME_TARGET_ENABLED=false`
17+
- `FIRSTRADE_DRY_RUN_ONLY=true`
18+
- `RUNTIME_TARGET_JSON.execution_mode=paper`
19+
- `CASH_ONLY_EXECUTION=true`
20+
- explicit `FIRSTRADE_ACCOUNT`
21+
- `FIRSTRADE_EXECUTION_COMMAND_CLOUD_URI` or
22+
`FIRSTRADE_EXECUTION_COMMAND_DIR`
23+
24+
The exact account identifier is required for this endpoint even if Firstrade
25+
currently returns one account; this prevents a newly added account from being
26+
selected implicitly. The consumer binds logical delivery using the runtime
27+
target's `account_scope`, never command-provided metadata.
28+
29+
## Fail-closed reconciliation
30+
31+
The account read includes all positions rather than only the strategy's managed
32+
symbols. Missing cash/current market values, an unmanaged position, a short,
33+
a stale or invalid quote, a mismatch between cash-plus-positions and equity, or
34+
a release/binding mismatch records a blocked or rejected paper event. The
35+
consumer never guesses a quantity, adjusts leverage, or submits a broker order.
36+
37+
Turn the flag off again after a manual verification. Live rollout remains a
38+
separate, reviewed release-readiness decision.

0 commit comments

Comments
 (0)