diff --git a/application/execution_service.py b/application/execution_service.py index e3fb791..2dd1477 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -53,6 +53,7 @@ class ExecutionCycleResult: DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 +SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0 def _floor_quantity(quantity: float) -> int: @@ -90,6 +91,16 @@ def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str, return tuple(dict.fromkeys(symbols)) +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 substitute_small_safe_haven_targets_with_cash( plan: dict[str, Any], *, @@ -134,17 +145,22 @@ def _apply_small_account_whole_share_compatibility( ) -> dict[str, Any]: adjusted_plan = dict(plan or {}) allocation = dict(adjusted_plan.get("allocation") or {}) + portfolio = dict(adjusted_plan.get("portfolio") or {}) targets = dict(allocation.get("targets") or {}) candidate_symbols = tuple( dict.fromkeys( - tuple(allocation.get("risk_symbols", ())) + str(symbol or "").strip().upper() + for symbol in tuple(allocation.get("risk_symbols", ())) + tuple(allocation.get("income_symbols", ())) + if str(symbol or "").strip() ) ) if not candidate_symbols: - safe_haven_symbols = set(allocation.get("safe_haven_symbols", ())) + safe_haven_symbols = set(_safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation)) candidate_symbols = tuple( - symbol for symbol in targets if symbol not in safe_haven_symbols + str(symbol or "").strip().upper() + for symbol in targets + if str(symbol or "").strip().upper() not in safe_haven_symbols ) prices = {} for symbol in candidate_symbols: @@ -157,9 +173,27 @@ def _apply_small_account_whole_share_compatibility( symbols=candidate_symbols, quantity_step=1.0, ) + safe_haven_symbols = _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation) + remaining_non_safe_targets = [ + symbol + for symbol in candidate_symbols + if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0 + ] + safe_haven_substituted: list[str] = [] + if ( + substituted + and not remaining_non_safe_targets + and _positive_target_total(adjusted_targets) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD + ): + for symbol in safe_haven_symbols: + if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0: + adjusted_targets[symbol] = 0.0 + safe_haven_substituted.append(symbol) allocation["targets"] = adjusted_targets if substituted: allocation["small_account_whole_share_substituted_symbols"] = substituted + if safe_haven_substituted: + allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted) adjusted_plan["allocation"] = allocation return adjusted_plan diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 6d8387e..e9dd45b 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -238,3 +238,92 @@ def test_execute_value_target_plan_projects_unbuyable_value_target_to_zero(): ("sell", "SOXX", 1.0), ("buy", "SOXL", 1.0), ] + + +def test_execute_value_target_plan_keeps_safe_haven_cash_when_only_risk_target_is_unbuyable(): + execution_port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": { + "strategy_symbols": ("SOXL", "SOXX", "BOXX"), + "risk_symbols": ("SOXL", "SOXX"), + "safe_haven_symbols": ("BOXX",), + "targets": {"SOXL": 0.0, "SOXX": 194.10, "BOXX": 1099.90}, + }, + "portfolio": { + "market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + "sellable_quantities": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + "liquid_cash": 1294.00, + "cash_sweep_symbol": "BOXX", + }, + "execution": {"current_min_trade": 100.0, "investable_cash": 1255.18}, + }, + market_data_port=FakeMarketDataPort({"SOXL": 175.0, "SOXX": 525.0, "BOXX": 116.83}), + execution_port=execution_port, + dry_run_only=True, + max_order_notional_usd=2000.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + assert result.action_done is False + assert execution_port.orders == [] + + +def test_execute_value_target_plan_uses_cash_sweep_symbol_for_small_safe_haven_cash(): + execution_port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": { + "strategy_symbols": ("SOXX", "BOXX"), + "risk_symbols": ("SOXX",), + "targets": {"SOXX": 194.10, "BOXX": 1099.90}, + }, + "portfolio": { + "market_values": {"SOXX": 0.0, "BOXX": 0.0}, + "sellable_quantities": {"SOXX": 0.0, "BOXX": 0.0}, + "liquid_cash": 1294.00, + "cash_sweep_symbol": "BOXX", + }, + "execution": {"current_min_trade": 100.0, "investable_cash": 1255.18}, + }, + market_data_port=FakeMarketDataPort({"SOXX": 525.0, "BOXX": 116.83}), + execution_port=execution_port, + dry_run_only=True, + max_order_notional_usd=2000.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + assert result.action_done is False + assert execution_port.orders == [] + + +def test_execute_value_target_plan_keeps_safe_haven_when_mixed_case_risk_target_remains_buyable(): + execution_port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": { + "strategy_symbols": ("SOXL", "SOXX", "BOXX"), + "risk_symbols": ("soxl", "soxx"), + "safe_haven_symbols": ("BOXX",), + "targets": {"SOXL": 500.0, "SOXX": 194.10, "BOXX": 1000.0}, + }, + "portfolio": { + "market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + "sellable_quantities": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + "liquid_cash": 2000.0, + "cash_sweep_symbol": "BOXX", + }, + "execution": {"current_min_trade": 100.0, "investable_cash": 2000.0}, + }, + market_data_port=FakeMarketDataPort({"SOXL": 100.0, "SOXX": 525.0, "BOXX": 100.0}), + execution_port=execution_port, + dry_run_only=True, + max_order_notional_usd=2000.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + assert result.action_done is True + assert [(order.side, order.symbol, order.quantity) for order in execution_port.orders] == [ + ("buy", "BOXX", 10.0), + ("buy", "SOXL", 5.0), + ]