Skip to content

Commit 212ecac

Browse files
authored
Merge pull request #219 from QuantStrategyLab/fix/fill-aware-sell-release-budget
Use confirmed sell release for Firstrade buys
2 parents 1c4c0dd + bf81cca commit 212ecac

11 files changed

Lines changed: 268 additions & 8 deletions

application/execution_service.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import dataclass
66
from typing import Any
77

8+
from quant_platform_kit.common.order_status import compute_confirmed_sell_release_value
89
from quant_platform_kit.common.models import OrderIntent
910
from quant_platform_kit.common.ports import ExecutionPort, MarketDataPort
1011
try:
@@ -573,6 +574,7 @@ def execute_value_target_plan(
573574
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
574575
cash_only_execution: bool = True,
575576
notional_buy_execution: bool = False,
577+
fetch_order_status=None,
576578
) -> ExecutionCycleResult:
577579
del dry_run_only # ExecutionPort owns preview vs live submission.
578580
plan = substitute_small_safe_haven_targets_with_cash(
@@ -628,6 +630,7 @@ def execute_value_target_plan(
628630
skipped: list[dict[str, Any]] = []
629631
reference_prices: dict[str, float] = {}
630632
pending_sell_release_symbols: list[str] = []
633+
submitted_sell_orders: list[dict[str, Any]] = []
631634
sell_submitted = False
632635

633636
tradable_deltas: list[tuple[str, float, float]] = []
@@ -687,21 +690,35 @@ def execute_value_target_plan(
687690
max_notional_usd=max_order_notional_usd,
688691
)
689692
)
693+
submitted_sell_orders.append(submitted[-1])
694+
pending_sell_release_symbols.append(symbol)
690695
sell_submitted = True
691-
investable_cash += quantity * sell_limit_price
692696
continue
693697

698+
confirmed_sell_release_value = compute_confirmed_sell_release_value(
699+
submitted_sell_orders=submitted_sell_orders,
700+
fetch_order_status=fetch_order_status,
701+
)
702+
investable_cash = max(
703+
0.0,
704+
float(execution.get("investable_cash") or portfolio.get("liquid_cash") or 0.0)
705+
+ confirmed_sell_release_value,
706+
)
707+
694708
buy_deltas = [item for item in tradable_deltas if item[1] > 0]
695709
_buys_blocked_reason: str | None = None
696710
if cash_only_execution and buy_deltas and pending_sell_release_symbols:
697711
estimated_buy_cost = 0.0
712+
requires_pending_sell_release = False
698713
for symbol, delta_value, price in buy_deltas:
699714
buy_budget = min(float(delta_value), investable_cash)
700715
if order_notional_cap is not None:
701716
buy_budget = min(buy_budget, order_notional_cap)
702717
if notional_buy_execution:
703718
if buy_budget >= MIN_NOTIONAL_BUY_USD:
704719
estimated_buy_cost += buy_budget
720+
elif float(delta_value) >= MIN_NOTIONAL_BUY_USD:
721+
requires_pending_sell_release = True
705722
else:
706723
limit_price = _limit_buy_price(
707724
symbol, price, limit_buy_premium, limit_buy_premium_by_symbol
@@ -718,7 +735,9 @@ def execute_value_target_plan(
718735
)
719736
if quantity > 0:
720737
estimated_buy_cost += quantity * limit_price
721-
if estimated_buy_cost > investable_cash:
738+
elif float(delta_value) > 0.0 and limit_price > max(0.0, buy_budget):
739+
requires_pending_sell_release = True
740+
if estimated_buy_cost > investable_cash or requires_pending_sell_release:
722741
_buys_blocked_reason = "pending_sell_release"
723742
for symbol, _delta_value, _price in buy_deltas:
724743
skipped.append(

application/firstrade_client.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from time import time
1515
from typing import Any, Callable
1616

17+
from application.account_payload_utils import flatten_values, float_or_none
1718
from application.state_persistence import GcsStateStore
1819

1920

@@ -447,6 +448,68 @@ def get_positions(self, account: str) -> dict[str, Any]:
447448
_, account_data = self.require_connected()
448449
return dict(account_data.get_positions(account))
449450

451+
def get_orders(self, account: str, *, per_page: int = 0) -> list[dict[str, Any]]:
452+
_, account_data = self.require_connected()
453+
payload = account_data.get_orders(account, per_page=per_page)
454+
if isinstance(payload, list):
455+
return [dict(row) for row in payload if isinstance(row, dict)]
456+
if isinstance(payload, dict):
457+
for key in ("items", "orders", "data", "result"):
458+
value = payload.get(key)
459+
if isinstance(value, list):
460+
return [dict(row) for row in value if isinstance(row, dict)]
461+
return []
462+
463+
def get_order_status(self, account: str, order_id: str) -> dict[str, Any] | None:
464+
normalized_order_id = str(order_id or "").strip()
465+
if not normalized_order_id:
466+
return None
467+
for row in self.get_orders(account):
468+
if not _payload_contains_order_id(row, normalized_order_id):
469+
continue
470+
status = _first_text_from_payload(
471+
row,
472+
"status",
473+
"order_status",
474+
"state",
475+
"status_description",
476+
"description",
477+
)
478+
executed_qty = _first_numeric_from_payload(
479+
row,
480+
"executed_qty",
481+
"executed_quantity",
482+
"filled_quantity",
483+
"filled_qty",
484+
"filled",
485+
"filled_shares",
486+
"executed_shares",
487+
"quantity_filled",
488+
"quantity",
489+
"shares",
490+
"qty",
491+
)
492+
executed_price = _first_numeric_from_payload(
493+
row,
494+
"executed_price",
495+
"average_fill_price",
496+
"avg_fill_price",
497+
"avg_price",
498+
"average_price",
499+
"fill_price",
500+
"filled_price",
501+
"price",
502+
"limit_price",
503+
)
504+
return {
505+
"status": status or "",
506+
"executed_qty": max(0.0, float(executed_qty or 0.0)),
507+
"executed_price": max(0.0, float(executed_price or 0.0)),
508+
"broker_order_id": normalized_order_id,
509+
"raw_payload": dict(row),
510+
}
511+
return None
512+
450513
def get_quote(self, account: str, symbol: str) -> dict[str, Any]:
451514
session, _ = self.require_connected()
452515
quote_factory = self._quote_factory
@@ -533,3 +596,41 @@ def place_stock_order(
533596
notional=notional,
534597
)
535598
)
599+
600+
601+
def _sanitize_payload_key(value: Any) -> str:
602+
return "".join(ch for ch in str(value or "").lower() if ch.isalnum())
603+
604+
605+
def _first_payload_value(payload: Any, *candidate_keys: str) -> Any:
606+
flattened = flatten_values(payload)
607+
candidates = {_sanitize_payload_key(key) for key in candidate_keys}
608+
for key, value in flattened.items():
609+
if _sanitize_payload_key(key.rsplit(".", 1)[-1]) in candidates:
610+
return value
611+
return None
612+
613+
614+
def _first_text_from_payload(payload: Any, *candidate_keys: str) -> str | None:
615+
value = _first_payload_value(payload, *candidate_keys)
616+
text = str(value or "").strip()
617+
return text or None
618+
619+
620+
def _first_numeric_from_payload(payload: Any, *candidate_keys: str) -> float | None:
621+
return float_or_none(_first_payload_value(payload, *candidate_keys))
622+
623+
624+
def _payload_contains_order_id(payload: Any, order_id: str) -> bool:
625+
normalized_order_id = str(order_id or "").strip()
626+
if not normalized_order_id:
627+
return False
628+
for key, value in flatten_values(payload).items():
629+
key_normalized = _sanitize_payload_key(key)
630+
if "order" not in key_normalized:
631+
continue
632+
if not any(token in key_normalized for token in ("id", "number", "orderno")):
633+
continue
634+
if str(value or "").strip() == normalized_order_id:
635+
return True
636+
return False

application/rebalance_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,7 @@ def log_message(message: str) -> None:
618618
safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
619619
cash_only_execution=settings.cash_only_execution,
620620
notional_buy_execution=notional_buy_execution_enabled(settings.strategy_profile),
621+
fetch_order_status=lambda broker_order_id: client.get_order_status(account, broker_order_id),
621622
)
622623
submitted_orders = list(execution_result.submitted_orders)
623624
skipped_orders = list(execution_result.skipped_orders)

application/runtime_broker_adapters.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from application.account_payload_utils import (
1313
first_numeric_by_keywords,
14+
flatten_values,
1415
float_or_none,
1516
get_first,
1617
iter_position_rows,
@@ -63,6 +64,19 @@ def _utcnow() -> datetime:
6364
)
6465

6566

67+
def _extract_broker_order_id(payload) -> str | None:
68+
for key, value in flatten_values(payload).items():
69+
normalized = "".join(ch for ch in str(key or "").lower() if ch.isalnum())
70+
if "order" not in normalized:
71+
continue
72+
if not any(token in normalized for token in ("id", "number", "orderno")):
73+
continue
74+
text = str(value or "").strip()
75+
if text:
76+
return text
77+
return None
78+
79+
6680
def _market_date(value: datetime) -> date:
6781
normalized = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
6882
return normalized.astimezone(_NEW_YORK_TZ).date()
@@ -326,6 +340,7 @@ def submit(order_intent) -> ExecutionReport:
326340
side=request.side,
327341
quantity=float(request.quantity or request.notional_usd or 0),
328342
status="previewed" if not self.live_orders else "submitted",
343+
broker_order_id=_extract_broker_order_id(raw),
329344
raw_payload=raw,
330345
)
331346

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dependencies = [
1818
"pytest",
1919
"pytz",
2020
"requests",
21-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@69a0256934d081b5ef309a885384b9eb9f62cf90",
21+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2381aa4577e9fd6329053a73a1c888929170eaf3",
2222
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@17ddb86c72d44b2c7b78ba7a10d8f71b21180166",
2323
]
2424
license = "MIT"
@@ -82,5 +82,5 @@ show_missing = true
8282

