diff --git a/application/ibkr_portfolio.py b/application/ibkr_portfolio.py index 2ff48b1..09dfbb6 100644 --- a/application/ibkr_portfolio.py +++ b/application/ibkr_portfolio.py @@ -285,6 +285,20 @@ def fetch_portfolio_snapshot( if verified_nlv is not None and source_digest is not None: metadata["broker_net_liquidation"] = float(verified_nlv) metadata["source_digest_sha256"] = source_digest + # Cash-only SOXL sizes value targets from positions+cash, while RRL + # divides by capital_base NLV. When sleeve marks exceed NetLiquidation, + # in-cap weights inflate and fail closed. Shrink the cash sleeve so + # strategy equity matches the verified USD NLV used by the gate. + if cash_only_execution and market_currency == "USD": + position_mv_sum = sum(float(position.market_value) for position in positions) + strategy_equity = float(total_equity) + nlv = float(verified_nlv) + if strategy_equity > nlv + 1e-6: + metadata["strategy_equity_before_nlv_align"] = strategy_equity + aligned_cash = nlv - position_mv_sum + metadata["market_currency_cash"] = aligned_cash + total_equity = nlv + buying_power = aligned_cash return PortfolioSnapshot( as_of=datetime.now(timezone.utc), diff --git a/strategy_runtime.py b/strategy_runtime.py index f7e1f4b..a6ba47c 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -1324,6 +1324,31 @@ def _evaluate_value_target_strategy( ), ) decision = self.entrypoint.evaluate(ctx) + capability_status = self._capability_status_metadata(portfolio_snapshot) + if any(str(flag).startswith("rejected:runtime_risk") for flag in decision.risk_flags): + diagnostics = decision.diagnostics if isinstance(decision.diagnostics, Mapping) else {} + snapshot_metadata = getattr(portfolio_snapshot, "metadata", None) + broker_nlv = ( + snapshot_metadata.get("broker_net_liquidation") + if isinstance(snapshot_metadata, Mapping) + else None + ) + target_value_sum = sum( + float(position.target_value) + for position in decision.positions + if position.target_value is not None + ) + self.logger( + "strategy_runtime_risk_reject | " + f"profile={self.profile} " + f"capital_base_status={capability_status.get('capital_base_status')} " + f"runtime_risk_status={capability_status.get('runtime_risk_status')} " + f"risk_flags={','.join(str(flag) for flag in decision.risk_flags)} " + f"hold={diagnostics.get('runtime_risk_small_account_hold')} " + f"broker_nlv={broker_nlv} " + f"portfolio_total_equity={getattr(portfolio_snapshot, 'total_equity', None)} " + f"target_value_sum={target_value_sum}" + ) safe_haven_symbol = next( (position.symbol for position in decision.positions if position.role == "safe_haven"), None, @@ -1340,7 +1365,7 @@ def _evaluate_value_target_strategy( metadata = self._enrich_portfolio_metadata( { "strategy_profile": self.profile, - **self._capability_status_metadata(portfolio_snapshot), + **capability_status, "managed_symbols": managed_symbols, "status_icon": self.status_icon, "dry_run_only": self.runtime_settings.dry_run_only, diff --git a/tests/test_ibkr_portfolio.py b/tests/test_ibkr_portfolio.py index 11540d5..f994a57 100644 --- a/tests/test_ibkr_portfolio.py +++ b/tests/test_ibkr_portfolio.py @@ -179,11 +179,47 @@ def accountValues(self): assert snapshot.metadata["total_equity_source"] == "broker_net_liquidation" assert snapshot.metadata["broker_net_liquidation"] == 371.93 + assert snapshot.total_equity == 371.93 assert snapshot.metadata["account_hash"] == "U15998061" assert isinstance(snapshot.metadata["source_digest_sha256"], str) assert len(snapshot.metadata["source_digest_sha256"]) == 64 +def test_fetch_portfolio_snapshot_aligns_cash_only_equity_to_verified_usd_nlv(): + class DriftedMarksIB(FakeIB): + def positions(self): + return [ + SimpleNamespace( + account="U15998061", + contract=SimpleNamespace(secType="STK", symbol="SOXL", currency="USD"), + position=3, + avgCost=150.0, + ) + ] + + def accountValues(self): + return [ + SimpleNamespace(account="U15998061", currency="USD", tag="NetLiquidation", value="472.0"), + SimpleNamespace(account="U15998061", currency="USD", tag="CashBalance", value="40.0"), + SimpleNamespace(account="U15998061", currency="USD", tag="AvailableFunds", value="40.0"), + ] + + snapshot = fetch_portfolio_snapshot( + DriftedMarksIB(), + account_ids=("U15998061",), + wait_seconds=0, + currency="USD", + cash_only_execution=True, + ) + + # Position marks sum to 450 + cash 40 = 490 before align; gate uses NLV 472. + assert snapshot.metadata["broker_net_liquidation"] == 472.0 + assert snapshot.metadata["strategy_equity_before_nlv_align"] == 490.0 + assert snapshot.total_equity == 472.0 + assert snapshot.metadata["market_currency_cash"] == 22.0 + assert snapshot.buying_power == 22.0 + + def test_fetch_portfolio_snapshot_prefers_usd_net_liquidation_over_base(): class DualNetLiquidationIB(FakeIB): def positions(self):