From afca4102625c14d1b999da1857d379049d5d7d0c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:15:43 +0800 Subject: [PATCH] Add fractional/notional DCA execution helpers and broker paths. Introduce shared execution capability gating, Schwab DOLLARS orders, LongBridge fractional buys, IBKR notional rejection, and verification script. Co-authored-by: Cursor --- pyproject.toml | 2 +- scripts/verify_fractional_dca_execution.py | 454 ++++++++++++++++++ setup.py | 2 +- src/quant_platform_kit/__init__.py | 2 +- .../common/execution_capabilities.py | 30 ++ src/quant_platform_kit/ibkr/execution.py | 20 + .../longbridge/execution.py | 49 +- src/quant_platform_kit/schwab/execution.py | 69 ++- tests/test_execution_capabilities.py | 116 +++++ tests/test_ibkr_execution.py | 19 + tests/test_longbridge_execution.py | 64 +++ tests/test_schwab_execution.py | 22 + 12 files changed, 823 insertions(+), 26 deletions(-) create mode 100644 scripts/verify_fractional_dca_execution.py create mode 100644 src/quant_platform_kit/common/execution_capabilities.py create mode 100644 tests/test_execution_capabilities.py diff --git a/pyproject.toml b/pyproject.toml index a04bb249..6a9489f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quant-platform-kit" -version = "0.7.40" +version = "0.7.41" description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies." readme = "README.md" requires-python = ">=3.9" diff --git a/scripts/verify_fractional_dca_execution.py b/scripts/verify_fractional_dca_execution.py new file mode 100644 index 00000000..98a6b7db --- /dev/null +++ b/scripts/verify_fractional_dca_execution.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +"""Cross-platform fractional / notional DCA execution verification. + +Runs broker-boundary payload checks and execution-layer simulations without +live broker credentials. Exit code 0 only when all automated checks pass. +""" +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +import types +from dataclasses import dataclass, field +from decimal import Decimal +from pathlib import Path +from typing import Any, Callable + +ROOT = Path(__file__).resolve().parents[2] +QPK_SRC = ROOT / "QuantPlatformKit" / "src" +for path in (str(QPK_SRC),): + if path not in sys.path: + sys.path.insert(0, path) + + +@dataclass +class CheckResult: + platform: str + scenario: str + status: str # pass | fail | warn | skip + detail: str + payload: dict[str, Any] = field(default_factory=dict) + + +RESULTS: list[CheckResult] = [] + + +def record(platform: str, scenario: str, status: str, detail: str, **payload: Any) -> None: + RESULTS.append( + CheckResult(platform=platform, scenario=scenario, status=status, detail=detail, payload=payload) + ) + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def verify_qpk_schwab() -> None: + import types + from unittest.mock import patch + + from quant_platform_kit.common.models import OrderIntent + from quant_platform_kit.schwab.execution import build_equity_dollar_buy_market_order, submit_equity_order + + order = build_equity_dollar_buy_market_order("QQQM", 50.0) + leg = order["orderLegCollection"][0] + if leg["quantityType"] != "DOLLARS" or leg["quantity"] != 50.0: + record("Schwab", "dollar_order_json", "fail", f"unexpected leg: {leg}") + else: + record("Schwab", "dollar_order_json", "pass", "quantityType=DOLLARS quantity=50") + + try: + build_equity_dollar_buy_market_order("QQQM", 0.5) + record("Schwab", "min_notional_guard", "fail", "expected ValueError for $0.50") + except ValueError: + record("Schwab", "min_notional_guard", "pass", "rejects notional below $1") + + captured: dict[str, Any] = {} + + class FakeResponse: + status_code = 201 + text = "" + headers = {"Location": "/orders/999"} + + class FakeClient: + def __init__(self): + self.last_call = None + + def place_order(self, account_hash, payload): + self.last_call = (account_hash, payload) + return FakeResponse() + + buy_client = FakeClient() + report = submit_equity_order( + buy_client, + "hash-1", + OrderIntent( + symbol="QQQM", + side="buy", + quantity=0, + order_type="market", + metadata={"notional_usd": 50.0}, + ), + ) + captured["order"] = buy_client.last_call[1] if buy_client.last_call else None + if report.status != "accepted": + record("Schwab", "submit_notional_path", "fail", f"status={report.status}") + elif captured.get("order", {}).get("orderLegCollection", [{}])[0].get("quantityType") != "DOLLARS": + record("Schwab", "submit_notional_path", "fail", "submit path did not use DOLLARS") + else: + record("Schwab", "submit_notional_path", "pass", "accepted with DOLLARS payload") + + equities_module = types.ModuleType("schwab.orders.equities") + equities_module.equity_sell_market = lambda symbol, quantity: ("sell_market", symbol, quantity) + equities_module.equity_buy_market = lambda symbol, quantity: ("buy_market", symbol, quantity) + equities_module.equity_buy_limit = lambda symbol, quantity, price: ("buy_limit", symbol, quantity, price) + with patch.dict(sys.modules, {"schwab.orders.equities": equities_module}): + sell_client = FakeClient() + sell_report = submit_equity_order( + sell_client, + "hash-1", + OrderIntent(symbol="QQQM", side="sell", quantity=1, metadata={"notional_usd": 50.0}), + ) + order_payload = sell_client.last_call[1] if hasattr(sell_client, "last_call") else captured.get("order") + if isinstance(order_payload, dict) and order_payload.get("orderLegCollection", [{}])[0].get("quantityType") == "DOLLARS": + record("Schwab", "sell_ignores_notional_metadata", "fail", "sell incorrectly used DOLLARS path") + elif sell_report.status == "accepted": + record("Schwab", "sell_ignores_notional_metadata", "pass", "sell uses share-quantity path") + else: + record("Schwab", "sell_ignores_notional_metadata", "fail", f"unexpected sell report: {sell_report}") + + +def verify_qpk_longbridge() -> None: + from quant_platform_kit.longbridge.execution import submit_order + + longport_module = types.ModuleType("longport") + openapi_module = types.ModuleType("longport.openapi") + openapi_module.OrderSide = types.SimpleNamespace(Buy="Buy", Sell="Sell") + openapi_module.OrderType = types.SimpleNamespace(LO="LO", MO="MO") + openapi_module.TimeInForceType = types.SimpleNamespace(Day="Day") + sys.modules["longport"] = longport_module + sys.modules["longport.openapi"] = openapi_module + + class FakeCtx: + def __init__(self): + self.submit_args = None + + def submit_order(self, symbol, order_type, side, quantity, tif, **kwargs): + self.submit_args = (symbol, order_type, side, quantity, tif, kwargs) + return types.SimpleNamespace(order_id="LB-1") + + ctx = FakeCtx() + report = submit_order( + ctx, + "QQQM.US", + order_kind="market", + side="buy", + quantity=0.1, + allow_fractional_shares=True, + quantity_step=0.0001, + ) + qty = str(ctx.submit_args[3]) if ctx.submit_args else None + if report.status != "submitted" or qty != "0.1": + record("LongBridge", "fractional_market_buy", "fail", f"status={report.status} qty={qty}") + else: + record("LongBridge", "fractional_market_buy", "pass", "submitted quantity=0.1") + + blocked = submit_order( + FakeCtx(), + "QQQM.US", + order_kind="market", + side="buy", + quantity=0.1, + allow_fractional_shares=False, + ) + if blocked.status != "rejected": + record("LongBridge", "whole_share_gate", "fail", f"expected reject, got {blocked.status}") + else: + record("LongBridge", "whole_share_gate", "pass", "blocks sub-share buy without flag") + + +def verify_qpk_ibkr() -> None: + from quant_platform_kit.common.models import OrderIntent + from quant_platform_kit.ibkr.execution import submit_order_intent + + report = submit_order_intent( + object(), + OrderIntent(symbol="QQQM", side="buy", quantity=0, metadata={"notional_usd": 50.0}), + wait_seconds=0, + ) + if report.status != "rejected" or report.raw_payload.get("skip_reason") != "ibkr_fractional_equity_api_unsupported": + record("IBKR", "notional_rejected", "fail", f"unexpected report: {report}") + else: + record("IBKR", "notional_rejected", "pass", "rejects notional equity at API layer") + + +def verify_firstrade_execution_layer() -> None: + platform_root = ROOT / "FirstradePlatform" + if str(platform_root) not in sys.path: + sys.path.insert(0, str(platform_root)) + from application.execution_service import execute_value_target_plan + + class FakeQuote: + def __init__(self, price: float): + self.last_price = price + + class FakeMarketDataPort: + def get_quote(self, symbol: str): + return FakeQuote(500.0) + + class FakeExecutionPort: + def __init__(self): + self.orders = [] + + def submit_order(self, order_intent): + self.orders.append(order_intent) + return types.SimpleNamespace( + symbol=order_intent.symbol, + side=order_intent.side, + quantity=order_intent.metadata.get("notional_usd", order_intent.quantity), + status="previewed", + broker_order_id="FT-1", + raw_payload={"notional": True}, + ) + + port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": {"targets": {"QQQM": 50.0}}, + "portfolio": {"market_values": {"QQQM": 0.0}, "liquid_cash": 100.0, "sellable_quantities": {}}, + "execution": {"current_min_trade": 1.0, "investable_cash": 100.0}, + }, + market_data_port=FakeMarketDataPort(), + execution_port=port, + dry_run_only=True, + notional_buy_execution=True, + ) + if not result.action_done or not port.orders: + record("Firstrade", "dca_notional_intent", "fail", "no order submitted") + return + order = port.orders[0] + notional = order.metadata.get("notional_usd") + if order.order_type != "market" or notional != 50.0: + record("Firstrade", "dca_notional_intent", "fail", f"order_type={order.order_type} notional={notional}") + else: + record("Firstrade", "dca_notional_intent", "pass", "market buy metadata.notional_usd=50") + + whole_port = FakeExecutionPort() + whole_result = execute_value_target_plan( + plan={ + "allocation": {"targets": {"QQQM": 50.0}}, + "portfolio": {"market_values": {"QQQM": 0.0}, "liquid_cash": 100.0, "sellable_quantities": {}}, + "execution": {"current_min_trade": 1.0, "investable_cash": 100.0}, + }, + market_data_port=FakeMarketDataPort(), + execution_port=whole_port, + dry_run_only=True, + notional_buy_execution=False, + ) + if whole_result.action_done and whole_port.orders and whole_port.orders[0].metadata.get("notional_usd"): + record("Firstrade", "rotation_no_notional", "fail", "rotation path emitted notional order") + elif whole_result.action_done and whole_port.orders and int(whole_port.orders[0].quantity or 0) == 0: + record("Firstrade", "rotation_no_notional", "warn", "whole-share path skipped $50 buy (qty=0 at $500)") + else: + record( + "Firstrade", + "rotation_no_notional", + "pass", + "rotation path uses share qty, not notional metadata", + ) + + +def _run_subprocess_check(platform: str, platform_dir: Path, script: str) -> None: + env = dict(**{k: v for k, v in __import__("os").environ.items()}) + env["PYTHONPATH"] = f"{QPK_SRC}:{platform_dir}" + proc = subprocess.run( + [sys.executable, "-c", script], + cwd=str(platform_dir), + env=env, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "subprocess failed").strip().splitlines()[-1] + record(platform, "execution_layer", "fail", detail) + return + try: + payload = json.loads(proc.stdout.strip().splitlines()[-1]) + except json.JSONDecodeError: + record(platform, "execution_layer", "fail", f"bad subprocess output: {proc.stdout!r}") + return + for item in payload: + record(platform, item["scenario"], item["status"], item["detail"]) + + +def verify_longbridge_execution_layer() -> None: + script = r''' +import json +from application.execution_service import execute_rebalance_cycle +from quant_platform_kit.common.models import ExecutionReport, QuoteSnapshot +from quant_platform_kit.common.port_adapters import CallableExecutionPort, CallableMarketDataPort + +captured = [] +result = execute_rebalance_cycle( + trade_context=object(), + plan={"allocation": {"strategy_symbols": ("QQQM",), "risk_symbols": ("QQQM",), "income_symbols": (), "targets": {"QQQM": 50.0}}}, + portfolio={"market_values": {"QQQM": 0.0}, "quantities": {"QQQM": 0}, "sellable_quantities": {"QQQM": 0}, "liquid_cash": 50.0}, + execution={"trade_threshold_value": 1.0, "current_min_trade": 1.0, "investable_cash": 50.0}, + allocation={"strategy_symbols": ("QQQM",), "risk_symbols": ("QQQM",), "income_symbols": (), "targets": {"QQQM": 50.0}}, + fetch_replanned_state=lambda: ( + {"allocation": {"strategy_symbols": ("QQQM",), "risk_symbols": ("QQQM",), "income_symbols": (), "targets": {"QQQM": 50.0}}}, + {"market_values": {"QQQM": 0.0}, "quantities": {"QQQM": 0}, "sellable_quantities": {"QQQM": 0}, "liquid_cash": 50.0}, + {"trade_threshold_value": 1.0, "current_min_trade": 1.0, "investable_cash": 50.0}, + {"strategy_symbols": ("QQQM",), "risk_symbols": ("QQQM",), "income_symbols": (), "targets": {"QQQM": 50.0}}, + ), + market_data_port=CallableMarketDataPort(quote_loader=lambda symbol: QuoteSnapshot(symbol=symbol, as_of="2026-06-28", last_price=500.0)), + estimate_max_purchase_quantity=lambda *_a, **_k: 0.1, + execution_port=CallableExecutionPort(lambda order_intent: (captured.append(order_intent), ExecutionReport(symbol=order_intent.symbol, side=order_intent.side, quantity=order_intent.quantity, status="accepted", broker_order_id="LB-1"))[-1]), + notify_issue=lambda *_a, **_k: None, + translator=lambda key, **kwargs: key, + with_prefix=lambda message: message, + fractional_buy_execution=True, + buy_quantity_step=0.0001, + min_order_notional_usd=100.0, + limit_sell_discount=1.0, + limit_buy_premium=1.0, +) +out = [] +if result.action_done and captured and abs(float(captured[0].quantity) - 0.1) < 1e-6 and captured[0].order_type == "market": + out.append({"scenario": "dca_fractional_qty", "status": "pass", "detail": "market buy quantity=0.1"}) +else: + out.append({"scenario": "dca_fractional_qty", "status": "fail", "detail": f"action_done={result.action_done} captured={captured}"}) +out.append({"scenario": "live_api_fractional", "status": "warn", "detail": "decimal qty depends on LongBridge account entitlement; no API fractional flag on submit"}) +print(json.dumps(out)) +''' + _run_subprocess_check("LongBridge", ROOT / "LongBridgePlatform", script) + + +def verify_schwab_execution_layer() -> None: + script = r''' +import json +from application.execution_service import execute_rebalance_cycle +from quant_platform_kit.common.models import ExecutionReport, QuoteSnapshot +from quant_platform_kit.common.port_adapters import CallableExecutionPort, CallableMarketDataPort + +captured = [] +plan = { + "account_hash": "demo", + "allocation": {"target_mode": "value", "strategy_symbols": ("QQQM",), "risk_symbols": ("QQQM",), "income_symbols": (), "safe_haven_symbols": (), "targets": {"QQQM": 50.0}}, + "portfolio": {"market_values": {"QQQM": 0.0}, "quantities": {"QQQM": 0}, "liquid_cash": 50.0, "cash_sweep_symbol": ""}, + "execution": {"trade_threshold_value": 1.0, "reserved_cash": 0.0}, +} +execute_rebalance_cycle( + client=object(), + plan=plan, + portfolio=plan["portfolio"], + execution=plan["execution"], + allocation=plan["allocation"], + fetch_managed_snapshot=lambda _c: None, + market_data_port=CallableMarketDataPort(quote_loader=lambda symbol: QuoteSnapshot(symbol=symbol, as_of="2026-06-28", last_price=500.0, ask_price=500.0)), + load_plan=lambda _s: (plan, plan["portfolio"], plan["execution"], plan["allocation"]), + execution_port=CallableExecutionPort(lambda order_intent: (captured.append(order_intent), ExecutionReport(symbol=order_intent.symbol, side=order_intent.side, quantity=order_intent.quantity, status="accepted", broker_order_id="SCH-1"))[-1]), + translator=lambda key, **kwargs: key, + limit_buy_premium=1.0, + sell_settle_delay_sec=0, + publish_order_issue=lambda _m: None, + notional_buy_execution=True, +) +out = [] +if captured and captured[0].metadata.get("notional_usd") == 50.0 and captured[0].order_type == "market": + out.append({"scenario": "dca_notional_intent", "status": "pass", "detail": "metadata.notional_usd=50 market order"}) +else: + out.append({"scenario": "dca_notional_intent", "status": "fail", "detail": f"captured={captured}"}) +out.append({"scenario": "live_dollars_order", "status": "warn", "detail": "quantityType=DOLLARS needs paper/live validation"}) +print(json.dumps(out)) +''' + _run_subprocess_check("Schwab", ROOT / "CharlesSchwabPlatform", script) + + +def verify_ibkr_policy() -> None: + platform_root = ROOT / "InteractiveBrokersPlatform" + qpk_caps = importlib.import_module("quant_platform_kit.common.execution_capabilities") + qpk_strategies = importlib.import_module("quant_platform_kit.common.strategies") + + fake_registry = types.ModuleType("strategy_registry") + fake_registry.PLATFORM_CAPABILITY_MATRIX = qpk_strategies.PlatformCapabilityMatrix( + platform_id="ibkr", + supported_domains=frozenset({"us_equity"}), + supported_target_modes=frozenset({"weight", "value"}), + supported_inputs=frozenset(), + supported_capabilities=frozenset({"broker_client"}), + ) + fake_registry.STRATEGY_CATALOG = qpk_strategies.StrategyCatalog( + definitions={ + "nasdaq_sp500_smart_dca": qpk_strategies.StrategyDefinition( + profile="nasdaq_sp500_smart_dca", + domain="us_equity", + supported_platforms=frozenset({"ibkr"}), + compatible_capabilities=frozenset({qpk_caps.FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), + ), + } + ) + sys.modules["strategy_registry"] = fake_registry + policy = _load_module("ibkr_runtime_execution_policy", platform_root / "runtime_execution_policy.py") + reason = policy.dca_execution_unsupported_reason("nasdaq_sp500_smart_dca") + if reason != policy.IBKR_FRACTIONAL_EQUITY_API_UNSUPPORTED_SKIP_REASON: + record("IBKR", "dca_policy_skip", "fail", f"reason={reason}") + else: + record("IBKR", "dca_policy_skip", "pass", "DCA profile blocked with ibkr-specific reason") + + +def print_report() -> int: + counts = {"pass": 0, "fail": 0, "warn": 0, "skip": 0} + print("\n=== Fractional / Notional DCA Verification ===\n") + current_platform = None + for item in RESULTS: + if item.platform != current_platform: + current_platform = item.platform + print(f"\n[{current_platform}]") + counts[item.status] = counts.get(item.status, 0) + 1 + icon = {"pass": "OK", "fail": "FAIL", "warn": "WARN", "skip": "SKIP"}[item.status] + print(f" {icon:4} {item.scenario}: {item.detail}") + if item.payload: + print(f" payload={json.dumps(item.payload, default=str)}") + + print( + f"\nSummary: pass={counts['pass']} fail={counts['fail']} " + f"warn={counts['warn']} skip={counts.get('skip', 0)}" + ) + if counts["fail"]: + print("\nSome automated checks failed — fix before enabling live DCA.") + return 1 + if counts["warn"]: + print("\nAutomated checks passed; see WARN items for paper/live validation still required.") + else: + print("\nAll automated checks passed.") + return 0 + + +def main() -> int: + checks: list[tuple[str, Callable[[], None]]] = [ + ("Schwab QPK", verify_qpk_schwab), + ("LongBridge QPK", verify_qpk_longbridge), + ("IBKR QPK", verify_qpk_ibkr), + ("Firstrade execution", verify_firstrade_execution_layer), + ("LongBridge execution", verify_longbridge_execution_layer), + ("Schwab execution", verify_schwab_execution_layer), + ("IBKR policy", verify_ibkr_policy), + ] + for label, fn in checks: + try: + fn() + except Exception as exc: + record("?", label, "fail", f"exception: {exc}") + return print_report() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py index 8ebb209b..a6a2c5cc 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="quant-platform-kit", - version="0.7.40", + version="0.7.41", description="Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies.", package_dir={"": "src"}, packages=find_packages(where="src"), diff --git a/src/quant_platform_kit/__init__.py b/src/quant_platform_kit/__init__.py index c62644e1..eb2cc78e 100644 --- a/src/quant_platform_kit/__init__.py +++ b/src/quant_platform_kit/__init__.py @@ -4,7 +4,7 @@ used by older strategy repositories. """ -__version__ = "0.7.40" +__version__ = "0.7.41" from .common.models import ( ExecutionReport, diff --git a/src/quant_platform_kit/common/execution_capabilities.py b/src/quant_platform_kit/common/execution_capabilities.py new file mode 100644 index 00000000..9d793514 --- /dev/null +++ b/src/quant_platform_kit/common/execution_capabilities.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from .strategies import PlatformCapabilityMatrix, StrategyCatalog, StrategyDefinition, normalize_profile_name + +FRACTIONAL_SHARE_EXECUTION_CAPABILITY = "fractional_share_execution" +FRACTIONAL_SHARE_EXECUTION_SKIP_REASON = "fractional_share_execution_required" + + +def definition_requires_fractional_share_execution(definition: StrategyDefinition) -> bool: + return FRACTIONAL_SHARE_EXECUTION_CAPABILITY in frozenset(definition.compatible_capabilities) + + +def platform_supports_fractional_share_execution(*, capability_matrix: PlatformCapabilityMatrix) -> bool: + return FRACTIONAL_SHARE_EXECUTION_CAPABILITY in frozenset(capability_matrix.supported_capabilities) + + +def fractional_share_execution_unsupported_reason( + profile: str, + *, + strategy_catalog: StrategyCatalog, + capability_matrix: PlatformCapabilityMatrix, +) -> str | None: + normalized_profile = normalize_profile_name(profile) + definition = strategy_catalog.definitions.get(normalized_profile) + if definition is None: + return None + if definition_requires_fractional_share_execution(definition): + if not platform_supports_fractional_share_execution(capability_matrix=capability_matrix): + return FRACTIONAL_SHARE_EXECUTION_SKIP_REASON + return None diff --git a/src/quant_platform_kit/ibkr/execution.py b/src/quant_platform_kit/ibkr/execution.py index 4080685e..91f7d91d 100644 --- a/src/quant_platform_kit/ibkr/execution.py +++ b/src/quant_platform_kit/ibkr/execution.py @@ -201,6 +201,26 @@ def submit_order_intent( limit_order_factory: Callable[..., Any] | None = None, ) -> ExecutionReport: metadata = dict(order_intent.metadata or {}) + notional_usd = metadata.get("notional_usd") + if ( + notional_usd is not None + and not _is_option_intent(order_intent) + and not _is_combo_option_intent(order_intent) + ): + return ExecutionReport( + symbol=order_intent.symbol, + side=order_intent.side.lower(), + quantity=float(notional_usd), + status="rejected", + raw_payload={ + "detail": ( + "IBKR TWS API does not support fractional or notional equity orders " + f"(notional_usd={float(notional_usd):.2f})." + ), + "skip_reason": "ibkr_fractional_equity_api_unsupported", + }, + ) + if _is_combo_option_intent(order_intent): contract = _build_option_combo_contract( ib, diff --git a/src/quant_platform_kit/longbridge/execution.py b/src/quant_platform_kit/longbridge/execution.py index a3b1d3f1..f3cccb34 100644 --- a/src/quant_platform_kit/longbridge/execution.py +++ b/src/quant_platform_kit/longbridge/execution.py @@ -5,6 +5,9 @@ from quant_platform_kit.common.models import ExecutionReport +LONGBRIDGE_FRACTIONAL_QUANTITY_STEP = Decimal("0.0001") +LONGBRIDGE_MIN_FRACTIONAL_BUY_QUANTITY = Decimal("0.0001") + def estimate_max_purchase_quantity( t_ctx: Any, @@ -12,16 +15,20 @@ def estimate_max_purchase_quantity( *, order_kind: str, ref_price: float, + fractional_shares: bool = False, ) -> float: from longport.openapi import OrderSide, OrderType order_type = OrderType.LO if order_kind == "limit" else OrderType.MO - response = t_ctx.estimate_max_purchase_quantity( - symbol=symbol, - order_type=order_type, - side=OrderSide.Buy, - price=Decimal(str(ref_price)), - ) + estimate_kwargs: dict[str, Any] = { + "symbol": symbol, + "order_type": order_type, + "side": OrderSide.Buy, + "price": Decimal(str(ref_price)), + } + if fractional_shares: + estimate_kwargs["fractional_shares"] = True + response = t_ctx.estimate_max_purchase_quantity(**estimate_kwargs) cash_max_qty = getattr(response, "cash_max_qty", 0) return max(0.0, float(Decimal(str(cash_max_qty or "0")))) @@ -34,13 +41,34 @@ def submit_order( side: str, quantity: float, submitted_price: float | None = None, + allow_fractional_shares: bool = False, + quantity_step: float = 1.0, ) -> ExecutionReport: from longport.openapi import OrderSide, OrderType, TimeInForceType order_type = OrderType.LO if order_kind == "limit" else OrderType.MO order_side = OrderSide.Buy if side == "buy" else OrderSide.Sell submitted_quantity = Decimal(str(quantity)) - if submitted_quantity < Decimal("1"): + if side == "buy" and allow_fractional_shares: + min_buy_quantity = max( + LONGBRIDGE_MIN_FRACTIONAL_BUY_QUANTITY, + Decimal(str(quantity_step)), + ) + if submitted_quantity < min_buy_quantity: + return ExecutionReport( + symbol=symbol.split(".")[0], + side=side, + quantity=float(quantity), + status="rejected", + raw_payload={ + "detail": ( + "LongBridge fractional buy submitted_quantity must be at least " + f"{min_buy_quantity}; got {submitted_quantity}." + ), + "order_kind": order_kind, + }, + ) + elif submitted_quantity < Decimal("1"): return ExecutionReport( symbol=symbol.split(".")[0], side=side, @@ -54,7 +82,12 @@ def submit_order( "order_kind": order_kind, }, ) - if order_kind == "limit" and side == "buy" and submitted_quantity != submitted_quantity.to_integral_value(): + if ( + not allow_fractional_shares + and order_kind == "limit" + and side == "buy" + and submitted_quantity != submitted_quantity.to_integral_value() + ): return ExecutionReport( symbol=symbol.split(".")[0], side=side, diff --git a/src/quant_platform_kit/schwab/execution.py b/src/quant_platform_kit/schwab/execution.py index 4f787ee6..451efa61 100644 --- a/src/quant_platform_kit/schwab/execution.py +++ b/src/quant_platform_kit/schwab/execution.py @@ -4,25 +4,64 @@ from quant_platform_kit.common.models import ExecutionReport, OrderIntent +MIN_DOLLAR_BUY_NOTIONAL_USD = 1.0 + + +def build_equity_dollar_buy_market_order(symbol: str, notional_usd: float) -> dict[str, Any]: + notional = round(float(notional_usd), 2) + if notional < MIN_DOLLAR_BUY_NOTIONAL_USD: + raise ValueError( + f"Schwab dollar buy notional_usd must be at least {MIN_DOLLAR_BUY_NOTIONAL_USD:.2f}; got {notional:.2f}." + ) + normalized_symbol = str(symbol or "").strip().upper() + if not normalized_symbol: + raise ValueError("Schwab dollar buy requires a non-empty symbol.") + return { + "orderType": "MARKET", + "session": "NORMAL", + "duration": "DAY", + "orderStrategyType": "SINGLE", + "orderLegCollection": [ + { + "instruction": "BUY", + "quantity": notional, + "quantityType": "DOLLARS", + "instrument": { + "symbol": normalized_symbol, + "assetType": "EQUITY", + }, + } + ], + } -def submit_equity_order(api_client: Any, account_hash: str, order_intent: OrderIntent) -> ExecutionReport: - from schwab.orders.equities import equity_buy_limit, equity_buy_market, equity_sell_market +def submit_equity_order(api_client: Any, account_hash: str, order_intent: OrderIntent) -> ExecutionReport: side = order_intent.side.lower() order_type = order_intent.order_type.lower() + metadata = dict(getattr(order_intent, "metadata", {}) or {}) + notional_usd = metadata.get("notional_usd") - if side == "sell" and order_type == "market": - order = equity_sell_market(order_intent.symbol, order_intent.quantity) - elif side == "buy" and order_type == "market": - order = equity_buy_market(order_intent.symbol, order_intent.quantity) - elif side == "buy" and order_type == "limit": - if order_intent.limit_price is None: - raise ValueError("Limit buy orders require OrderIntent.limit_price.") - order = equity_buy_limit(order_intent.symbol, order_intent.quantity, f"{order_intent.limit_price:.2f}") + if side == "buy" and notional_usd is not None: + order = build_equity_dollar_buy_market_order(order_intent.symbol, float(notional_usd)) + reported_quantity = float(notional_usd) else: - raise ValueError( - f"Unsupported Schwab order intent: side={order_intent.side!r}, order_type={order_intent.order_type!r}" - ) + from schwab.orders.equities import equity_buy_limit, equity_buy_market, equity_sell_market + + if side == "sell" and order_type == "market": + order = equity_sell_market(order_intent.symbol, order_intent.quantity) + reported_quantity = float(order_intent.quantity) + elif side == "buy" and order_type == "market": + order = equity_buy_market(order_intent.symbol, order_intent.quantity) + reported_quantity = float(order_intent.quantity) + elif side == "buy" and order_type == "limit": + if order_intent.limit_price is None: + raise ValueError("Limit buy orders require OrderIntent.limit_price.") + order = equity_buy_limit(order_intent.symbol, order_intent.quantity, f"{order_intent.limit_price:.2f}") + reported_quantity = float(order_intent.quantity) + else: + raise ValueError( + f"Unsupported Schwab order intent: side={order_intent.side!r}, order_type={order_intent.order_type!r}" + ) response = api_client.place_order(account_hash, order) if response.status_code in (200, 201): @@ -31,7 +70,7 @@ def submit_equity_order(api_client: Any, account_hash: str, order_intent: OrderI return ExecutionReport( symbol=order_intent.symbol, side=side, - quantity=float(order_intent.quantity), + quantity=reported_quantity, status="accepted", broker_order_id=order_id, raw_payload={"status_code": response.status_code}, @@ -40,7 +79,7 @@ def submit_equity_order(api_client: Any, account_hash: str, order_intent: OrderI return ExecutionReport( symbol=order_intent.symbol, side=side, - quantity=float(order_intent.quantity), + quantity=reported_quantity, status="rejected", raw_payload={ "status_code": response.status_code, diff --git a/tests/test_execution_capabilities.py b/tests/test_execution_capabilities.py new file mode 100644 index 00000000..8a7f1c5b --- /dev/null +++ b/tests/test_execution_capabilities.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import unittest + +from quant_platform_kit.common.execution_capabilities import ( + FRACTIONAL_SHARE_EXECUTION_CAPABILITY, + FRACTIONAL_SHARE_EXECUTION_SKIP_REASON, + fractional_share_execution_unsupported_reason, +) +from quant_platform_kit.common.strategies import ( + PlatformCapabilityMatrix, + StrategyCatalog, + StrategyDefinition, + US_EQUITY_DOMAIN, + derive_eligible_profiles_for_platform, +) +from quant_platform_kit.common.strategy_contracts import StrategyRuntimeAdapter + + +class ExecutionCapabilitiesTests(unittest.TestCase): + def test_fractional_share_execution_unsupported_reason(self) -> None: + catalog = StrategyCatalog( + definitions={ + "ibit_smart_dca": StrategyDefinition( + profile="ibit_smart_dca", + domain=US_EQUITY_DOMAIN, + supported_platforms=frozenset({"schwab"}), + required_inputs=frozenset({"portfolio_snapshot"}), + target_mode="value", + compatible_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), + ), + "tqqq_growth_income": StrategyDefinition( + profile="tqqq_growth_income", + domain=US_EQUITY_DOMAIN, + supported_platforms=frozenset({"schwab"}), + required_inputs=frozenset({"portfolio_snapshot"}), + target_mode="value", + ), + } + ) + whole_share_matrix = PlatformCapabilityMatrix( + platform_id="schwab", + supported_domains=frozenset({US_EQUITY_DOMAIN}), + supported_target_modes=frozenset({"value"}), + supported_inputs=frozenset({"portfolio_snapshot"}), + supported_capabilities=frozenset(), + ) + fractional_matrix = PlatformCapabilityMatrix( + platform_id="schwab", + supported_domains=frozenset({US_EQUITY_DOMAIN}), + supported_target_modes=frozenset({"value"}), + supported_inputs=frozenset({"portfolio_snapshot"}), + supported_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), + ) + + self.assertEqual( + fractional_share_execution_unsupported_reason( + "ibit_smart_dca", + strategy_catalog=catalog, + capability_matrix=whole_share_matrix, + ), + FRACTIONAL_SHARE_EXECUTION_SKIP_REASON, + ) + self.assertIsNone( + fractional_share_execution_unsupported_reason( + "ibit_smart_dca", + strategy_catalog=catalog, + capability_matrix=fractional_matrix, + ) + ) + self.assertIsNone( + fractional_share_execution_unsupported_reason( + "tqqq_growth_income", + strategy_catalog=catalog, + capability_matrix=whole_share_matrix, + ) + ) + + def test_capability_matrix_excludes_fractional_dca_profiles(self) -> None: + catalog = StrategyCatalog( + definitions={ + "ibit_smart_dca": StrategyDefinition( + profile="ibit_smart_dca", + domain=US_EQUITY_DOMAIN, + supported_platforms=frozenset({"schwab"}), + required_inputs=frozenset({"portfolio_snapshot"}), + target_mode="value", + compatible_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), + ), + } + ) + matrix = PlatformCapabilityMatrix( + platform_id="schwab", + supported_domains=frozenset({US_EQUITY_DOMAIN}), + supported_target_modes=frozenset({"value"}), + supported_inputs=frozenset({"portfolio_snapshot"}), + supported_capabilities=frozenset(), + ) + adapters = { + "ibit_smart_dca": StrategyRuntimeAdapter( + available_inputs=frozenset({"portfolio_snapshot"}), + available_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}), + ), + } + + eligible = derive_eligible_profiles_for_platform( + catalog, + capability_matrix=matrix, + runtime_adapter_loader=lambda profile: adapters[profile], + ) + + self.assertEqual(eligible, frozenset()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ibkr_execution.py b/tests/test_ibkr_execution.py index cd68875f..d37def9f 100644 --- a/tests/test_ibkr_execution.py +++ b/tests/test_ibkr_execution.py @@ -119,6 +119,25 @@ def test_submit_order_intent_sets_account_when_provided(self) -> None: self.assertEqual(ib.orders[0][1].account, "U18308207") self.assertEqual(report.raw_payload["account_id"], "U18308207") + def test_submit_order_intent_rejects_notional_equity_order(self) -> None: + ib = FakeIB() + report = submit_order_intent( + ib, + OrderIntent( + symbol="SPY", + side="buy", + quantity=0, + metadata={"notional_usd": 100.0}, + ), + wait_seconds=0, + stock_factory=FakeContract, + market_order_factory=FakeMarketOrder, + ) + + self.assertEqual(report.status, "rejected") + self.assertEqual(report.raw_payload["skip_reason"], "ibkr_fractional_equity_api_unsupported") + self.assertEqual(ib.orders, []) + def test_submit_order_intent_rejects_conflicting_account_id(self) -> None: ib = FakeIB() diff --git a/tests/test_longbridge_execution.py b/tests/test_longbridge_execution.py index d5affe0d..790b9357 100644 --- a/tests/test_longbridge_execution.py +++ b/tests/test_longbridge_execution.py @@ -55,6 +55,25 @@ def test_estimate_max_purchase_quantity(self) -> None: self.assertEqual(quantity, 12) self.assertNotIn("fractional_shares", ctx.estimate_kwargs) + def test_estimate_max_purchase_quantity_with_fractional_shares(self) -> None: + longport_module = types.ModuleType("longport") + openapi_module = types.ModuleType("longport.openapi") + openapi_module.OrderSide = types.SimpleNamespace(Buy="Buy") + openapi_module.OrderType = types.SimpleNamespace(LO="LO", MO="MO") + + ctx = FakeTradeContext() + with patch.dict(sys.modules, {"longport": longport_module, "longport.openapi": openapi_module}): + quantity = estimate_max_purchase_quantity( + ctx, + "QQQM.US", + order_kind="market", + ref_price=100.5, + fractional_shares=True, + ) + + self.assertEqual(quantity, 12) + self.assertTrue(ctx.estimate_kwargs.get("fractional_shares")) + def test_submit_order(self) -> None: longport_module = types.ModuleType("longport") openapi_module = types.ModuleType("longport.openapi") @@ -105,6 +124,51 @@ def test_submit_order_rejects_quantity_below_one_before_api_call(self) -> None: self.assertIn("at least 1 share", report.raw_payload["detail"]) self.assertFalse(hasattr(ctx, "submit_args")) + def test_submit_order_allows_fractional_market_buy_when_enabled(self) -> None: + longport_module = types.ModuleType("longport") + openapi_module = types.ModuleType("longport.openapi") + openapi_module.OrderSide = types.SimpleNamespace(Buy="Buy", Sell="Sell") + openapi_module.OrderType = types.SimpleNamespace(LO="LO", MO="MO") + openapi_module.TimeInForceType = types.SimpleNamespace(Day="Day") + + ctx = FakeTradeContext() + with patch.dict(sys.modules, {"longport": longport_module, "longport.openapi": openapi_module}): + report = submit_order( + ctx, + "QQQM.US", + order_kind="market", + side="buy", + quantity=0.4326, + allow_fractional_shares=True, + quantity_step=0.0001, + ) + + self.assertEqual(report.status, "submitted") + self.assertEqual(str(ctx.submit_args[3]), "0.4326") + + def test_submit_order_allows_fractional_limit_buy_when_enabled(self) -> None: + longport_module = types.ModuleType("longport") + openapi_module = types.ModuleType("longport.openapi") + openapi_module.OrderSide = types.SimpleNamespace(Buy="Buy", Sell="Sell") + openapi_module.OrderType = types.SimpleNamespace(LO="LO", MO="MO") + openapi_module.TimeInForceType = types.SimpleNamespace(Day="Day") + + ctx = FakeTradeContext() + with patch.dict(sys.modules, {"longport": longport_module, "longport.openapi": openapi_module}): + report = submit_order( + ctx, + "QQQM.US", + order_kind="limit", + side="buy", + quantity=0.4326, + submitted_price=100.25, + allow_fractional_shares=True, + quantity_step=0.0001, + ) + + self.assertEqual(report.status, "submitted") + self.assertEqual(str(ctx.submit_args[3]), "0.4326") + def test_submit_order_rejects_fractional_quantity_before_api_call(self) -> None: longport_module = types.ModuleType("longport") openapi_module = types.ModuleType("longport.openapi") diff --git a/tests/test_schwab_execution.py b/tests/test_schwab_execution.py index c3bbc2c2..2756ab17 100644 --- a/tests/test_schwab_execution.py +++ b/tests/test_schwab_execution.py @@ -61,6 +61,28 @@ def test_submit_sell_market_returns_rejected_report(self) -> None: self.assertEqual(report.status, "rejected") self.assertIn("bad request", report.raw_payload["detail"]) + def test_submit_dollar_buy_market_uses_quantity_type_dollars(self) -> None: + client = FakeClient(FakeResponse(201, headers={"Location": "/orders/789"})) + report = submit_equity_order( + client, + "acct-hash", + OrderIntent( + symbol="QQQM", + side="buy", + quantity=0.0, + order_type="market", + metadata={"notional_usd": 50.0}, + ), + ) + + self.assertEqual(report.status, "accepted") + self.assertEqual(report.quantity, 50.0) + order = client.last_call[1] + self.assertEqual(order["orderType"], "MARKET") + self.assertEqual(order["orderLegCollection"][0]["quantityType"], "DOLLARS") + self.assertEqual(order["orderLegCollection"][0]["quantity"], 50.0) + self.assertEqual(order["orderLegCollection"][0]["instrument"]["symbol"], "QQQM") + if __name__ == "__main__": unittest.main()