Skip to content

Commit be3beb5

Browse files
committed
Handle zero-equity Firstrade strategy runs
1 parent 261727b commit be3beb5

7 files changed

Lines changed: 283 additions & 12 deletions

application/account_payload_utils.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,19 @@
99
def float_or_none(value: Any) -> float | None:
1010
if value in (None, ""):
1111
return None
12+
text = str(value).strip()
13+
if not text:
14+
return None
15+
negative_parentheses = text.startswith("(") and text.endswith(")")
16+
if negative_parentheses:
17+
text = text[1:-1].strip()
18+
if text.startswith("$"):
19+
text = text[1:].strip()
1220
try:
13-
return float(str(value).replace(",", ""))
21+
number = float(text.replace(",", ""))
1422
except (TypeError, ValueError):
1523
return None
24+
return -number if negative_parentheses else number
1625

1726

1827
def flatten_values(payload: Any, prefix: str = "") -> dict[str, Any]:

application/firstrade_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,12 @@ def list_account_summaries(self) -> list[dict[str, Any]]:
428428

429429
def get_balances(self, account: str) -> dict[str, Any]:
430430
_, account_data = self.require_connected()
431-
return dict(account_data.get_account_balances(account))
431+
balances = dict(account_data.get_account_balances(account))
432+
account_balances = dict(getattr(account_data, "account_balances", {}) or {})
433+
account_list_total_value = account_balances.get(account)
434+
if account_list_total_value is not None and "account_list_total_value" not in balances:
435+
balances["account_list_total_value"] = account_list_total_value
436+
return balances
432437

433438
def get_positions(self, account: str) -> dict[str, Any]:
434439
_, account_data = self.require_connected()

application/runtime_broker_adapters.py

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,78 @@ def _utcnow() -> datetime:
4141

4242

4343
_NEW_YORK_TZ = ZoneInfo("America/New_York")
44+
_TOTAL_EQUITY_KEYWORD_GROUPS = (
45+
("total", "value"),
46+
("total", "equity"),
47+
("account", "value"),
48+
("account", "equity"),
49+
("net", "liquid"),
50+
("liquidation",),
51+
("equity",),
52+
)
53+
_BUYING_POWER_KEYWORD_GROUPS = (
54+
("buying", "power"),
55+
("buying",),
56+
("bp",),
57+
)
58+
_CASH_BALANCE_KEYWORD_GROUPS = (
59+
("cash", "balance"),
60+
("available", "cash"),
61+
("cash", "available"),
62+
("cash",),
63+
)
4464

4565

4666
def _market_date(value: datetime) -> date:
4767
normalized = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
4868
return normalized.astimezone(_NEW_YORK_TZ).date()
4969

5070

