diff --git a/application/execution_service.py b/application/execution_service.py index 0aaa148..5d1c59e 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -547,6 +547,17 @@ def _sell_order_quantity( DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 +SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0 + + +def _positive_target_total(targets: dict[str, Any]) -> float: + total = 0.0 + for value in dict(targets or {}).values(): + try: + total += max(0.0, float(value or 0.0)) + except (TypeError, ValueError): + continue + return total def _apply_safe_haven_cash_substitution_to_weights( @@ -651,6 +662,7 @@ def execute_rebalance( ), "safe_haven_cash_substituted_symbols": [], "small_account_whole_share_substituted_symbols": [], + "small_account_safe_haven_cash_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, @@ -726,11 +738,17 @@ def execute_rebalance( 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"])) + dict.fromkeys( + str(symbol or "").strip().upper() + for symbol in tuple(allocation["risk_symbols"]) + tuple(allocation["income_symbols"]) + if str(symbol or "").strip() + ) ) if not small_account_candidate_symbols: small_account_candidate_symbols = tuple( - symbol for symbol in target_mv if symbol not in safe_haven_symbols + str(symbol or "").strip().upper() + for symbol in target_mv + if str(symbol or "").strip().upper() not in safe_haven_symbols ) target_mv, small_account_substituted_symbols = project_unbuyable_value_targets_to_cash( target_mv, @@ -740,9 +758,36 @@ def execute_rebalance( ) for symbol in small_account_substituted_symbols: target_weights[symbol] = 0.0 + remaining_non_safe_targets = [ + symbol + for symbol in small_account_candidate_symbols + if float(target_mv.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0 + ] + small_account_safe_haven_cash_substituted_symbols: list[str] = [] + if ( + small_account_substituted_symbols + and not remaining_non_safe_targets + and _positive_target_total(target_mv) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD + ): + for symbol in safe_haven_symbols: + normalized_symbol = str(symbol or "").strip().upper() + if float(target_mv.get(normalized_symbol, 0.0) or 0.0) > 0.0: + target_mv[normalized_symbol] = 0.0 + target_weights[normalized_symbol] = 0.0 + small_account_safe_haven_cash_substituted_symbols.append(normalized_symbol) + if safe_haven_symbols: + execution_summary["realized_safe_haven_weight"] = float( + sum( + float(target_weights.get(str(symbol or "").strip().upper(), 0.0) or 0.0) + for symbol in safe_haven_symbols + ) + ) execution_summary["small_account_whole_share_substituted_symbols"] = list( small_account_substituted_symbols ) + execution_summary["small_account_safe_haven_cash_substituted_symbols"] = ( + small_account_safe_haven_cash_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/requirements.txt b/requirements.txt index 59ff999..4c9d340 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@6f71766356beb0b1517a539dcacfe1c6d2719ef8 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@2918e1485561a50ad14abcd0bb05a09bd755b8c4 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@190edb21c5fe0c8e0efba66bfd8bacfdd00b3f77 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ead0c075f5b47ec1d05d3a45cfcdda58217fe086 pandas numpy requests diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 7adfd76..b698daa 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -223,6 +223,61 @@ def accountValues(self): assert summary["orders_submitted"][1]["quantity"] == 2 +def test_execute_rebalance_keeps_safe_haven_cash_when_only_risk_target_is_unbuyable(tmp_path, monkeypatch): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="1294.00")] + + prices = {"SOXL": 175.0, "SOXX": 525.0, "BOXX": 116.83} + submitted = [] + monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {}, + {}, + {"equity": 1294.0, "buying_power": 1294.0}, + fetch_quote_snapshots=lambda _ib, symbols: { + symbol: SimpleNamespace(last_price=prices[symbol]) for symbol in symbols + }, + submit_order_intent=lambda _ib, intent: submitted.append(intent) or 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.0, "SOXX": 0.15, "BOXX": 0.85}, + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + trade_date="2026-05-26", + ), + 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 submitted == [] + assert summary["small_account_whole_share_substituted_symbols"] == ["SOXX"] + assert summary["small_account_safe_haven_cash_substituted_symbols"] == ["BOXX"] + assert summary["realized_safe_haven_weight"] == 0.0 + boxx_row = next(row for row in summary["target_vs_current"] if row["symbol"] == "BOXX") + assert boxx_row["target_weight"] == 0.0 + + def test_execute_rebalance_routes_order_to_single_account_id(monkeypatch, tmp_path): class FakeIB: def openTrades(self):