diff --git a/application/account_payload_utils.py b/application/account_payload_utils.py index dcafd01..6bb99e8 100644 --- a/application/account_payload_utils.py +++ b/application/account_payload_utils.py @@ -9,10 +9,19 @@ def float_or_none(value: Any) -> float | None: if value in (None, ""): return None + text = str(value).strip() + if not text: + return None + negative_parentheses = text.startswith("(") and text.endswith(")") + if negative_parentheses: + text = text[1:-1].strip() + if text.startswith("$"): + text = text[1:].strip() try: - return float(str(value).replace(",", "")) + number = float(text.replace(",", "")) except (TypeError, ValueError): return None + return -number if negative_parentheses else number def flatten_values(payload: Any, prefix: str = "") -> dict[str, Any]: diff --git a/application/firstrade_client.py b/application/firstrade_client.py index df9ebed..d5847c8 100644 --- a/application/firstrade_client.py +++ b/application/firstrade_client.py @@ -428,7 +428,12 @@ def list_account_summaries(self) -> list[dict[str, Any]]: def get_balances(self, account: str) -> dict[str, Any]: _, account_data = self.require_connected() - return dict(account_data.get_account_balances(account)) + balances = dict(account_data.get_account_balances(account)) + account_balances = dict(getattr(account_data, "account_balances", {}) or {}) + account_list_total_value = account_balances.get(account) + if account_list_total_value is not None and "account_list_total_value" not in balances: + balances["account_list_total_value"] = account_list_total_value + return balances def get_positions(self, account: str) -> dict[str, Any]: _, account_data = self.require_connected() diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index fdfebd2..81e84cc 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -41,6 +41,26 @@ def _utcnow() -> datetime: _NEW_YORK_TZ = ZoneInfo("America/New_York") +_TOTAL_EQUITY_KEYWORD_GROUPS = ( + ("total", "value"), + ("total", "equity"), + ("account", "value"), + ("account", "equity"), + ("net", "liquid"), + ("liquidation",), + ("equity",), +) +_BUYING_POWER_KEYWORD_GROUPS = ( + ("buying", "power"), + ("buying",), + ("bp",), +) +_CASH_BALANCE_KEYWORD_GROUPS = ( + ("cash", "balance"), + ("available", "cash"), + ("cash", "available"), + ("cash",), +) def _market_date(value: datetime) -> date: @@ -48,6 +68,51 @@ def _market_date(value: datetime) -> date: return normalized.astimezone(_NEW_YORK_TZ).date() +def _first_numeric_by_keyword_groups(payload, keyword_groups: tuple[tuple[str, ...], ...]) -> float | None: + for keywords in keyword_groups: + value = first_numeric_by_keywords(payload, keywords) + if value is not None: + return value + return None + + +def _positive_or_none(value: float | None) -> float | None: + if value is None: + return None + resolved = float(value) + return resolved if resolved > 0.0 else None + + +def _resolve_total_equity( + *, + balances, + cash_balance: float | None, + buying_power: float | None, + position_market_value: float, +) -> tuple[float, str]: + balance_total = _positive_or_none( + _first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS) + ) + if balance_total is not None: + return balance_total, "balance_total" + + resolved_cash = _positive_or_none(cash_balance) + if resolved_cash is not None: + combined_value = resolved_cash + max(0.0, float(position_market_value)) + if combined_value > 0.0: + return combined_value, "cash_plus_positions" + + positive_position_value = _positive_or_none(position_market_value) + if positive_position_value is not None: + return positive_position_value, "positions" + + positive_buying_power = _positive_or_none(buying_power) + if positive_buying_power is not None: + return positive_buying_power, "buying_power_fallback" + + return 0.0, "unresolved" + + @dataclass(frozen=True) class FirstradeBrokerAdapters: client: FirstradeBrokerClient @@ -184,22 +249,26 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot: account_id=mask_account_id(self.account), ) ) - total_equity = ( - first_numeric_by_keywords(balances, ("total", "value")) - or first_numeric_by_keywords(balances, ("equity",)) - or sum(position.market_value for position in positions) + buying_power = _first_numeric_by_keyword_groups(balances, _BUYING_POWER_KEYWORD_GROUPS) + cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS) + position_market_value = sum(position.market_value for position in positions) + total_equity, total_equity_source = _resolve_total_equity( + balances=balances, + cash_balance=cash_balance, + buying_power=buying_power, + position_market_value=position_market_value, ) return PortfolioSnapshot( as_of=self.clock(), - total_equity=float(total_equity or 0.0), - buying_power=first_numeric_by_keywords(balances, ("buying",)) - or first_numeric_by_keywords(balances, ("bp",)), - cash_balance=first_numeric_by_keywords(balances, ("cash",)), + total_equity=float(total_equity), + buying_power=buying_power, + cash_balance=cash_balance, positions=tuple(positions), metadata={ "broker": "firstrade", "account_hash": self.account_hash or mask_account_id(self.account), "api_kind": "unofficial-reverse-engineered", + "total_equity_source": total_equity_source, }, ) diff --git a/decision_mapper.py b/decision_mapper.py index 59f9144..5d2127f 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -96,6 +96,31 @@ def _build_hold_current_value_decision(portfolio_inputs, *, diagnostics: Mapping ) +def _build_zero_equity_no_execute_decision( + decision: StrategyDecision, + *, + portfolio_inputs, + diagnostics: Mapping[str, Any], +) -> StrategyDecision: + if portfolio_inputs.market_values: + return _build_hold_current_value_decision(portfolio_inputs, diagnostics=diagnostics) + positions = [] + for position in decision.positions: + positions.append( + PositionTarget( + symbol=position.symbol, + target_value=0.0, + role=position.role or _symbol_role(position.symbol), + order_preference=position.order_preference, + ) + ) + return StrategyDecision( + positions=tuple(positions), + risk_flags=tuple(dict.fromkeys((*decision.risk_flags, "no_execute"))), + diagnostics=dict(diagnostics), + ) + + def _build_weight_translation_annotations( decision: StrategyDecision, *, @@ -185,14 +210,32 @@ def _normalize_to_value_decision( if target_mode == "value" and not no_execute: return decision, None if target_mode == "weight" and not no_execute: + total_equity = float(portfolio_inputs.total_equity) + if total_equity <= 0.0: + diagnostics = { + **dict(runtime_metadata or {}), + **dict(decision.diagnostics), + "execution_blocked_reason": "non_positive_total_equity", + "portfolio_total_equity": total_equity, + } + return _build_zero_equity_no_execute_decision( + decision, + portfolio_inputs=portfolio_inputs, + diagnostics=diagnostics, + ), _build_weight_translation_annotations( + decision, + total_equity=total_equity, + liquid_cash=float(portfolio_inputs.liquid_cash), + runtime_metadata=runtime_metadata, + ) translated = translate_decision_to_target_mode( decision, target_mode="value", - total_equity=float(portfolio_inputs.total_equity), + total_equity=total_equity, ) return translated, _build_weight_translation_annotations( decision, - total_equity=float(portfolio_inputs.total_equity), + total_equity=total_equity, liquid_cash=float(portfolio_inputs.liquid_cash), runtime_metadata=runtime_metadata, ) diff --git a/tests/test_firstrade_client.py b/tests/test_firstrade_client.py index 205981b..44dc46b 100644 --- a/tests/test_firstrade_client.py +++ b/tests/test_firstrade_client.py @@ -151,6 +151,26 @@ def test_client_order_preview_uses_dry_run_by_default(): assert response["price"] == 5.0 +def test_get_balances_includes_account_list_total_value(): + class BalancesWithoutTotalAccountData(FakeAccountData): + account_balances = {"12345678": "$987.65"} + + def get_account_balances(self, account): + return {"account": account, "cash_balance": "$987.65"} + + credentials = FirstradeCredentials(username="user", password="pass") + client = FirstradeBrokerClient( + credentials, + session_factory=FakeSession, + account_data_factory=BalancesWithoutTotalAccountData, + order_factory=FakeOrder, + ).connect() + + balances = client.get_balances("12345678") + + assert balances["account_list_total_value"] == "$987.65" + + def test_select_account_requires_explicit_account_when_multiple(): class MultiAccountData(FakeAccountData): account_numbers = ["11111111", "22222222"] diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 016cfed..f3f4b65 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -192,6 +192,93 @@ def fake_client_factory(*args, **kwargs): assert "🧪 Dry-run limit buy AAA: 2 shares @ $10.05" in messages[0] +def test_run_strategy_cycle_translates_weight_targets_when_balance_total_missing(monkeypatch): + class CashOnlyClient(FakeFirstradeClient): + def get_balances(self, _account): + return {"cash_balance": "$1000.00", "buying_power": "$1000.00"} + + class WeightTargetRuntime(FakeStrategyRuntime): + profile = "mega_cap_leader_rotation_top50_balanced" + display_name = "Mega Cap Leader Rotation Top50 Balanced" + + def evaluate(self, **inputs): + assert "portfolio_snapshot" in inputs + return SimpleNamespace( + decision=StrategyDecision( + positions=( + PositionTarget(symbol="AAA", target_weight=0.5, role="risk"), + ), + diagnostics={}, + ), + metadata={"strategy_profile": self.profile}, + ) + + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: WeightTargetRuntime(), + ) + + result = run_strategy_cycle( + runtime_settings=_runtime_settings_with_persistence( + strategy_profile="mega_cap_leader_rotation_top50_balanced", + strategy_display_name="Mega Cap Leader Rotation Top50 Balanced", + ), + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=CashOnlyClient, + env_reader=lambda _name, default=None: default, + ) + + assert result["ok"] is True + assert result["portfolio"]["total_equity"] == 1000.0 + assert result["allocation"]["targets"]["AAA"] == 500.0 + assert result["submitted_orders"][0]["symbol"] == "AAA" + + +def test_run_strategy_cycle_no_executes_weight_targets_when_total_equity_zero(monkeypatch): + class ZeroEquityClient(FakeFirstradeClient): + def get_balances(self, _account): + return {"total_value": "$0.00", "cash_balance": "$0.00", "buying_power": "$0.00"} + + class WeightTargetRuntime(FakeStrategyRuntime): + profile = "mega_cap_leader_rotation_top50_balanced" + display_name = "Mega Cap Leader Rotation Top50 Balanced" + + def evaluate(self, **inputs): + assert "portfolio_snapshot" in inputs + return SimpleNamespace( + decision=StrategyDecision( + positions=( + PositionTarget(symbol="AAA", target_weight=0.5, role="risk"), + ), + diagnostics={}, + ), + metadata={"strategy_profile": self.profile}, + ) + + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: WeightTargetRuntime(), + ) + + result = run_strategy_cycle( + runtime_settings=_runtime_settings_with_persistence( + strategy_profile="mega_cap_leader_rotation_top50_balanced", + strategy_display_name="Mega Cap Leader Rotation Top50 Balanced", + ), + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=ZeroEquityClient, + env_reader=lambda _name, default=None: default, + ) + + assert result["ok"] is True + assert result["portfolio"]["total_equity"] == 0.0 + assert result["allocation"]["targets"]["AAA"] == 0.0 + assert result["submitted_orders"] == [] + assert result["skipped_orders"] == [ + {"symbol": "AAA", "reason": "below_trade_threshold", "delta_value": 0.0} + ] + + def test_run_strategy_cycle_loads_strategy_plugin_report_and_sends_email( monkeypatch, tmp_path, diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py index cc05ee1..cd17068 100644 --- a/tests/test_runtime_broker_adapters.py +++ b/tests/test_runtime_broker_adapters.py @@ -48,6 +48,44 @@ def test_runtime_adapters_build_quote_and_portfolio_ports(): assert portfolio.positions[0].symbol == "SPY" +def test_portfolio_snapshot_uses_account_value_balance_key(): + class AccountValueClient(FakeClient): + def get_balances(self, _account): + return {"account_value": "$1,234.56", "cash_balance": "$200.00"} + + adapters = build_runtime_broker_adapters( + client=AccountValueClient(), + account="12345678", + strategy_symbols=("SPY",), + ) + + portfolio = adapters.build_portfolio_port().get_portfolio_snapshot() + + assert portfolio.total_equity == 1234.56 + assert portfolio.cash_balance == 200.0 + assert portfolio.metadata["total_equity_source"] == "balance_total" + + +def test_portfolio_snapshot_falls_back_to_cash_when_total_value_missing(): + class CashOnlyClient(FakeClient): + def get_balances(self, _account): + return {"cash_balance": "$120.00", "buying_power": "$120.00"} + + def get_positions(self, _account): + return {"items": []} + + adapters = build_runtime_broker_adapters( + client=CashOnlyClient(), + account="12345678", + strategy_symbols=("SPY",), + ) + + portfolio = adapters.build_portfolio_port().get_portfolio_snapshot() + + assert portfolio.total_equity == 120.0 + assert portfolio.metadata["total_equity_source"] == "cash_plus_positions" + + def test_price_series_appends_live_quote_when_history_lags_today(): adapters = build_runtime_broker_adapters( client=FakeClient(