8383
[tool.uv]
8484
override-dependencies = [
85-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@69a0256934d081b5ef309a885384b9eb9f62cf90",
85+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2381aa4577e9fd6329053a73a1c888929170eaf3",
8686
]

qsl.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ upgrade_ring = "ring_d"
55
allow_legacy = false
66

77
[qsl.requires]
8-
quant_platform_kit = "69a0256934d081b5ef309a885384b9eb9f62cf90"
8+
quant_platform_kit = "2381aa4577e9fd6329053a73a1c888929170eaf3"
99
us_equity_strategies = "17ddb86c72d44b2c7b78ba7a10d8f71b21180166"
1010

1111
[qsl.compat]

tests/test_execution_service.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def submit_order(self, order_intent) -> ExecutionReport:
3434
side=order_intent.side,
3535
quantity=order_intent.quantity,
3636
status="previewed",
37+
broker_order_id=f"OID-{len(self.orders)}",
3738
raw_payload={
3839
"limit_price": order_intent.limit_price,
3940
"max_notional_usd": order_intent.metadata.get("max_notional_usd"),
@@ -139,6 +140,11 @@ def test_execute_value_target_plan_tops_up_existing_whole_share_when_target_roun
139140
execution_port=execution_port,
140141
dry_run_only=True,
141142
limit_buy_premium=1.0,
143+
fetch_order_status=lambda broker_order_id: {
144+
"status": "Filled" if broker_order_id else "",
145+
"executed_qty": 3.0,
146+
"executed_price": 40.0,
147+
},
142148
)
143149

