diff --git a/application/execution_service.py b/application/execution_service.py index b3da915..fc05021 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -1225,6 +1225,76 @@ def _should_bootstrap_whole_share_buy(symbol, *, target_value, limit_price) -> b return max(0.0, float(target_value or 0.0)) >= effective_limit_price * float(min_target_share_ratio) +def _should_top_up_existing_whole_share_buy(symbol, *, target_gap_value, limit_price, quantity=0.0) -> bool: + normalized_symbol = str(symbol or "").strip().upper() + if normalized_symbol not in SMALL_ACCOUNT_WHOLE_SHARE_BOOTSTRAP_MIN_TARGET_SHARE_RATIO_BY_SYMBOL: + return False + if max(0.0, float(quantity or 0.0)) <= 0.0: + return False + effective_limit_price = max(0.0, float(limit_price or 0.0)) + if effective_limit_price <= 0.0: + return False + return max(0.0, float(target_gap_value or 0.0)) >= ( + effective_limit_price * float(_SMALL_ACCOUNT_RETENTION_MIN_TARGET_SHARE_RATIO_DEFAULT) + ) + + +def _planned_buy_order_quantity( + symbol, + *, + buy_value, + limit_price, + quantity_step, + investable_buying_power, + held_quantity=0.0, +) -> tuple[float, bool]: + effective_limit_price = max(0.0, float(limit_price or 0.0)) + qty = ( + _floor_order_quantity(float(buy_value or 0.0) / effective_limit_price, quantity_step=quantity_step) + if effective_limit_price > 0.0 + else 0.0 + ) + forced_whole_share = False + if ( + qty <= 0 + and effective_limit_price > 0.0 + and float(investable_buying_power or 0.0) >= effective_limit_price + and ( + _should_top_up_existing_whole_share_buy( + symbol, + target_gap_value=buy_value, + limit_price=effective_limit_price, + quantity=held_quantity, + ) + or _should_bootstrap_whole_share_buy( + symbol, + target_value=buy_value, + limit_price=effective_limit_price, + ) + ) + ): + qty = _floor_order_quantity(1.0, quantity_step=quantity_step) + forced_whole_share = qty > 0 + return qty, forced_whole_share + + +def _projected_sell_release_value_for_report(report, *, fallback_price=0.0, fallback_quantity=0.0) -> float: + status = str(getattr(report, "status", "") or "") + if status not in {"Filled", "PartiallyFilled", "Partial"}: + return 0.0 + filled_quantity = float(getattr(report, "filled_quantity", 0.0) or 0.0) + if status == "Filled" and filled_quantity <= 0.0: + filled_quantity = float(getattr(report, "quantity", 0.0) or fallback_quantity or 0.0) + if filled_quantity <= 0.0: + return 0.0 + fill_price = float(getattr(report, "average_fill_price", 0.0) or 0.0) + if fill_price <= 0.0: + fill_price = max(0.0, float(fallback_price or 0.0)) + if fill_price <= 0.0: + return 0.0 + return filled_quantity * fill_price + + def _format_symbol_with_suffix(symbol, *, suffix=".US") -> str: normalized = str(symbol or "").strip().upper() if not normalized: @@ -1370,6 +1440,7 @@ def record_quote_snapshot(symbol, snapshot) -> None: "small_account_whole_share_cash_notes": [], "small_account_allocation_drift_notes": [], "residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0), + "projected_sell_release_value": 0.0, "current_stock_weight": 0.0, "current_safe_haven_weight": 0.0, "price_source_mode": "market_quote", @@ -1778,10 +1849,13 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t min_notional_symbols.append(symbol) continue limit_price = _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol) - qty = ( - _floor_order_quantity(buy_value / limit_price, quantity_step=order_quantity_step) - if limit_price > 0 - else 0 + qty, _forced_whole_share = _planned_buy_order_quantity( + symbol, + buy_value=buy_value, + limit_price=limit_price, + quantity_step=order_quantity_step, + investable_buying_power=investable_anticipated_buying_power, + held_quantity=max(0.0, float(positions.get(symbol, {}).get("quantity", 0.0) or 0.0)), ) if qty > 0: has_buy_plan = True @@ -1900,6 +1974,7 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t execution_summary["execution_status"] = "executing" sell_executed = False + projected_sell_release_value = 0.0 pending_sell_release_symbols: list[str] = [] for symbol in all_symbols: current = current_mv.get(symbol, 0) @@ -1979,6 +2054,11 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t trade_logs.append(translator("market_sell", symbol=symbol, qty=format_quantity(qty)) + f" {status_msg}") if ok: sell_executed = True + projected_sell_release_value += _projected_sell_release_value_for_report( + report, + fallback_price=price, + fallback_quantity=qty, + ) if dry_run_only: buying_power = max(0.0, anticipated_buying_power + dry_run_sale_proceeds) @@ -1992,9 +2072,15 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t currency=market_currency, cash_only_execution=cash_only_execution, ) + if cash_only_execution and projected_sell_release_value > 0.0: + buying_power = max( + float(buying_power or 0.0), + float(anticipated_buying_power or 0.0) + float(projected_sell_release_value), + ) else: buying_power = anticipated_buying_power investable_buying_power = _investable_buying_power(buying_power, reserved) + execution_summary["projected_sell_release_value"] = float(projected_sell_release_value) pending_sell_release_symbols = list(dict.fromkeys(pending_sell_release_symbols)) buy_needed_symbols = [ symbol @@ -2064,10 +2150,26 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t continue limit_price = _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol) - qty = _floor_order_quantity( - buy_value / limit_price, + held_quantity = max(0.0, float(positions.get(symbol, {}).get("quantity", 0.0) or 0.0)) + qty, forced_whole_share = _planned_buy_order_quantity( + symbol, + buy_value=buy_value, + limit_price=limit_price, quantity_step=order_quantity_step, + investable_buying_power=investable_buying_power, + held_quantity=held_quantity, ) + if ( + forced_whole_share + and symbol not in execution_summary["small_account_whole_share_bootstrap_symbols"] + ): + execution_summary["small_account_whole_share_bootstrap_symbols"].append(symbol) + trade_logs.extend( + _format_small_account_whole_share_bootstrap_notes( + (symbol,), + translator=translator, + ) + ) if qty <= 0: execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "quantity_zero"}) continue diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 3a943fa..acbcef2 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -470,6 +470,193 @@ def accountValues(self): assert buy_notional <= max_investable + 1.0 +def test_execute_rebalance_live_cash_only_reuses_projected_sell_proceeds_when_cash_snapshot_is_stale( + tmp_path, monkeypatch +): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="CashBalance", currency="USD", value="112.38")] + + prices = {"SOXL": 192.50, "SOXX": 582.25} + submitted = [] + monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) + + def fake_submit_order_intent(_ib, intent): + submitted.append(intent) + if intent.side == "sell": + return SimpleNamespace( + broker_order_id=f"order-{len(submitted)}", + symbol=intent.symbol, + side=intent.side, + status="Filled", + quantity=intent.quantity, + filled_quantity=intent.quantity, + average_fill_price=prices[intent.symbol], + ) + return SimpleNamespace(broker_order_id=f"order-{len(submitted)}", status="Submitted") + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {}, + { + "SOXL": {"quantity": 3}, + "SOXX": {"quantity": 2}, + }, + {"equity": 1920.36, "buying_power": 112.38}, + fetch_quote_snapshots=lambda _ib, symbols: { + symbol: SimpleNamespace(last_price=prices[symbol]) for symbol in symbols + }, + submit_order_intent=fake_submit_order_intent, + order_intent_cls=OrderIntent, + translator=build_translator("zh"), + strategy_symbols=["SOXL", "SOXX"], + strategy_profile="soxl_soxx_trend_income", + signal_metadata=_signal_metadata( + {"SOXL": 0.0, "SOXX": 0.90}, + risk_symbols=("SOXL", "SOXX"), + trade_date="2026-07-09", + ), + dry_run_only=False, + cash_reserve_ratio=0.03, + rebalance_threshold_ratio=0.01, + limit_buy_premium=1.005, + quantity_step=1.0, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert [(intent.symbol, intent.side, intent.quantity) for intent in submitted] == [ + ("SOXL", "sell", 3), + ("SOXX", "buy", 1), + ] + assert summary["orders_filled"][0]["symbol"] == "SOXL" + assert summary["orders_submitted"][0]["symbol"] == "SOXX" + assert summary["projected_sell_release_value"] == 577.5 + assert summary["orders_skipped"] == [] + + +def test_execute_rebalance_live_cash_only_does_not_bridge_unfilled_sell_submissions(tmp_path, monkeypatch): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="CashBalance", currency="USD", value="112.38")] + + prices = {"SOXL": 192.50, "SOXX": 582.25} + submitted = [] + monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) + + def fake_submit_order_intent(_ib, intent): + submitted.append(intent) + return SimpleNamespace(broker_order_id=f"order-{len(submitted)}", status="Submitted") + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {}, + {"SOXL": {"quantity": 3}, "SOXX": {"quantity": 0}}, + {"equity": 700.0, "buying_power": 112.38}, + fetch_quote_snapshots=lambda _ib, symbols: { + symbol: SimpleNamespace(last_price=prices[symbol]) for symbol in symbols + }, + submit_order_intent=fake_submit_order_intent, + order_intent_cls=OrderIntent, + translator=build_translator("zh"), + strategy_symbols=["SOXL", "SOXX"], + strategy_profile="soxl_soxx_trend_income", + signal_metadata=_signal_metadata( + {"SOXL": 0.0, "SOXX": 0.90}, + risk_symbols=("SOXL", "SOXX"), + trade_date="2026-07-09", + ), + dry_run_only=False, + cash_reserve_ratio=0.03, + rebalance_threshold_ratio=0.01, + limit_buy_premium=1.005, + quantity_step=1.0, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert [(intent.symbol, intent.side, intent.quantity) for intent in submitted] == [("SOXL", "sell", 3)] + assert summary["projected_sell_release_value"] == 0.0 + assert {"symbol": "SOXX", "side": "buy", "reason": "quantity_zero"} in summary["orders_skipped"] + + +def test_execute_rebalance_live_cash_only_uses_partial_fill_proceeds_and_fill_price(tmp_path, monkeypatch): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="CashBalance", currency="USD", value="100.00")] + + prices = {"SOXL": 200.0, "SOXX": 300.0} + submitted = [] + monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) + + def fake_submit_order_intent(_ib, intent): + submitted.append(intent) + if intent.side == "sell": + return SimpleNamespace( + broker_order_id="order-1", + symbol=intent.symbol, + side=intent.side, + status="PartiallyFilled", + quantity=intent.quantity, + filled_quantity=1, + average_fill_price=190.0, + ) + return SimpleNamespace(broker_order_id=f"order-{len(submitted)}", status="Submitted") + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {}, + {"SOXL": {"quantity": 3}, "SOXX": {"quantity": 0}}, + {"equity": 700.0, "buying_power": 100.0}, + fetch_quote_snapshots=lambda _ib, symbols: { + symbol: SimpleNamespace(last_price=prices[symbol]) for symbol in symbols + }, + submit_order_intent=fake_submit_order_intent, + order_intent_cls=OrderIntent, + translator=build_translator("zh"), + strategy_symbols=["SOXL", "SOXX"], + strategy_profile="soxl_soxx_trend_income", + signal_metadata=_signal_metadata( + {"SOXL": 0.0, "SOXX": 0.90}, + risk_symbols=("SOXL", "SOXX"), + trade_date="2026-07-09", + ), + dry_run_only=False, + cash_reserve_ratio=0.0, + rebalance_threshold_ratio=0.01, + limit_buy_premium=1.005, + quantity_step=1.0, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert [(intent.symbol, intent.side, intent.quantity) for intent in submitted] == [("SOXL", "sell", 3)] + assert summary["projected_sell_release_value"] == 190.0 + assert summary["orders_partially_filled"][0]["symbol"] == "SOXL" + assert {"symbol": "SOXX", "side": "buy", "reason": "quantity_zero"} in summary["orders_skipped"] + + def test_execute_rebalance_projects_unbuyable_weight_target_to_zero(tmp_path, monkeypatch): class FakeIB: def openTrades(self): @@ -967,6 +1154,62 @@ def accountValues(self): ] +def test_execute_rebalance_buy_only_top_up_existing_whole_share_is_not_filtered_as_no_op(monkeypatch, tmp_path): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="700.00")] + + prices = {"SOXX": 605.0} + monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {}, + {"SOXX": {"quantity": 1}}, + {"equity": 1305.0, "buying_power": 700.0}, + 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=["SOXX"], + strategy_profile="soxl_soxx_trend_income", + signal_metadata=_signal_metadata( + {"SOXX": 0.883}, + risk_symbols=("SOXX",), + trade_date="2026-07-09", + ), + dry_run_only=True, + cash_reserve_ratio=0.0, + rebalance_threshold_ratio=0.01, + limit_buy_premium=1.005, + quantity_step=1.0, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert summary["execution_status"] == "executed" + assert summary["small_account_whole_share_bootstrap_symbols"] == ["SOXX"] + assert { + "symbol": "SOXX", + "side": "buy", + "quantity": 1, + "limit_price": 608.02, + "status": "dry_run", + } in summary["orders_submitted"] + + def test_execute_rebalance_skips_when_pending_orders_exist(): class FakeIB: def openTrades(self):