@@ -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
4666def _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 )
52117class 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
0 commit comments