144150
assert result.action_done is True
@@ -148,6 +154,41 @@ def test_execute_value_target_plan_tops_up_existing_whole_share_when_target_roun
148154
]
149155

150156

157+
def test_execute_value_target_plan_defers_buy_until_sell_release_is_confirmed():
158+
execution_port = FakeExecutionPort()
159+
result = execute_value_target_plan(
160+
plan={
161+
"allocation": {
162+
"targets": {"SOXL": 0.0, "SOXX": 260.0},
163+
"risk_symbols": ("SOXL", "SOXX"),
164+
},
165+
"portfolio": {
166+
"market_values": {"SOXL": 120.0, "SOXX": 200.0},
167+
"quantities": {"SOXL": 3.0, "SOXX": 2.0},
168+
"sellable_quantities": {"SOXL": 3.0, "SOXX": 2.0},
169+
"liquid_cash": 10.0,
170+
},
171+
"execution": {"current_min_trade": 10.0, "investable_cash": 10.0},
172+
},
173+
market_data_port=FakeMarketDataPort({"SOXL": 40.0, "SOXX": 100.0}),
174+
execution_port=execution_port,
175+
dry_run_only=True,
176+
limit_buy_premium=1.0,
177+
)
178+
179+
assert result.action_done is True
180+
assert [(order.side, order.symbol, order.quantity) for order in execution_port.orders] == [
181+
("sell", "SOXL", 3.0),
182+
]
183+
assert result.skipped_orders == (
184+
{
185+
"symbol": "SOXX",
186+
"reason": "pending_sell_release",
187+
"pending_sell_symbols": ["SOXL"],
188+
},
189+
)
190+
191+
151192
def test_execute_value_target_plan_skips_when_cap_cannot_buy_one_share():
152193
execution_port = FakeExecutionPort()
153194
result = execute_value_target_plan(
@@ -289,6 +330,11 @@ def test_execute_value_target_plan_projects_unbuyable_value_target_to_zero():
289330
dry_run_only=True,
290331
max_order_notional_usd=1000.0,
291332
safe_haven_cash_substitute_threshold_usd=1000.0,
333+
fetch_order_status=lambda broker_order_id: {
334+
"status": "Filled" if broker_order_id else "",
335+
"executed_qty": 1.0,
336+
"executed_price": 536.88,
337+
},
292338
)
293339

