Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion application/account_payload_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
7 changes: 6 additions & 1 deletion application/firstrade_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
85 changes: 77 additions & 8 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,78 @@ 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:
normalized = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
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)
)
Comment on lines +93 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit non-positive account equity

When Firstrade returns an authoritative non-positive total/account value, such as a margin-deficit balance formatted as ($50.00), _positive_or_none treats it as missing and falls through to cash/position fallbacks. If the positions endpoint still reports positive market values, the snapshot becomes positive and weight targets can be translated and executed instead of hitting the total_equity <= 0 no-execute guard. Only absent/unparseable totals should fall back; parsed <= 0 totals should be preserved so the execution block remains effective.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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,
},
)

Expand Down
47 changes: 45 additions & 2 deletions decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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,
)
Expand Down
20 changes: 20 additions & 0 deletions tests/test_firstrade_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
87 changes: 87 additions & 0 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions tests/test_runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down