66import json
77import tempfile
88import time
9+ from collections .abc import Mapping
10+ from dataclasses import dataclass
911from datetime import datetime
1012from pathlib import Path
1113from typing import Any
@@ -42,14 +44,22 @@ def should_sell_cash_sweep_to_fund_whole_share_buy(
4244 return False
4345try :
4446 from quant_platform_kit .common .small_account_compatibility import (
45- project_unbuyable_value_targets_to_cash ,
47+ apply_small_account_cash_compatibility ,
48+ format_small_account_cash_substitution_notes ,
4649 )
4750except ImportError : # pragma: no cover - compatibility with older pinned shared wheels
48- def project_unbuyable_value_targets_to_cash (
51+ @dataclass (frozen = True )
52+ class _SmallAccountCashCompatibilityResult :
53+ targets : dict [str , float ]
54+ whole_share_substituted_symbols : tuple [str , ...]
55+ safe_haven_cash_substituted_symbols : tuple [str , ...]
56+ cash_substitution_notes : tuple [dict [str , Any ], ...]
57+
58+ def _project_unbuyable_value_targets_to_cash (
4959 target_values ,
5060 prices ,
5161 * ,
52- symbols = None ,
62+ candidate_symbols = None ,
5363 quantity_step = 1.0 ,
5464 ):
5565 adjusted = {
@@ -59,23 +69,140 @@ def project_unbuyable_value_targets_to_cash(
5969 step = max (0.0 , float (quantity_step or 0.0 ))
6070 if step <= 0.0 :
6171 return adjusted , ()
62- candidate_symbols = (
72+ normalized_candidates = (
6373 tuple (adjusted )
64- if symbols is None
65- else tuple (dict .fromkeys (str (symbol or "" ).strip ().upper () for symbol in symbols ))
74+ if candidate_symbols is None
75+ else tuple (dict .fromkeys (str (symbol or "" ).strip ().upper () for symbol in candidate_symbols ))
6676 )
6777 normalized_prices = {
6878 str (symbol or "" ).strip ().upper (): float (price or 0.0 )
6979 for symbol , price in dict (prices or {}).items ()
7080 }
7181 substituted = []
72- for symbol in candidate_symbols :
82+ for symbol in normalized_candidates :
7383 target_value = max (0.0 , float (adjusted .get (symbol , 0.0 ) or 0.0 ))
7484 price = max (0.0 , float (normalized_prices .get (symbol , 0.0 ) or 0.0 ))
7585 if price > 0.0 and 0.0 < target_value < (price * step ):
7686 adjusted [symbol ] = 0.0
7787 substituted .append (symbol )
7888 return adjusted , tuple (dict .fromkeys (substituted ))
89+
90+ def apply_small_account_cash_compatibility (
91+ target_values ,
92+ prices ,
93+ * ,
94+ candidate_symbols = None ,
95+ safe_haven_cash_symbols = (),
96+ quantity_step = 1.0 ,
97+ cash_substitute_limit_usd = 2000.0 ,
98+ ):
99+ adjusted_targets , substituted = _project_unbuyable_value_targets_to_cash (
100+ target_values ,
101+ prices ,
102+ candidate_symbols = candidate_symbols ,
103+ quantity_step = quantity_step ,
104+ )
105+ normalized_candidates = (
106+ tuple (adjusted_targets )
107+ if candidate_symbols is None
108+ else tuple (dict .fromkeys (str (symbol or "" ).strip ().upper () for symbol in candidate_symbols ))
109+ )
110+ remaining_non_safe_targets = [
111+ symbol
112+ for symbol in normalized_candidates
113+ if float (adjusted_targets .get (str (symbol or "" ).strip ().upper (), 0.0 ) or 0.0 ) > 0.0
114+ ]
115+ safe_haven_symbols = tuple (
116+ dict .fromkeys (
117+ str (symbol or "" ).strip ().upper ()
118+ for symbol in safe_haven_cash_symbols
119+ if str (symbol or "" ).strip ()
120+ )
121+ )
122+ safe_haven_substituted = []
123+ if (
124+ substituted
125+ and not remaining_non_safe_targets
126+ and _positive_target_total (adjusted_targets ) <= max (0.0 , float (cash_substitute_limit_usd or 0.0 ))
127+ ):
128+ for symbol in safe_haven_symbols :
129+ if float (adjusted_targets .get (symbol , 0.0 ) or 0.0 ) > 0.0 :
130+ adjusted_targets [symbol ] = 0.0
131+ safe_haven_substituted .append (symbol )
132+ normalized_targets = {
133+ str (symbol or "" ).strip ().upper (): float (value or 0.0 )
134+ for symbol , value in dict (target_values or {}).items ()
135+ }
136+ normalized_prices = {
137+ str (symbol or "" ).strip ().upper (): float (price or 0.0 )
138+ for symbol , price in dict (prices or {}).items ()
139+ }
140+ notes = []
141+ if safe_haven_substituted :
142+ for symbol in substituted :
143+ target_value = max (0.0 , float (normalized_targets .get (symbol , 0.0 ) or 0.0 ))
144+ price = max (0.0 , float (normalized_prices .get (symbol , 0.0 ) or 0.0 ))
145+ if target_value <= 0.0 or price <= 0.0 :
146+ continue
147+ notes .append (
148+ {
149+ "symbol" : symbol ,
150+ "target_value" : target_value ,
151+ "price" : price ,
152+ "cash_symbols" : tuple (safe_haven_substituted ),
153+ }
154+ )
155+ return _SmallAccountCashCompatibilityResult (
156+ targets = adjusted_targets ,
157+ whole_share_substituted_symbols = substituted ,
158+ safe_haven_cash_substituted_symbols = tuple (safe_haven_substituted ),
159+ cash_substitution_notes = tuple (notes ),
160+ )
161+
162+ def format_small_account_cash_substitution_notes (
163+ notes ,
164+ * ,
165+ translator ,
166+ wrapper_key = "buy_deferred" ,
167+ detail_key = "buy_deferred_small_account_cash_substitution" ,
168+ cash_label_key = "cash_label" ,
169+ symbol_suffix = ".US" ,
170+ ):
171+ messages = []
172+ seen_keys = set ()
173+ for note in tuple (notes or ()):
174+ if not isinstance (note , Mapping ):
175+ continue
176+ symbol = str (note .get ("symbol" ) or "" ).strip ().upper ()
177+ if not symbol :
178+ continue
179+ target_value = max (0.0 , float (note .get ("target_value" ) or 0.0 ))
180+ price = max (0.0 , float (note .get ("price" ) or 0.0 ))
181+ if target_value <= 0.0 or price <= 0.0 :
182+ continue
183+ cash_symbols = tuple (
184+ dict .fromkeys (
185+ str (cash_symbol or "" ).strip ().upper ()
186+ for cash_symbol in tuple (note .get ("cash_symbols" ) or ())
187+ if str (cash_symbol or "" ).strip ()
188+ )
189+ )
190+ cash_symbols_text = ", " .join (f"{ cash_symbol } { symbol_suffix } " for cash_symbol in cash_symbols )
191+ if not cash_symbols_text :
192+ cash_symbols_text = translator (cash_label_key )
193+ note_key = (symbol , f"{ target_value :.2f} " , cash_symbols_text )
194+ if note_key in seen_keys :
195+ continue
196+ seen_keys .add (note_key )
197+ detail = translator (
198+ detail_key ,
199+ symbol = f"{ symbol } { symbol_suffix } " ,
200+ diff = f"{ target_value :.2f} " ,
201+ price = f"{ price :.2f} " ,
202+ cash_symbols = cash_symbols_text ,
203+ )
204+ messages .append (translator (wrapper_key , detail = detail ))
205+ return tuple (messages )
79206from quant_platform_kit .common .quantity import (
80207 floor_to_quantity_step ,
81208 format_quantity ,
@@ -663,6 +790,7 @@ def execute_rebalance(
663790 "safe_haven_cash_substituted_symbols" : [],
664791 "small_account_whole_share_substituted_symbols" : [],
665792 "small_account_safe_haven_cash_substituted_symbols" : [],
793+ "small_account_whole_share_cash_notes" : [],
666794 "residual_cash_estimate" : float (account_values .get ("buying_power" , 0.0 ) or 0.0 ),
667795 "current_stock_weight" : 0.0 ,
668796 "current_safe_haven_weight" : 0.0 ,
@@ -750,31 +878,23 @@ def execute_rebalance(
750878 for symbol in target_mv
751879 if str (symbol or "" ).strip ().upper () not in safe_haven_symbols
752880 )
753- target_mv , small_account_substituted_symbols = project_unbuyable_value_targets_to_cash (
881+ small_account_compatibility = apply_small_account_cash_compatibility (
754882 target_mv ,
755883 prices ,
756- symbols = small_account_candidate_symbols ,
884+ candidate_symbols = small_account_candidate_symbols ,
885+ safe_haven_cash_symbols = safe_haven_symbols ,
757886 quantity_step = order_quantity_step ,
887+ cash_substitute_limit_usd = SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD ,
758888 )
889+ target_mv = small_account_compatibility .targets
890+ small_account_substituted_symbols = small_account_compatibility .whole_share_substituted_symbols
759891 for symbol in small_account_substituted_symbols :
760892 target_weights [symbol ] = 0.0
761- remaining_non_safe_targets = [
762- symbol
763- for symbol in small_account_candidate_symbols
764- if float (target_mv .get (str (symbol or "" ).strip ().upper (), 0.0 ) or 0.0 ) > 0.0
765- ]
766- small_account_safe_haven_cash_substituted_symbols : list [str ] = []
767- if (
768- small_account_substituted_symbols
769- and not remaining_non_safe_targets
770- and _positive_target_total (target_mv ) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD
771- ):
772- for symbol in safe_haven_symbols :
773- normalized_symbol = str (symbol or "" ).strip ().upper ()
774- if float (target_mv .get (normalized_symbol , 0.0 ) or 0.0 ) > 0.0 :
775- target_mv [normalized_symbol ] = 0.0
776- target_weights [normalized_symbol ] = 0.0
777- small_account_safe_haven_cash_substituted_symbols .append (normalized_symbol )
893+ small_account_safe_haven_cash_substituted_symbols = list (
894+ small_account_compatibility .safe_haven_cash_substituted_symbols
895+ )
896+ for symbol in small_account_safe_haven_cash_substituted_symbols :
897+ target_weights [symbol ] = 0.0
778898 if safe_haven_symbols :
779899 execution_summary ["realized_safe_haven_weight" ] = float (
780900 sum (
@@ -788,7 +908,16 @@ def execute_rebalance(
788908 execution_summary ["small_account_safe_haven_cash_substituted_symbols" ] = (
789909 small_account_safe_haven_cash_substituted_symbols
790910 )
911+ execution_summary ["small_account_whole_share_cash_notes" ] = list (
912+ small_account_compatibility .cash_substitution_notes
913+ )
791914 trade_logs = []
915+ trade_logs .extend (
916+ format_small_account_cash_substitution_notes (
917+ small_account_compatibility .cash_substitution_notes ,
918+ translator = translator ,
919+ )
920+ )
792921 target_hash = _build_target_hash (target_weights )
793922 execution_summary ["target_vs_current" ] = _build_target_diff_rows (target_weights , current_mv , equity )
794923 if equity > 0 :
0 commit comments