294340
assert result.action_done is True

tests/test_firstrade_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def get_account_balances(self, account):
4242
def get_positions(self, _account):
4343
return {"items": [{"symbol": "SPY", "quantity": "1", "market_value": "500"}]}
4444

45+
def get_orders(self, _account, per_page=0):
46+
del per_page
47+
return []
48+
4549

4650
class FakeOrder:
4751
def __init__(self, _session):
@@ -151,6 +155,43 @@ def test_client_order_preview_uses_dry_run_by_default():
151155
assert response["price"] == 5.0
152156

153157

158+
def test_get_order_status_normalizes_matching_order_payload():
159+
class OrdersAccountData(FakeAccountData):
160+
def get_orders(self, _account, per_page=0):
161+
del per_page
162+
return [
163+
{
164+
"order_id": "OID-123",
165+
"status": "Filled",
166+
"filled_quantity": "3",
167+
"avg_price": "101.25",
168+
}
169+
]
170+
171+
credentials = FirstradeCredentials(username="user", password="pass")
172+
client = FirstradeBrokerClient(
173+
credentials,
174+
session_factory=FakeSession,
175+
account_data_factory=OrdersAccountData,
176+
order_factory=FakeOrder,
177+
).connect()
178+
179+
status = client.get_order_status("12345678", "OID-123")
180+
181+
assert status == {
182+
"status": "Filled",
183+
"executed_qty": 3.0,
184+
"executed_price": 101.25,
185+
"broker_order_id": "OID-123",
186+
"raw_payload": {
187+
"order_id": "OID-123",
188+
"status": "Filled",
189+
"filled_quantity": "3",
190+
"avg_price": "101.25",
191+
},
192+
}
193+
194+
154195
def test_get_balances_includes_account_list_total_value():
155196
class BalancesWithoutTotalAccountData(FakeAccountData):
156197
account_balances = {"12345678": "$987.65"}

tests/test_rebalance_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ def get_quote(self, _account, symbol):
8888
def get_ohlc(self, _symbol, _range):
8989
return [(1700000000000 + index * 86400000, 9, 11, 8, 10 + index, 1000) for index in range(5)]
9090

91+
def get_order_status(self, _account, _order_id):
92+
return None
93+
9194
def place_stock_order(self, request, dry_run=True, explicit_live_ack=False):
9295
self.orders.append((request, dry_run, explicit_live_ack))
9396
return {

0 commit comments

Comments
 (0)