71+
def _first_numeric_by_keyword_groups(payload, keyword_groups: tuple[tuple[str, ...], ...]) -> float | None:
72+
for keywords in keyword_groups:
73+
value = first_numeric_by_keywords(payload, keywords)
74+
if value is not None:
75+
return value
76+
return None
77+
78+
79+
def _positive_or_none(value: float | None) -> float | None:
80+
if value is None:
81+
return None
82+
resolved = float(value)
83+
return resolved if resolved > 0.0 else None
84+
85+
86+
def _resolve_total_equity(
87+
*,
88+
balances,
89+
cash_balance: float | None,
90+
buying_power: float | None,
91+
position_market_value: float,
92+
) -> tuple[float, str]:
93+
balance_total = _positive_or_none(
94+
_first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS)
95+
)
96+
if balance_total is not None:
97+
return balance_total, "balance_total"
98+
99+
resolved_cash = _positive_or_none(cash_balance)
100+
if resolved_cash is not None:
101+
combined_value = resolved_cash + max(0.0, float(position_market_value))
102+
if combined_value > 0.0:
103+
return combined_value, "cash_plus_positions"
104+
105+
positive_position_value = _positive_or_none(position_market_value)
106+
if positive_position_value is not None:
107+
return positive_position_value, "positions"
108+
109+
positive_buying_power = _positive_or_none(buying_power)
110+
if positive_buying_power is not None:
111+
return positive_buying_power, "buying_power_fallback"
112+
113+
return 0.0, "unresolved"
114+
115+
51116
@dataclass(frozen=True)
52117
class FirstradeBrokerAdapters:
53118
client: FirstradeBrokerClient
@@ -184,22 +249,26 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot:
184249
account_id=mask_account_id(self.account),
185250
)
186251
)
187-
total_equity = (
188-
first_numeric_by_keywords(balances, ("total", "value"))
189-
or first_numeric_by_keywords(balances, ("equity",))
190-
or sum(position.market_value for position in positions)
252+
buying_power = _first_numeric_by_keyword_groups(balances, _BUYING_POWER_KEYWORD_GROUPS)
253+
cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS)
254+
position_market_value = sum(position.market_value for position in positions)
255+
total_equity, total_equity_source = _resolve_total_equity(
256+
balances=balances,
257+
cash_balance=cash_balance,
258+
buying_power=buying_power,
259+
position_market_value=position_market_value,
191260
)
192261
return PortfolioSnapshot(
193262
as_of=self.clock(),
194-
total_equity=float(total_equity or 0.0),
195-
buying_power=first_numeric_by_keywords(balances, ("buying",))
196-
or first_numeric_by_keywords(balances, ("bp",)),
197-
cash_balance=first_numeric_by_keywords(balances, ("cash",)),
263+
total_equity=float(total_equity),
264+
buying_power=buying_power,
265+
cash_balance=cash_balance,
198266
positions=tuple(positions),
199267
metadata={
200268
"broker": "firstrade",
201269
"account_hash": self.account_hash or mask_account_id(self.account),
202270
"api_kind": "unofficial-reverse-engineered",
271+
"total_equity_source": total_equity_source,
203272
},
204273
)
205274

decision_mapper.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,31 @@ def _build_hold_current_value_decision(portfolio_inputs, *, diagnostics: Mapping
9696
)
9797

9898

