From 183434e2afc7927296cc307f2131ac363917df9d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 23 May 2026 10:34:50 +0800 Subject: [PATCH] Apply small-account whole-share compatibility --- application/execution_service.py | 55 ++++++++++++++++++++++++++++++ tests/test_execution_service.py | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/application/execution_service.py b/application/execution_service.py index b957821..721a07a 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -40,6 +40,42 @@ def should_sell_cash_sweep_to_fund_whole_share_buy( if current_buying_power + sweep_capacity >= quote_price: return True return False +try: + from quant_platform_kit.common.small_account_compatibility import ( + project_unbuyable_value_targets_to_cash, + ) +except ImportError: # pragma: no cover - compatibility with older pinned shared wheels + def project_unbuyable_value_targets_to_cash( + target_values, + prices, + *, + symbols=None, + quantity_step=1.0, + ): + adjusted = { + str(symbol or "").strip().upper(): float(value or 0.0) + for symbol, value in dict(target_values or {}).items() + } + step = max(0.0, float(quantity_step or 0.0)) + if step <= 0.0: + return adjusted, () + candidate_symbols = ( + tuple(adjusted) + if symbols is None + else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in symbols)) + ) + normalized_prices = { + str(symbol or "").strip().upper(): float(price or 0.0) + for symbol, price in dict(prices or {}).items() + } + substituted = [] + for symbol in candidate_symbols: + target_value = max(0.0, float(adjusted.get(symbol, 0.0) or 0.0)) + price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0)) + if price > 0.0 and 0.0 < target_value < (price * step): + adjusted[symbol] = 0.0 + substituted.append(symbol) + return adjusted, tuple(dict.fromkeys(substituted)) from quant_platform_kit.common.quantity import ( floor_to_quantity_step, format_quantity, @@ -613,6 +649,7 @@ def execute_rebalance( max(0.0, float(safe_haven_cash_substitute_threshold_usd or 0.0)) ), "safe_haven_cash_substituted_symbols": [], + "small_account_whole_share_substituted_symbols": [], "residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0), "current_stock_weight": 0.0, "current_safe_haven_weight": 0.0, @@ -684,6 +721,24 @@ def execute_rebalance( current_mv[symbol] = qty * price target_mv = {symbol: investable * weight for symbol, weight in target_weights.items()} + small_account_candidate_symbols = tuple( + dict.fromkeys(tuple(allocation["risk_symbols"]) + tuple(allocation["income_symbols"])) + ) + if not small_account_candidate_symbols: + small_account_candidate_symbols = tuple( + symbol for symbol in target_mv if symbol not in safe_haven_symbols + ) + target_mv, small_account_substituted_symbols = project_unbuyable_value_targets_to_cash( + target_mv, + prices, + symbols=small_account_candidate_symbols, + quantity_step=order_quantity_step, + ) + for symbol in small_account_substituted_symbols: + target_weights[symbol] = 0.0 + execution_summary["small_account_whole_share_substituted_symbols"] = list( + small_account_substituted_symbols + ) trade_logs = [] target_hash = _build_target_hash(target_weights) execution_summary["target_vs_current"] = _build_target_diff_rows(target_weights, current_mv, equity) diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 2019fd4..66600e3 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -130,6 +130,64 @@ def fake_fetch_quote_snapshots(_ib, symbols): assert any(log.startswith("buy VOO") for log in trade_logs) +def test_execute_rebalance_projects_unbuyable_weight_target_to_zero(tmp_path, monkeypatch): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="236.81")] + + prices = {"SOXL": 191.15, "SOXX": 536.88, "BOXX": 100.0} + monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {}, + {"SOXX": {"quantity": 1}}, + {"equity": 773.69, "buying_power": 236.81}, + fetch_quote_snapshots=lambda _ib, symbols: { + symbol: SimpleNamespace(last_price=prices[symbol]) for symbol in symbols + }, + submit_order_intent=lambda *_args, **_kwargs: SimpleNamespace( + broker_order_id="dry-run", + status="Submitted", + ), + order_intent_cls=OrderIntent, + translator=translate, + strategy_symbols=["SOXL", "SOXX", "BOXX"], + strategy_profile="soxl_soxx_trend_income", + signal_metadata=_signal_metadata( + {"SOXL": 0.70, "SOXX": 0.20, "BOXX": 0.10}, + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + trade_date="2026-05-22", + ), + dry_run_only=True, + cash_reserve_ratio=0.03, + rebalance_threshold_ratio=0.01, + limit_buy_premium=1.0, + quantity_step=1.0, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert summary["small_account_whole_share_substituted_symbols"] == ["SOXX"] + assert summary["orders_submitted"][0] == { + "symbol": "SOXX", + "side": "sell", + "quantity": 1, + "status": "dry_run", + } + assert summary["orders_submitted"][1]["symbol"] == "SOXL" + assert summary["orders_submitted"][1]["side"] == "buy" + assert summary["orders_submitted"][1]["quantity"] == 2 + + def test_execute_rebalance_routes_order_to_single_account_id(monkeypatch, tmp_path): class FakeIB: def openTrades(self):