99__all__ = [
1010 "SmallAccountCashCompatibilityResult" ,
1111 "apply_small_account_cash_compatibility" ,
12+ "build_small_account_allocation_drift_notes" ,
13+ "format_small_account_allocation_drift_notes" ,
1214 "format_small_account_cash_substitution_notes" ,
1315 "project_unbuyable_value_targets_to_cash" ,
1416]
@@ -26,6 +28,14 @@ def _normalize_symbol(value: object) -> str:
2628 return str (value or "" ).strip ().upper ()
2729
2830
31+ def _normalize_trade_symbol (value : object , * , symbol_suffix : str = ".US" ) -> str :
32+ symbol = _normalize_symbol (value )
33+ suffix = str (symbol_suffix or "" ).strip ().upper ()
34+ if suffix and symbol .endswith (suffix ):
35+ return symbol [: - len (suffix )]
36+ return symbol
37+
38+
2939def _positive_target_total (targets : Mapping [str , object ]) -> float :
3040 total = 0.0
3141 for value in dict (targets or {}).values ():
@@ -51,6 +61,189 @@ def _format_symbol(symbol: str, *, suffix: str) -> str:
5161 return normalized
5262
5363
64+ def _coerce_float (value : object , default : float = 0.0 ) -> float :
65+ try :
66+ return float (value or 0.0 )
67+ except (TypeError , ValueError ):
68+ return default
69+
70+
71+ def _coerce_order_price (order : Mapping [str , object ], prices : Mapping [str , float ], symbol : str ) -> float :
72+ for key in ("average_fill_price" , "filled_price" , "limit_price" , "price" , "submitted_price" ):
73+ price = _coerce_float (order .get (key ), 0.0 )
74+ if price > 0.0 :
75+ return price
76+ return max (0.0 , float (prices .get (symbol , 0.0 ) or 0.0 ))
77+
78+
79+ def _format_weight (value : float ) -> str :
80+ return f"{ float (value or 0.0 ):.1%} "
81+
82+
83+ def _format_weight_drift (value : float ) -> str :
84+ return f"{ float (value or 0.0 ) * 100 :+.1f} pp"
85+
86+
87+ def build_small_account_allocation_drift_notes (
88+ * ,
89+ target_values : Mapping [str , object ] | None = None ,
90+ target_weights : Mapping [str , object ] | None = None ,
91+ current_values : Mapping [str , object ] | None = None ,
92+ current_quantities : Mapping [str , object ] | None = None ,
93+ prices : Mapping [str , object ] | None = None ,
94+ submitted_orders : Iterable [Mapping [str , object ]] = (),
95+ total_value : float | None = None ,
96+ cash_value : float = 0.0 ,
97+ symbol_suffix : str = ".US" ,
98+ min_abs_weight_drift : float = 0.005 ,
99+ small_account_max_total_value : float = 5000.0 ,
100+ max_notes : int = 5 ,
101+ ) -> tuple [dict [str , object ], ...]:
102+ """Estimate target drift after whole-share orders fully fill.
103+
104+ The estimate is intentionally simple and side-effect free: it uses current
105+ values/quantities, order quantities, and reference prices to explain the
106+ integer-share gap a small account may see if the submitted orders all fill.
107+ """
108+
109+ normalized_prices = {
110+ _normalize_trade_symbol (symbol , symbol_suffix = symbol_suffix ): _coerce_float (price )
111+ for symbol , price in dict (prices or {}).items ()
112+ }
113+ normalized_current_values = {
114+ _normalize_trade_symbol (symbol , symbol_suffix = symbol_suffix ): _coerce_float (value )
115+ for symbol , value in dict (current_values or {}).items ()
116+ }
117+ normalized_current_quantities = {
118+ _normalize_trade_symbol (symbol , symbol_suffix = symbol_suffix ): _coerce_float (quantity )
119+ for symbol , quantity in dict (current_quantities or {}).items ()
120+ }
121+ for symbol , quantity in normalized_current_quantities .items ():
122+ if symbol not in normalized_current_values :
123+ normalized_current_values [symbol ] = quantity * max (0.0 , normalized_prices .get (symbol , 0.0 ))
124+
125+ denominator = _coerce_float (total_value , 0.0 )
126+ if denominator <= 0.0 :
127+ denominator = sum (max (0.0 , value ) for value in normalized_current_values .values ()) + max (
128+ 0.0 ,
129+ _coerce_float (cash_value , 0.0 ),
130+ )
131+ if denominator <= 0.0 :
132+ return ()
133+ if denominator > max (0.0 , _coerce_float (small_account_max_total_value , 0.0 )):
134+ return ()
135+
136+ normalized_target_values : dict [str , float ] = {}
137+ if target_values :
138+ normalized_target_values .update (
139+ {
140+ _normalize_trade_symbol (symbol , symbol_suffix = symbol_suffix ): _coerce_float (value )
141+ for symbol , value in dict (target_values or {}).items ()
142+ }
143+ )
144+ if target_weights :
145+ for symbol , weight in dict (target_weights or {}).items ():
146+ normalized_target_values [_normalize_trade_symbol (symbol , symbol_suffix = symbol_suffix )] = (
147+ denominator * _coerce_float (weight )
148+ )
149+
150+ if not normalized_target_values :
151+ return ()
152+
153+ projected_values = dict (normalized_current_values )
154+ projected_quantities = dict (normalized_current_quantities )
155+ for raw_order in tuple (submitted_orders or ()):
156+ if not isinstance (raw_order , Mapping ):
157+ continue
158+ symbol = _normalize_trade_symbol (raw_order .get ("symbol" ), symbol_suffix = symbol_suffix )
159+ if not symbol :
160+ continue
161+ side = str (raw_order .get ("side" ) or "" ).strip ().lower ()
162+ quantity = _coerce_float (raw_order .get ("quantity" ), 0.0 )
163+ if quantity <= 0.0 or side not in {"buy" , "sell" }:
164+ continue
165+ price = _coerce_order_price (raw_order , normalized_prices , symbol )
166+ if price <= 0.0 :
167+ continue
168+ signed_quantity = quantity if side == "buy" else - quantity
169+ projected_quantities [symbol ] = projected_quantities .get (symbol , 0.0 ) + signed_quantity
170+ projected_values [symbol ] = max (0.0 , projected_values .get (symbol , 0.0 ) + signed_quantity * price )
171+ normalized_prices .setdefault (symbol , price )
172+
173+ notes : list [dict [str , object ]] = []
174+ symbols = sorted (set (normalized_target_values ))
175+ for symbol in symbols :
176+ target_value = max (0.0 , normalized_target_values .get (symbol , 0.0 ))
177+ projected_value = max (0.0 , projected_values .get (symbol , 0.0 ))
178+ if target_value <= 0.0 and projected_value <= 0.0 :
179+ continue
180+ target_weight = target_value / denominator
181+ projected_weight = projected_value / denominator
182+ drift_weight = projected_weight - target_weight
183+ if abs (drift_weight ) < max (0.0 , _coerce_float (min_abs_weight_drift , 0.0 )):
184+ continue
185+ notes .append (
186+ {
187+ "kind" : "small_account_allocation_drift" ,
188+ "symbol" : symbol ,
189+ "target_value" : round (target_value , 2 ),
190+ "projected_value" : round (projected_value , 2 ),
191+ "target_weight" : target_weight ,
192+ "projected_weight" : projected_weight ,
193+ "drift_weight" : drift_weight ,
194+ "drift_value" : round (projected_value - target_value , 2 ),
195+ "projected_quantity" : projected_quantities .get (symbol ),
196+ }
197+ )
198+
199+ notes .sort (key = lambda note : abs (float (note .get ("drift_weight" ) or 0.0 )), reverse = True )
200+ return tuple (notes [: max (0 , int (max_notes or 0 ))])
201+
202+
203+ def format_small_account_allocation_drift_notes (
204+ notes : Iterable [Mapping [str , object ]],
205+ * ,
206+ translator ,
207+ wrapper_key : str = "small_account_allocation_drift" ,
208+ detail_key : str = "small_account_allocation_drift_detail" ,
209+ symbol_suffix : str = ".US" ,
210+ ) -> tuple [str , ...]:
211+ """Render small-account projected allocation drift notes."""
212+
213+ details : list [str ] = []
214+ seen_symbols : set [str ] = set ()
215+ for note in tuple (notes or ()):
216+ if not isinstance (note , Mapping ):
217+ continue
218+ if str (note .get ("kind" ) or "" ) != "small_account_allocation_drift" :
219+ continue
220+ symbol = _normalize_symbol (note .get ("symbol" ))
221+ if not symbol or symbol in seen_symbols :
222+ continue
223+ seen_symbols .add (symbol )
224+ detail = translator (
225+ detail_key ,
226+ symbol = _format_symbol (symbol , suffix = symbol_suffix ),
227+ projected_weight = _format_weight (_coerce_float (note .get ("projected_weight" ))),
228+ target_weight = _format_weight (_coerce_float (note .get ("target_weight" ))),
229+ drift_weight = _format_weight_drift (_coerce_float (note .get ("drift_weight" ))),
230+ )
231+ if not detail or detail == detail_key :
232+ detail = (
233+ f"{ _format_symbol (symbol , suffix = symbol_suffix )} projected "
234+ f"{ _format_weight (_coerce_float (note .get ('projected_weight' )))} vs target "
235+ f"{ _format_weight (_coerce_float (note .get ('target_weight' )))} "
236+ f"({ _format_weight_drift (_coerce_float (note .get ('drift_weight' )))} )"
237+ )
238+ details .append (str (detail ))
239+ if not details :
240+ return ()
241+ message = translator (wrapper_key , details = "; " .join (details ))
242+ if not message or message == wrapper_key :
243+ message = f"Small-account integer-share drift: { '; ' .join (details )} "
244+ return (message ,)
245+
246+
54247def project_unbuyable_value_targets_to_cash (
55248 target_values : Mapping [str , object ],
56249 prices : Mapping [str , object ],
0 commit comments