99+
def _build_zero_equity_no_execute_decision(
100+
decision: StrategyDecision,
101+
*,
102+
portfolio_inputs,
103+
diagnostics: Mapping[str, Any],
104+
) -> StrategyDecision:
105+
if portfolio_inputs.market_values:
106+
return _build_hold_current_value_decision(portfolio_inputs, diagnostics=diagnostics)
107+
positions = []
108+
for position in decision.positions:
109+
positions.append(
110+
PositionTarget(
111+
symbol=position.symbol,
112+
target_value=0.0,
113+
role=position.role or _symbol_role(position.symbol),
114+
order_preference=position.order_preference,
115+
)
116+
)
117+
return StrategyDecision(
118+
positions=tuple(positions),
119+
risk_flags=tuple(dict.fromkeys((*decision.risk_flags, "no_execute"))),
120+
diagnostics=dict(diagnostics),
121+
)
122+
123+
99124
def _build_weight_translation_annotations(
100125
decision: StrategyDecision,
101126
*,
@@ -185,14 +210,32 @@ def _normalize_to_value_decision(
185210
if target_mode == "value" and not no_execute:
186211
return decision, None
187212
if target_mode == "weight" and not no_execute:
213+
total_equity = float(portfolio_inputs.total_equity)
214+
if total_equity <= 0.0:
215+
diagnostics = {
216+
**dict(runtime_metadata or {}),
217+
**dict(decision.diagnostics),
218+
"execution_blocked_reason": "non_positive_total_equity",
219+
"portfolio_total_equity": total_equity,
220+
}
221+
return _build_zero_equity_no_execute_decision(
222+
decision,
223+
portfolio_inputs=portfolio_inputs,
224+
diagnostics=diagnostics,
225+
), _build_weight_translation_annotations(
226+
decision,
227+
total_equity=total_equity,
228+
liquid_cash=float(portfolio_inputs.liquid_cash),
229+
runtime_metadata=runtime_metadata,
230+
)
188231
translated = translate_decision_to_target_mode(
189232
decision,
190233
target_mode="value",
191-
total_equity=float(portfolio_inputs.total_equity),
234+
total_equity=total_equity,
192235
)
193236
return translated, _build_weight_translation_annotations(
194237
decision,
195-
total_equity=float(portfolio_inputs.total_equity),
238+
total_equity=total_equity,
196239
liquid_cash=float(portfolio_inputs.liquid_cash),
197240
runtime_metadata=runtime_metadata,
198241
)

tests/test_firstrade_client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,26 @@ def test_client_order_preview_uses_dry_run_by_default():
151151
assert response["price"] == 5.0
152152

153153

154+
def test_get_balances_includes_account_list_total_value():
155+
class BalancesWithoutTotalAccountData(FakeAccountData):
156+
account_balances = {"12345678": "$987.65"}
157+
158+
def get_account_balances(self, account):
159+
return {"account": account, "cash_balance": "$987.65"}
160+
161+
credentials = FirstradeCredentials(username="user", password="pass")
162+
client = FirstradeBrokerClient(
163+
credentials,
164+
session_factory=FakeSession,
165+
account_data_factory=BalancesWithoutTotalAccountData,
166+
order_factory=FakeOrder,
167+
).connect()
168+
169+
balances = client.get_balances("12345678")
170+
171+
assert balances["account_list_total_value"] == "$987.65"
172+
173+
154174
def test_select_account_requires_explicit_account_when_multiple():
155175
class MultiAccountData(FakeAccountData):
156176
account_numbers = ["11111111", "22222222"]

tests/test_rebalance_service.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,93 @@ def fake_client_factory(*args, **kwargs):
192192
assert "🧪 Dry-run limit buy AAA: 2 shares @ $10.05" in messages[0]
193193

194194

195+
def test_run_strategy_cycle_translates_weight_targets_when_balance_total_missing(monkeypatch):
196+
class CashOnlyClient(FakeFirstradeClient):
197+
def get_balances(self, _account):
198+
return {"cash_balance": "$1000.00", "buying_power": "$1000.00"}
199+
200+
class WeightTargetRuntime(FakeStrategyRuntime):
201+
profile = "mega_cap_leader_rotation_top50_balanced"
202+
display_name = "Mega Cap Leader Rotation Top50 Balanced"
203+
204+
def evaluate(self, **inputs):
205+
assert "portfolio_snapshot" in inputs
206+
return SimpleNamespace(
207+
decision=StrategyDecision(
208+
positions=(
209+
PositionTarget(symbol="AAA", target_weight=0.5, role="risk"),
210+
),
211+
diagnostics={},
212+
),
213+
metadata={"strategy_profile": self.profile},
214+
)
215+
216+
monkeypatch.setattr(
217+
"application.rebalance_service.load_strategy_runtime",
218+
lambda *_args, **_kwargs: WeightTargetRuntime(),
219+
)
220+
221+
result = run_strategy_cycle(
222+
runtime_settings=_runtime_settings_with_persistence(
223+
strategy_profile="mega_cap_leader_rotation_top50_balanced",
224+
strategy_display_name="Mega Cap Leader Rotation Top50 Balanced",
225+
),
226+
credentials=FirstradeCredentials(username="user", password="pass"),
227+
client_factory=CashOnlyClient,
228+
env_reader=lambda _name, default=None: default,
229+
)
230+
231+
assert result["ok"] is True
232+
assert result["portfolio"]["total_equity"] == 1000.0
233+
assert result["allocation"]["targets"]["AAA"] == 500.0
234+
assert result["submitted_orders"][0]["symbol"] == "AAA"
235+
236+
237+
def test_run_strategy_cycle_no_executes_weight_targets_when_total_equity_zero(monkeypatch):
238+
class ZeroEquityClient(FakeFirstradeClient):
239+
def get_balances(self, _account):
240+
return {"total_value": "$0.00", "cash_balance": "$0.00", "buying_power": "$0.00"}
241+
242+
class WeightTargetRuntime(FakeStrategyRuntime):
243+
profile = "mega_cap_leader_rotation_top50_balanced"
244+
display_name = "Mega Cap Leader Rotation Top50 Balanced"
245+
246+
def evaluate(self, **inputs):
247+
assert "portfolio_snapshot" in inputs
248+
return SimpleNamespace(
249+
decision=StrategyDecision(
250+
positions=(
251+
PositionTarget(symbol="AAA", target_weight=0.5, role="risk"),
252+
),
253+
diagnostics={},
254+
),
255+
metadata={"strategy_profile": self.profile},
256+
)
257+
258+
monkeypatch.setattr(
259+
"application.rebalance_service.load_strategy_runtime",
260+
lambda *_args, **_kwargs: WeightTargetRuntime(),
261+
)
262+
263+
result = run_strategy_cycle(
264+
runtime_settings=_runtime_settings_with_persistence(
265+
strategy_profile="mega_cap_leader_rotation_top50_balanced",
266+
strategy_display_name="Mega Cap Leader Rotation Top50 Balanced",
267+
),
268+
credentials=FirstradeCredentials(username="user", password="pass"),
269+
client_factory=ZeroEquityClient,
270+
env_reader=lambda _name, default=None: default,
271+
)
272+
273+
assert result["ok"] is True
274+
assert result["portfolio"]["total_equity"] == 0.0
275+
assert result["allocation"]["targets"]["AAA"] == 0.0
276+
assert result["submitted_orders"] == []
277+
assert result["skipped_orders"] == [
278+
{"symbol": "AAA", "reason": "below_trade_threshold", "delta_value": 0.0}
279+
]
280+
281+
195282
def test_run_strategy_cycle_loads_strategy_plugin_report_and_sends_email(
196283
monkeypatch,
197284
tmp_path,

tests/test_runtime_broker_adapters.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,44 @@ def test_runtime_adapters_build_quote_and_portfolio_ports():
4848
assert portfolio.positions[0].symbol == "SPY"
4949

5050

51+
def test_portfolio_snapshot_uses_account_value_balance_key():
52+
class AccountValueClient(FakeClient):
53+
def get_balances(self, _account):
54+
return {"account_value": "$1,234.56", "cash_balance": "$200.00"}
55+
56+
adapters = build_runtime_broker_adapters(
57+
client=AccountValueClient(),
58+
account="12345678",
59+
strategy_symbols=("SPY",),
60+
)
61+
62+
portfolio = adapters.build_portfolio_port().get_portfolio_snapshot()
63+
64+
assert portfolio.total_equity == 1234.56
65+
assert portfolio.cash_balance == 200.0
66+
assert portfolio.metadata["total_equity_source"] == "balance_total"
67+
68+
69+
def test_portfolio_snapshot_falls_back_to_cash_when_total_value_missing():
70+
class CashOnlyClient(FakeClient):
71+
def get_balances(self, _account):
72+
return {"cash_balance": "$120.00", "buying_power": "$120.00"}
73+
74+
def get_positions(self, _account):
75+
return {"items": []}
76+
77+
adapters = build_runtime_broker_adapters(
78+
client=CashOnlyClient(),
79+
account="12345678",
80+
strategy_symbols=("SPY",),
81+
)
82+
83+
portfolio = adapters.build_portfolio_port().get_portfolio_snapshot()
84+
85+
assert portfolio.total_equity == 120.0
86+
assert portfolio.metadata["total_equity_source"] == "cash_plus_positions"
87+
88+
5189
def test_price_series_appends_live_quote_when_history_lags_today():
5290
adapters = build_runtime_broker_adapters(
5391
client=FakeClient(

0 commit comments

Comments
 (0)