Skip to content

Commit d0bd996

Browse files
authored
Apply small-account whole-share compatibility (#71)
1 parent fceee54 commit d0bd996

2 files changed

Lines changed: 113 additions & 0 deletions

File tree

application/execution_service.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,42 @@ def should_sell_cash_sweep_to_fund_whole_share_buy(
4040
if current_buying_power + sweep_capacity >= quote_price:
4141
return True
4242
return False
43+
try:
44+
from quant_platform_kit.common.small_account_compatibility import (
45+
project_unbuyable_value_targets_to_cash,
46+
)
47+
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
48+
def project_unbuyable_value_targets_to_cash(
49+
target_values,
50+
prices,
51+
*,
52+
symbols=None,
53+
quantity_step=1.0,
54+
):
55+
adjusted = {
56+
str(symbol or "").strip().upper(): float(value or 0.0)
57+
for symbol, value in dict(target_values or {}).items()
58+
}
59+
step = max(0.0, float(quantity_step or 0.0))
60+
if step <= 0.0:
61+
return adjusted, ()
62+
candidate_symbols = (
63+
tuple(adjusted)
64+
if symbols is None
65+
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in symbols))
66+
)
67+
normalized_prices = {
68+
str(symbol or "").strip().upper(): float(price or 0.0)
69+
for symbol, price in dict(prices or {}).items()
70+
}
71+
substituted = []
72+
for symbol in candidate_symbols:
73+
target_value = max(0.0, float(adjusted.get(symbol, 0.0) or 0.0))
74+
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
75+
if price > 0.0 and 0.0 < target_value < (price * step):
76+
adjusted[symbol] = 0.0
77+
substituted.append(symbol)
78+
return adjusted, tuple(dict.fromkeys(substituted))
4379
from quant_platform_kit.common.quantity import (
4480
floor_to_quantity_step,
4581
format_quantity,
@@ -613,6 +649,7 @@ def execute_rebalance(
613649
max(0.0, float(safe_haven_cash_substitute_threshold_usd or 0.0))
614650
),
615651
"safe_haven_cash_substituted_symbols": [],
652+
"small_account_whole_share_substituted_symbols": [],
616653
"residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0),
617654
"current_stock_weight": 0.0,
618655
"current_safe_haven_weight": 0.0,
@@ -684,6 +721,24 @@ def execute_rebalance(
684721
current_mv[symbol] = qty * price
685722

686723
target_mv = {symbol: investable * weight for symbol, weight in target_weights.items()}
724+
small_account_candidate_symbols = tuple(
725+
dict.fromkeys(tuple(allocation["risk_symbols"]) + tuple(allocation["income_symbols"]))
726+
)
727+
if not small_account_candidate_symbols:
728+
small_account_candidate_symbols = tuple(
729+
symbol for symbol in target_mv if symbol not in safe_haven_symbols
730+
)
731+
target_mv, small_account_substituted_symbols = project_unbuyable_value_targets_to_cash(
732+
target_mv,
733+
prices,
734+
symbols=small_account_candidate_symbols,
735+
quantity_step=order_quantity_step,
736+
)
737+
for symbol in small_account_substituted_symbols:
738+
target_weights[symbol] = 0.0
739+
execution_summary["small_account_whole_share_substituted_symbols"] = list(
740+
small_account_substituted_symbols
741+
)
687742
trade_logs = []
688743
target_hash = _build_target_hash(target_weights)
689744
execution_summary["target_vs_current"] = _build_target_diff_rows(target_weights, current_mv, equity)

tests/test_execution_service.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,64 @@ def fake_fetch_quote_snapshots(_ib, symbols):
130130
assert any(log.startswith("buy VOO") for log in trade_logs)
131131

132132

133+
def test_execute_rebalance_projects_unbuyable_weight_target_to_zero(tmp_path, monkeypatch):
134+
class FakeIB:
135+
def openTrades(self):
136+
return []
137+
138+
def fills(self):
139+
return []
140+
141+
def accountValues(self):
142+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="236.81")]
143+
144+
prices = {"SOXL": 191.15, "SOXX": 536.88, "BOXX": 100.0}
145+
monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)
146+
147+
_trade_logs, summary = execute_rebalance(
148+
FakeIB(),
149+
{},
150+
{"SOXX": {"quantity": 1}},
151+
{"equity": 773.69, "buying_power": 236.81},
152+
fetch_quote_snapshots=lambda _ib, symbols: {
153+
symbol: SimpleNamespace(last_price=prices[symbol]) for symbol in symbols
154+
},
155+
submit_order_intent=lambda *_args, **_kwargs: SimpleNamespace(
156+
broker_order_id="dry-run",
157+
status="Submitted",
158+
),
159+
order_intent_cls=OrderIntent,
160+
translator=translate,
161+
strategy_symbols=["SOXL", "SOXX", "BOXX"],
162+
strategy_profile="soxl_soxx_trend_income",
163+
signal_metadata=_signal_metadata(
164+
{"SOXL": 0.70, "SOXX": 0.20, "BOXX": 0.10},
165+
risk_symbols=("SOXL", "SOXX"),
166+
safe_haven_symbols=("BOXX",),
167+
trade_date="2026-05-22",
168+
),
169+
dry_run_only=True,
170+
cash_reserve_ratio=0.03,
171+
rebalance_threshold_ratio=0.01,
172+
limit_buy_premium=1.0,
173+
quantity_step=1.0,
174+
sell_settle_delay_sec=0,
175+
execution_lock_dir=tmp_path,
176+
return_summary=True,
177+
)
178+
179+
assert summary["small_account_whole_share_substituted_symbols"] == ["SOXX"]
180+
assert summary["orders_submitted"][0] == {
181+
"symbol": "SOXX",
182+
"side": "sell",
183+
"quantity": 1,
184+
"status": "dry_run",
185+
}
186+
assert summary["orders_submitted"][1]["symbol"] == "SOXL"
187+
assert summary["orders_submitted"][1]["side"] == "buy"
188+
assert summary["orders_submitted"][1]["quantity"] == 2
189+
190+
133191
def test_execute_rebalance_routes_order_to_single_account_id(monkeypatch, tmp_path):
134192
class FakeIB:
135193
def openTrades(self):

0 commit comments

Comments
 (0)