Skip to content

Commit dfdbef6

Browse files
Pigbibicursoragent
andauthored
Add fractional/notional DCA execution helpers and broker paths. (#114)
Introduce shared execution capability gating, Schwab DOLLARS orders, LongBridge fractional buys, IBKR notional rejection, and verification script. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7b6e3ce commit dfdbef6

12 files changed

Lines changed: 823 additions & 26 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quant-platform-kit"
7-
version = "0.7.40"
7+
version = "0.7.41"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

scripts/verify_fractional_dca_execution.py

Lines changed: 454 additions & 0 deletions
Large diffs are not rendered by default.

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
setup(
55
name="quant-platform-kit",
6-
version="0.7.40",
6+
version="0.7.41",
77
description="Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies.",
88
package_dir={"": "src"},
99
packages=find_packages(where="src"),

src/quant_platform_kit/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
used by older strategy repositories.
55
"""
66

7-
__version__ = "0.7.40"
7+
__version__ = "0.7.41"
88

99
from .common.models import (
1010
ExecutionReport,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from .strategies import PlatformCapabilityMatrix, StrategyCatalog, StrategyDefinition, normalize_profile_name
4+
5+
FRACTIONAL_SHARE_EXECUTION_CAPABILITY = "fractional_share_execution"
6+
FRACTIONAL_SHARE_EXECUTION_SKIP_REASON = "fractional_share_execution_required"
7+
8+
9+
def definition_requires_fractional_share_execution(definition: StrategyDefinition) -> bool:
10+
return FRACTIONAL_SHARE_EXECUTION_CAPABILITY in frozenset(definition.compatible_capabilities)
11+
12+
13+
def platform_supports_fractional_share_execution(*, capability_matrix: PlatformCapabilityMatrix) -> bool:
14+
return FRACTIONAL_SHARE_EXECUTION_CAPABILITY in frozenset(capability_matrix.supported_capabilities)
15+
16+
17+
def fractional_share_execution_unsupported_reason(
18+
profile: str,
19+
*,
20+
strategy_catalog: StrategyCatalog,
21+
capability_matrix: PlatformCapabilityMatrix,
22+
) -> str | None:
23+
normalized_profile = normalize_profile_name(profile)
24+
definition = strategy_catalog.definitions.get(normalized_profile)
25+
if definition is None:
26+
return None
27+
if definition_requires_fractional_share_execution(definition):
28+
if not platform_supports_fractional_share_execution(capability_matrix=capability_matrix):
29+
return FRACTIONAL_SHARE_EXECUTION_SKIP_REASON
30+
return None

src/quant_platform_kit/ibkr/execution.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,26 @@ def submit_order_intent(
201201
limit_order_factory: Callable[..., Any] | None = None,
202202
) -> ExecutionReport:
203203
metadata = dict(order_intent.metadata or {})
204+
notional_usd = metadata.get("notional_usd")
205+
if (
206+
notional_usd is not None
207+
and not _is_option_intent(order_intent)
208+
and not _is_combo_option_intent(order_intent)
209+
):
210+
return ExecutionReport(
211+
symbol=order_intent.symbol,
212+
side=order_intent.side.lower(),
213+
quantity=float(notional_usd),
214+
status="rejected",
215+
raw_payload={
216+
"detail": (
217+
"IBKR TWS API does not support fractional or notional equity orders "
218+
f"(notional_usd={float(notional_usd):.2f})."
219+
),
220+
"skip_reason": "ibkr_fractional_equity_api_unsupported",
221+
},
222+
)
223+
204224
if _is_combo_option_intent(order_intent):
205225
contract = _build_option_combo_contract(
206226
ib,

src/quant_platform_kit/longbridge/execution.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,30 @@
55

66
from quant_platform_kit.common.models import ExecutionReport
77

8+
LONGBRIDGE_FRACTIONAL_QUANTITY_STEP = Decimal("0.0001")
9+
LONGBRIDGE_MIN_FRACTIONAL_BUY_QUANTITY = Decimal("0.0001")
10+
811

912
def estimate_max_purchase_quantity(
1013
t_ctx: Any,
1114
symbol: str,
1215
*,
1316
order_kind: str,
1417
ref_price: float,
18+
fractional_shares: bool = False,
1519
) -> float:
1620
from longport.openapi import OrderSide, OrderType
1721

1822
order_type = OrderType.LO if order_kind == "limit" else OrderType.MO
19-
response = t_ctx.estimate_max_purchase_quantity(
20-
symbol=symbol,
21-
order_type=order_type,
22-
side=OrderSide.Buy,
23-
price=Decimal(str(ref_price)),
24-
)
23+
estimate_kwargs: dict[str, Any] = {
24+
"symbol": symbol,
25+
"order_type": order_type,
26+
"side": OrderSide.Buy,
27+
"price": Decimal(str(ref_price)),
28+
}
29+
if fractional_shares:
30+
estimate_kwargs["fractional_shares"] = True
31+
response = t_ctx.estimate_max_purchase_quantity(**estimate_kwargs)
2532
cash_max_qty = getattr(response, "cash_max_qty", 0)
2633
return max(0.0, float(Decimal(str(cash_max_qty or "0"))))
2734

@@ -34,13 +41,34 @@ def submit_order(
3441
side: str,
3542
quantity: float,
3643
submitted_price: float | None = None,
44+
allow_fractional_shares: bool = False,
45+
quantity_step: float = 1.0,
3746
) -> ExecutionReport:
3847
from longport.openapi import OrderSide, OrderType, TimeInForceType
3948

4049
order_type = OrderType.LO if order_kind == "limit" else OrderType.MO
4150
order_side = OrderSide.Buy if side == "buy" else OrderSide.Sell
4251
submitted_quantity = Decimal(str(quantity))
43-
if submitted_quantity < Decimal("1"):
52+
if side == "buy" and allow_fractional_shares:
53+
min_buy_quantity = max(
54+
LONGBRIDGE_MIN_FRACTIONAL_BUY_QUANTITY,
55+
Decimal(str(quantity_step)),
56+
)
57+
if submitted_quantity < min_buy_quantity:
58+
return ExecutionReport(
59+
symbol=symbol.split(".")[0],
60+
side=side,
61+
quantity=float(quantity),
62+
status="rejected",
63+
raw_payload={
64+
"detail": (
65+
"LongBridge fractional buy submitted_quantity must be at least "
66+
f"{min_buy_quantity}; got {submitted_quantity}."
67+
),
68+
"order_kind": order_kind,
69+
},
70+
)
71+
elif submitted_quantity < Decimal("1"):
4472
return ExecutionReport(
4573
symbol=symbol.split(".")[0],
4674
side=side,
@@ -54,7 +82,12 @@ def submit_order(
5482
"order_kind": order_kind,
5583
},
5684
)
57-
if order_kind == "limit" and side == "buy" and submitted_quantity != submitted_quantity.to_integral_value():
85+
if (
86+
not allow_fractional_shares
87+
and order_kind == "limit"
88+
and side == "buy"
89+
and submitted_quantity != submitted_quantity.to_integral_value()
90+
):
5891
return ExecutionReport(
5992
symbol=symbol.split(".")[0],
6093
side=side,

src/quant_platform_kit/schwab/execution.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,64 @@
44

55
from quant_platform_kit.common.models import ExecutionReport, OrderIntent
66

7+
MIN_DOLLAR_BUY_NOTIONAL_USD = 1.0
8+
9+
10+
def build_equity_dollar_buy_market_order(symbol: str, notional_usd: float) -> dict[str, Any]:
11+
notional = round(float(notional_usd), 2)
12+
if notional < MIN_DOLLAR_BUY_NOTIONAL_USD:
13+
raise ValueError(
14+
f"Schwab dollar buy notional_usd must be at least {MIN_DOLLAR_BUY_NOTIONAL_USD:.2f}; got {notional:.2f}."
15+
)
16+
normalized_symbol = str(symbol or "").strip().upper()
17+
if not normalized_symbol:
18+
raise ValueError("Schwab dollar buy requires a non-empty symbol.")
19+
return {
20+
"orderType": "MARKET",
21+
"session": "NORMAL",
22+
"duration": "DAY",
23+
"orderStrategyType": "SINGLE",
24+
"orderLegCollection": [
25+
{
26+
"instruction": "BUY",
27+
"quantity": notional,
28+
"quantityType": "DOLLARS",
29+
"instrument": {
30+
"symbol": normalized_symbol,
31+
"assetType": "EQUITY",
32+
},
33+
}
34+
],
35+
}
736

8-
def submit_equity_order(api_client: Any, account_hash: str, order_intent: OrderIntent) -> ExecutionReport:
9-
from schwab.orders.equities import equity_buy_limit, equity_buy_market, equity_sell_market
1037

38+
def submit_equity_order(api_client: Any, account_hash: str, order_intent: OrderIntent) -> ExecutionReport:
1139
side = order_intent.side.lower()
1240
order_type = order_intent.order_type.lower()
41+
metadata = dict(getattr(order_intent, "metadata", {}) or {})
42+
notional_usd = metadata.get("notional_usd")
1343

14-
if side == "sell" and order_type == "market":
15-
order = equity_sell_market(order_intent.symbol, order_intent.quantity)
16-
elif side == "buy" and order_type == "market":
17-
order = equity_buy_market(order_intent.symbol, order_intent.quantity)
18-
elif side == "buy" and order_type == "limit":
19-
if order_intent.limit_price is None:
20-
raise ValueError("Limit buy orders require OrderIntent.limit_price.")
21-
order = equity_buy_limit(order_intent.symbol, order_intent.quantity, f"{order_intent.limit_price:.2f}")
44+
if side == "buy" and notional_usd is not None:
45+
order = build_equity_dollar_buy_market_order(order_intent.symbol, float(notional_usd))
46+
reported_quantity = float(notional_usd)
2247
else:
23-
raise ValueError(
24-
f"Unsupported Schwab order intent: side={order_intent.side!r}, order_type={order_intent.order_type!r}"
25-
)
48+
from schwab.orders.equities import equity_buy_limit, equity_buy_market, equity_sell_market
49+
50+
if side == "sell" and order_type == "market":
51+
order = equity_sell_market(order_intent.symbol, order_intent.quantity)
52+
reported_quantity = float(order_intent.quantity)
53+
elif side == "buy" and order_type == "market":
54+
order = equity_buy_market(order_intent.symbol, order_intent.quantity)
55+
reported_quantity = float(order_intent.quantity)
56+
elif side == "buy" and order_type == "limit":
57+
if order_intent.limit_price is None:
58+
raise ValueError("Limit buy orders require OrderIntent.limit_price.")
59+
order = equity_buy_limit(order_intent.symbol, order_intent.quantity, f"{order_intent.limit_price:.2f}")
60+
reported_quantity = float(order_intent.quantity)
61+
else:
62+
raise ValueError(
63+
f"Unsupported Schwab order intent: side={order_intent.side!r}, order_type={order_intent.order_type!r}"
64+
)
2665

2766
response = api_client.place_order(account_hash, order)
2867
if response.status_code in (200, 201):
@@ -31,7 +70,7 @@ def submit_equity_order(api_client: Any, account_hash: str, order_intent: OrderI
3170
return ExecutionReport(
3271
symbol=order_intent.symbol,
3372
side=side,
34-
quantity=float(order_intent.quantity),
73+
quantity=reported_quantity,
3574
status="accepted",
3675
broker_order_id=order_id,
3776
raw_payload={"status_code": response.status_code},
@@ -40,7 +79,7 @@ def submit_equity_order(api_client: Any, account_hash: str, order_intent: OrderI
4079
return ExecutionReport(
4180
symbol=order_intent.symbol,
4281
side=side,
43-
quantity=float(order_intent.quantity),
82+
quantity=reported_quantity,
4483
status="rejected",
4584
raw_payload={
4685
"status_code": response.status_code,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
5+
from quant_platform_kit.common.execution_capabilities import (
6+
FRACTIONAL_SHARE_EXECUTION_CAPABILITY,
7+
FRACTIONAL_SHARE_EXECUTION_SKIP_REASON,
8+
fractional_share_execution_unsupported_reason,
9+
)
10+
from quant_platform_kit.common.strategies import (
11+
PlatformCapabilityMatrix,
12+
StrategyCatalog,
13+
StrategyDefinition,
14+
US_EQUITY_DOMAIN,
15+
derive_eligible_profiles_for_platform,
16+
)
17+
from quant_platform_kit.common.strategy_contracts import StrategyRuntimeAdapter
18+
19+
20+
class ExecutionCapabilitiesTests(unittest.TestCase):
21+
def test_fractional_share_execution_unsupported_reason(self) -> None:
22+
catalog = StrategyCatalog(
23+
definitions={
24+
"ibit_smart_dca": StrategyDefinition(
25+
profile="ibit_smart_dca",
26+
domain=US_EQUITY_DOMAIN,
27+
supported_platforms=frozenset({"schwab"}),
28+
required_inputs=frozenset({"portfolio_snapshot"}),
29+
target_mode="value",
30+
compatible_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}),
31+
),
32+
"tqqq_growth_income": StrategyDefinition(
33+
profile="tqqq_growth_income",
34+
domain=US_EQUITY_DOMAIN,
35+
supported_platforms=frozenset({"schwab"}),
36+
required_inputs=frozenset({"portfolio_snapshot"}),
37+
target_mode="value",
38+
),
39+
}
40+
)
41+
whole_share_matrix = PlatformCapabilityMatrix(
42+
platform_id="schwab",
43+
supported_domains=frozenset({US_EQUITY_DOMAIN}),
44+
supported_target_modes=frozenset({"value"}),
45+
supported_inputs=frozenset({"portfolio_snapshot"}),
46+
supported_capabilities=frozenset(),
47+
)
48+
fractional_matrix = PlatformCapabilityMatrix(
49+
platform_id="schwab",
50+
supported_domains=frozenset({US_EQUITY_DOMAIN}),
51+
supported_target_modes=frozenset({"value"}),
52+
supported_inputs=frozenset({"portfolio_snapshot"}),
53+
supported_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}),
54+
)
55+
56+
self.assertEqual(
57+
fractional_share_execution_unsupported_reason(
58+
"ibit_smart_dca",
59+
strategy_catalog=catalog,
60+
capability_matrix=whole_share_matrix,
61+
),
62+
FRACTIONAL_SHARE_EXECUTION_SKIP_REASON,
63+
)
64+
self.assertIsNone(
65+
fractional_share_execution_unsupported_reason(
66+
"ibit_smart_dca",
67+
strategy_catalog=catalog,
68+
capability_matrix=fractional_matrix,
69+
)
70+
)
71+
self.assertIsNone(
72+
fractional_share_execution_unsupported_reason(
73+
"tqqq_growth_income",
74+
strategy_catalog=catalog,
75+
capability_matrix=whole_share_matrix,
76+
)
77+
)
78+
79+
def test_capability_matrix_excludes_fractional_dca_profiles(self) -> None:
80+
catalog = StrategyCatalog(
81+
definitions={
82+
"ibit_smart_dca": StrategyDefinition(
83+
profile="ibit_smart_dca",
84+
domain=US_EQUITY_DOMAIN,
85+
supported_platforms=frozenset({"schwab"}),
86+
required_inputs=frozenset({"portfolio_snapshot"}),
87+
target_mode="value",
88+
compatible_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}),
89+
),
90+
}
91+
)
92+
matrix = PlatformCapabilityMatrix(
93+
platform_id="schwab",
94+
supported_domains=frozenset({US_EQUITY_DOMAIN}),
95+
supported_target_modes=frozenset({"value"}),
96+
supported_inputs=frozenset({"portfolio_snapshot"}),
97+
supported_capabilities=frozenset(),
98+
)
99+
adapters = {
100+
"ibit_smart_dca": StrategyRuntimeAdapter(
101+
available_inputs=frozenset({"portfolio_snapshot"}),
102+
available_capabilities=frozenset({FRACTIONAL_SHARE_EXECUTION_CAPABILITY}),
103+
),
104+
}
105+
106+
eligible = derive_eligible_profiles_for_platform(
107+
catalog,
108+
capability_matrix=matrix,
109+
runtime_adapter_loader=lambda profile: adapters[profile],
110+
)
111+
112+
self.assertEqual(eligible, frozenset())
113+
114+
115+
if __name__ == "__main__":
116+
unittest.main()

0 commit comments

Comments
 (0)