33from __future__ import annotations
44
55from collections .abc import Iterable , Mapping
6+ from dataclasses import dataclass
67
78
8- __all__ = ["project_unbuyable_value_targets_to_cash" ]
9+ __all__ = [
10+ "SmallAccountCashCompatibilityResult" ,
11+ "apply_small_account_cash_compatibility" ,
12+ "format_small_account_cash_substitution_notes" ,
13+ "project_unbuyable_value_targets_to_cash" ,
14+ ]
15+
16+
17+ @dataclass (frozen = True )
18+ class SmallAccountCashCompatibilityResult :
19+ targets : dict [str , float ]
20+ whole_share_substituted_symbols : tuple [str , ...]
21+ safe_haven_cash_substituted_symbols : tuple [str , ...]
22+ cash_substitution_notes : tuple [dict [str , object ], ...]
923
1024
1125def _normalize_symbol (value : object ) -> str :
1226 return str (value or "" ).strip ().upper ()
1327
1428
29+ def _positive_target_total (targets : Mapping [str , object ]) -> float :
30+ total = 0.0
31+ for value in dict (targets or {}).values ():
32+ try :
33+ total += max (0.0 , float (value or 0.0 ))
34+ except (TypeError , ValueError ):
35+ continue
36+ return total
37+
38+
39+ def _normalize_prices (prices : Mapping [str , object ]) -> dict [str , float ]:
40+ return {
41+ _normalize_symbol (symbol ): float (price or 0.0 )
42+ for symbol , price in dict (prices or {}).items ()
43+ }
44+
45+
46+ def _format_symbol (symbol : str , * , suffix : str ) -> str :
47+ normalized = _normalize_symbol (symbol )
48+ normalized_suffix = str (suffix or "" ).strip ()
49+ if normalized_suffix and not normalized .endswith (normalized_suffix .upper ()):
50+ return f"{ normalized } { normalized_suffix } "
51+ return normalized
52+
53+
1554def project_unbuyable_value_targets_to_cash (
1655 target_values : Mapping [str , object ],
1756 prices : Mapping [str , object ],
@@ -40,10 +79,7 @@ def project_unbuyable_value_targets_to_cash(
4079 candidate_symbols = tuple (dict .fromkeys (_normalize_symbol (symbol ) for symbol in symbols ))
4180
4281 substituted : list [str ] = []
43- normalized_prices = {
44- _normalize_symbol (symbol ): float (price or 0.0 )
45- for symbol , price in dict (prices or {}).items ()
46- }
82+ normalized_prices = _normalize_prices (prices )
4783 for symbol in candidate_symbols :
4884 if not symbol :
4985 continue
@@ -56,3 +92,140 @@ def project_unbuyable_value_targets_to_cash(
5692 substituted .append (symbol )
5793
5894 return adjusted , tuple (dict .fromkeys (substituted ))
95+
96+
97+ def apply_small_account_cash_compatibility (
98+ target_values : Mapping [str , object ],
99+ prices : Mapping [str , object ],
100+ * ,
101+ candidate_symbols : Iterable [str ] | None = None ,
102+ safe_haven_cash_symbols : Iterable [str ] = (),
103+ quantity_step : float = 1.0 ,
104+ cash_substitute_limit_usd : float = 2000.0 ,
105+ ) -> SmallAccountCashCompatibilityResult :
106+ """Apply whole-share small-account projection and cash-safe-haven fallback.
107+
108+ If every risk/income target that remains positive is below one tradable unit,
109+ and the remaining positive safe-haven/cash-sweep sleeve is still small, the
110+ safe-haven target is also projected to cash. The returned notes preserve the
111+ original target and price so platform notifications can explain why no risk
112+ or safe-haven rebuy was submitted.
113+ """
114+
115+ adjusted_targets , substituted = project_unbuyable_value_targets_to_cash (
116+ target_values ,
117+ prices ,
118+ symbols = candidate_symbols ,
119+ quantity_step = quantity_step ,
120+ )
121+ normalized_candidates = (
122+ tuple (adjusted_targets )
123+ if candidate_symbols is None
124+ else tuple (dict .fromkeys (_normalize_symbol (symbol ) for symbol in candidate_symbols ))
125+ )
126+ remaining_non_safe_targets = [
127+ symbol
128+ for symbol in normalized_candidates
129+ if float (adjusted_targets .get (_normalize_symbol (symbol ), 0.0 ) or 0.0 ) > 0.0
130+ ]
131+ safe_haven_symbols = tuple (
132+ dict .fromkeys (
133+ _normalize_symbol (symbol )
134+ for symbol in safe_haven_cash_symbols
135+ if _normalize_symbol (symbol )
136+ )
137+ )
138+ safe_haven_substituted : list [str ] = []
139+ if (
140+ substituted
141+ and not remaining_non_safe_targets
142+ and _positive_target_total (adjusted_targets ) <= max (0.0 , float (cash_substitute_limit_usd or 0.0 ))
143+ ):
144+ for symbol in safe_haven_symbols :
145+ if float (adjusted_targets .get (symbol , 0.0 ) or 0.0 ) > 0.0 :
146+ adjusted_targets [symbol ] = 0.0
147+ safe_haven_substituted .append (symbol )
148+
149+ notes : list [dict [str , object ]] = []
150+ if safe_haven_substituted :
151+ normalized_targets = {
152+ _normalize_symbol (symbol ): float (value or 0.0 )
153+ for symbol , value in dict (target_values or {}).items ()
154+ }
155+ normalized_prices = _normalize_prices (prices )
156+ for symbol in substituted :
157+ target_value = max (0.0 , float (normalized_targets .get (symbol , 0.0 ) or 0.0 ))
158+ price = max (0.0 , float (normalized_prices .get (symbol , 0.0 ) or 0.0 ))
159+ if target_value <= 0.0 or price <= 0.0 :
160+ continue
161+ notes .append (
162+ {
163+ "symbol" : symbol ,
164+ "target_value" : target_value ,
165+ "price" : price ,
166+ "cash_symbols" : tuple (safe_haven_substituted ),
167+ }
168+ )
169+
170+ return SmallAccountCashCompatibilityResult (
171+ targets = adjusted_targets ,
172+ whole_share_substituted_symbols = substituted ,
173+ safe_haven_cash_substituted_symbols = tuple (safe_haven_substituted ),
174+ cash_substitution_notes = tuple (notes ),
175+ )
176+
177+
178+ def format_small_account_cash_substitution_notes (
179+ notes : Iterable [Mapping [str , object ]],
180+ * ,
181+ translator ,
182+ wrapper_key : str = "buy_deferred" ,
183+ detail_key : str = "buy_deferred_small_account_cash_substitution" ,
184+ cash_label_key : str = "cash_label" ,
185+ symbol_suffix : str = ".US" ,
186+ ) -> tuple [str , ...]:
187+ """Render small-account cash substitution notes through platform i18n."""
188+
189+ messages : list [str ] = []
190+ seen_keys : set [tuple [str , str , str ]] = set ()
191+ for note in tuple (notes or ()):
192+ if not isinstance (note , Mapping ):
193+ continue
194+ symbol = _normalize_symbol (note .get ("symbol" ))
195+ if not symbol :
196+ continue
197+ target_value = max (0.0 , float (note .get ("target_value" ) or 0.0 ))
198+ price = max (0.0 , float (note .get ("price" ) or 0.0 ))
199+ if target_value <= 0.0 or price <= 0.0 :
200+ continue
201+ cash_symbols = tuple (
202+ dict .fromkeys (
203+ _normalize_symbol (cash_symbol )
204+ for cash_symbol in tuple (note .get ("cash_symbols" ) or ())
205+ if _normalize_symbol (cash_symbol )
206+ )
207+ )
208+ cash_symbols_text = ", " .join (
209+ _format_symbol (cash_symbol , suffix = symbol_suffix )
210+ for cash_symbol in cash_symbols
211+ )
212+ if not cash_symbols_text :
213+ cash_symbols_text = str (translator (cash_label_key )).strip ()
214+ if not cash_symbols_text or cash_symbols_text == cash_label_key :
215+ cash_symbols_text = "cash"
216+ note_key = (symbol , f"{ target_value :.2f} " , cash_symbols_text )
217+ if note_key in seen_keys :
218+ continue
219+ seen_keys .add (note_key )
220+ detail = translator (
221+ detail_key ,
222+ symbol = _format_symbol (symbol , suffix = symbol_suffix ),
223+ diff = f"{ target_value :.2f} " ,
224+ price = f"{ price :.2f} " ,
225+ cash_symbols = cash_symbols_text ,
226+ )
227+ message = translator (wrapper_key , detail = detail )
228+ if not message or message == wrapper_key :
229+ message = detail
230+ messages .append (message )
231+ return tuple (messages )
0 commit comments