1010try :
1111 from quant_platform_kit .common .small_account_compatibility import (
1212 apply_small_account_cash_compatibility ,
13+ build_small_account_allocation_drift_notes ,
1314 )
1415except ImportError : # pragma: no cover - compatibility with older pinned shared wheels
1516 @dataclass (frozen = True )
@@ -123,6 +124,9 @@ def apply_small_account_cash_compatibility(
123124 cash_substitution_notes = tuple (notes ),
124125 )
125126
127+ def build_small_account_allocation_drift_notes (** _kwargs ):
128+ return ()
129+
126130@dataclass (frozen = True )
127131class ExecutionCycleResult :
128132 submitted_orders : tuple [dict [str , Any ], ...]
@@ -134,6 +138,39 @@ class ExecutionCycleResult:
134138DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
135139SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
136140SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset ({"TQQQ" , "SOXL" })
141+ SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
142+ "SOXX" : 0.90 ,
143+ }
144+ SMALL_ACCOUNT_WHOLE_SHARE_BOOTSTRAP_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
145+ "TQQQ" : 0.90 ,
146+ "SOXL" : 0.90 ,
147+ "SOXX" : 0.90 ,
148+ }
149+
150+
151+ def _limit_buy_premium_for_symbol (symbol , default_premium , premium_by_symbol = None ) -> float :
152+ normalized_symbol = str (symbol or "" ).strip ().upper ()
153+ try :
154+ fallback = float (default_premium )
155+ except (TypeError , ValueError ):
156+ fallback = 1.005
157+ if not isinstance (premium_by_symbol , dict ):
158+ return fallback
159+ raw_value = premium_by_symbol .get (normalized_symbol )
160+ if raw_value is None :
161+ return fallback
162+ try :
163+ premium = float (raw_value )
164+ except (TypeError , ValueError ):
165+ return fallback
166+ return premium if premium > 0.0 else fallback
167+
168+
169+ def _limit_buy_price (symbol , price , default_premium , premium_by_symbol = None ) -> float :
170+ return round (
171+ float (price ) * _limit_buy_premium_for_symbol (symbol , default_premium , premium_by_symbol ),
172+ 2 ,
173+ )
137174
138175
139176def _floor_quantity (quantity : float ) -> int :
@@ -171,6 +208,30 @@ def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str,
171208 return tuple (dict .fromkeys (symbols ))
172209
173210
211+ def _small_account_drift_reference_targets (
212+ allocation : dict [str , Any ],
213+ * ,
214+ portfolio : dict [str , Any ] | None = None ,
215+ ) -> dict [str , float ]:
216+ allocation = dict (allocation or {})
217+ targets = {
218+ str (symbol or "" ).strip ().upper (): float (value or 0.0 )
219+ for symbol , value in dict (allocation .get ("targets" ) or {}).items ()
220+ }
221+ candidate_symbols = tuple (
222+ dict .fromkeys (
223+ str (symbol or "" ).strip ().upper ()
224+ for symbol in tuple (allocation .get ("risk_symbols" , ()))
225+ + tuple (allocation .get ("income_symbols" , ()))
226+ if str (symbol or "" ).strip ()
227+ )
228+ )
229+ if not candidate_symbols :
230+ safe_haven_symbols = set (_safe_haven_cash_symbols (portfolio = dict (portfolio or {}), allocation = allocation ))
231+ candidate_symbols = tuple (symbol for symbol in targets if symbol not in safe_haven_symbols )
232+ return {symbol : targets .get (symbol , 0.0 ) for symbol in candidate_symbols if symbol in targets }
233+
234+
174235def _positive_target_total (targets : dict [str , Any ]) -> float :
175236 total = 0.0
176237 for value in dict (targets or {}).values ():
@@ -210,6 +271,35 @@ def substitute_small_safe_haven_targets_with_cash(
210271 return adjusted_plan
211272
212273
274+ def _should_retain_existing_whole_share (symbol , * , target_value , price ) -> bool :
275+ normalized_symbol = str (symbol or "" ).strip ().upper ()
276+ if normalized_symbol in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS :
277+ return True
278+
279+ min_target_share_ratio = (
280+ SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL .get (normalized_symbol )
281+ )
282+ if min_target_share_ratio is None :
283+ return False
284+ quote_price = max (0.0 , float (price or 0.0 ))
285+ if quote_price <= 0.0 :
286+ return False
287+ return max (0.0 , float (target_value or 0.0 )) >= quote_price * float (min_target_share_ratio )
288+
289+
290+ def _should_bootstrap_whole_share_buy (symbol , * , target_value , limit_price ) -> bool :
291+ normalized_symbol = str (symbol or "" ).strip ().upper ()
292+ min_target_share_ratio = (
293+ SMALL_ACCOUNT_WHOLE_SHARE_BOOTSTRAP_MIN_TARGET_SHARE_RATIO_BY_SYMBOL .get (normalized_symbol )
294+ )
295+ if min_target_share_ratio is None :
296+ return False
297+ effective_limit_price = max (0.0 , float (limit_price or 0.0 ))
298+ if effective_limit_price <= 0.0 :
299+ return False
300+ return max (0.0 , float (target_value or 0.0 )) >= effective_limit_price * float (min_target_share_ratio )
301+
302+
213303def _quote_price (market_data_port : MarketDataPort , symbol : str ) -> float | None :
214304 try :
215305 price = float (market_data_port .get_quote (symbol ).last_price )
@@ -222,6 +312,8 @@ def _apply_small_account_whole_share_compatibility(
222312 plan : dict [str , Any ],
223313 * ,
224314 market_data_port : MarketDataPort ,
315+ limit_buy_premium : float = 1.005 ,
316+ limit_buy_premium_by_symbol : dict [str , float ] | None = None ,
225317) -> dict [str , Any ]:
226318 adjusted_plan = dict (plan or {})
227319 allocation = dict (adjusted_plan .get ("allocation" ) or {})
@@ -248,6 +340,7 @@ def _apply_small_account_whole_share_compatibility(
248340 if price is not None :
249341 prices [str (symbol ).strip ().upper ()] = price
250342 retained_symbols = []
343+ bootstrap_symbols = []
251344 quantities = {
252345 str (symbol or "" ).strip ().upper (): float (quantity or 0.0 )
253346 for symbol , quantity in dict (portfolio .get ("quantities" ) or {}).items ()
@@ -257,13 +350,33 @@ def _apply_small_account_whole_share_compatibility(
257350 for symbol , value in targets .items ()
258351 }
259352 for symbol in candidate_symbols :
260- if symbol not in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS :
261- continue
262353 target_value = max (0.0 , float (compatibility_targets .get (symbol , 0.0 ) or 0.0 ))
263354 price = max (0.0 , float (prices .get (symbol , 0.0 ) or 0.0 ))
355+ limit_price = (
356+ _limit_buy_price (symbol , price , limit_buy_premium , limit_buy_premium_by_symbol )
357+ if price > 0.0
358+ else 0.0
359+ )
360+ if not _should_retain_existing_whole_share (symbol , target_value = target_value , price = price ):
361+ if (
362+ quantities .get (symbol , 0.0 ) <= 0.0
363+ and 0.0 < target_value < limit_price
364+ and _should_bootstrap_whole_share_buy (symbol , target_value = target_value , limit_price = limit_price )
365+ ):
366+ compatibility_targets [symbol ] = limit_price
367+ bootstrap_symbols .append (symbol )
368+ continue
264369 if price > 0.0 and 0.0 < target_value < price and quantities .get (symbol , 0.0 ) >= 1.0 :
265370 compatibility_targets [symbol ] = price
266371 retained_symbols .append (symbol )
372+ continue
373+ if (
374+ quantities .get (symbol , 0.0 ) <= 0.0
375+ and 0.0 < target_value < limit_price
376+ and _should_bootstrap_whole_share_buy (symbol , target_value = target_value , limit_price = limit_price )
377+ ):
378+ compatibility_targets [symbol ] = limit_price
379+ bootstrap_symbols .append (symbol )
267380 safe_haven_symbols = _safe_haven_cash_symbols (portfolio = portfolio , allocation = allocation )
268381 compatibility = apply_small_account_cash_compatibility (
269382 compatibility_targets ,
@@ -285,6 +398,10 @@ def _apply_small_account_whole_share_compatibility(
285398 allocation ["small_account_existing_whole_share_retained_symbols" ] = tuple (
286399 dict .fromkeys (retained_symbols )
287400 )
401+ if bootstrap_symbols :
402+ allocation ["small_account_whole_share_bootstrap_symbols" ] = tuple (
403+ dict .fromkeys (bootstrap_symbols )
404+ )
288405 if compatibility .cash_substitution_notes :
289406 allocation ["small_account_whole_share_cash_notes" ] = tuple (compatibility .cash_substitution_notes )
290407 adjusted_plan ["allocation" ] = allocation
@@ -334,6 +451,7 @@ def execute_value_target_plan(
334451 dry_run_only : bool ,
335452 limit_sell_discount : float = 0.995 ,
336453 limit_buy_premium : float = 1.005 ,
454+ limit_buy_premium_by_symbol : dict [str , float ] | None = None ,
337455 max_order_notional_usd : float | None = None ,
338456 safe_haven_cash_substitute_threshold_usd : float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD ,
339457) -> ExecutionCycleResult :
@@ -342,9 +460,16 @@ def execute_value_target_plan(
342460 plan ,
343461 threshold_usd = safe_haven_cash_substitute_threshold_usd ,
344462 )
463+ plan_portfolio = dict (plan .get ("portfolio" ) or {})
464+ small_account_reference_target_values = _small_account_drift_reference_targets (
465+ dict (plan .get ("allocation" ) or {}),
466+ portfolio = plan_portfolio ,
467+ )
345468 plan = _apply_small_account_whole_share_compatibility (
346469 plan ,
347470 market_data_port = market_data_port ,
471+ limit_buy_premium = limit_buy_premium ,
472+ limit_buy_premium_by_symbol = limit_buy_premium_by_symbol ,
348473 )
349474 allocation = dict (plan .get ("allocation" ) or {})
350475 portfolio = dict (plan .get ("portfolio" ) or {})
@@ -359,6 +484,10 @@ def execute_value_target_plan(
359484 str (k ).upper (): float (v or 0.0 )
360485 for k , v in dict (portfolio .get ("sellable_quantities" ) or {}).items ()
361486 }
487+ current_quantities = {
488+ str (k ).upper (): float (v or 0.0 )
489+ for k , v in dict (portfolio .get ("quantities" ) or sellable_quantities ).items ()
490+ }
362491 threshold = float (
363492 execution .get ("current_min_trade" )
364493 or execution .get ("trade_threshold_value" )
@@ -376,6 +505,7 @@ def execute_value_target_plan(
376505
377506 submitted : list [dict [str , Any ]] = []
378507 skipped : list [dict [str , Any ]] = []
508+ reference_prices : dict [str , float ] = {}
379509
380510 tradable_deltas : list [tuple [str , float , float ]] = []
381511 for symbol in sorted (set (targets ) | set (market_values )):
@@ -395,6 +525,7 @@ def execute_value_target_plan(
395525 if price is None :
396526 skipped .append ({"symbol" : symbol , "reason" : "quote_unavailable" })
397527 continue
528+ reference_prices [symbol ] = price
398529 tradable_deltas .append ((symbol , delta_value , price ))
399530
400531 for symbol , delta_value , price in [item for item in tradable_deltas if item [1 ] < 0 ]:
@@ -437,16 +568,17 @@ def execute_value_target_plan(
437568 buy_budget = min (float (delta_value ), investable_cash )
438569 if order_notional_cap is not None :
439570 buy_budget = min (buy_budget , order_notional_cap )
440- quantity = _floor_quantity (buy_budget / price )
571+ limit_price = _limit_buy_price (symbol , price , limit_buy_premium , limit_buy_premium_by_symbol )
572+ quantity = _floor_quantity (buy_budget / limit_price ) if limit_price > 0 else 0
441573 if quantity <= 0 :
442- if order_notional_cap is None and investable_cash < price :
574+ if order_notional_cap is None and investable_cash < limit_price :
443575 skipped .append (
444576 {
445577 "symbol" : symbol ,
446578 "reason" : "insufficient_cash_for_whole_share" ,
447- "price" : round (price , 2 ),
579+ "price" : round (limit_price , 2 ),
448580 "investable_cash" : round (investable_cash , 2 ),
449- "required_cash_for_one_share" : round (price , 2 ),
581+ "required_cash_for_one_share" : round (limit_price , 2 ),
450582 }
451583 )
452584 else :
@@ -468,11 +600,23 @@ def execute_value_target_plan(
468600 symbol = symbol ,
469601 side = "buy" ,
470602 quantity = quantity ,
471- limit_price = price * float ( limit_buy_premium ) ,
603+ limit_price = limit_price ,
472604 max_notional_usd = max_order_notional_usd ,
473605 )
474606 )
475- investable_cash = max (0.0 , investable_cash - (quantity * price ))
607+ investable_cash = max (0.0 , investable_cash - (quantity * limit_price ))
608+
609+ total_value = float (portfolio .get ("total_equity" ) or portfolio .get ("total_strategy_equity" ) or 0.0 )
610+ drift_notes = build_small_account_allocation_drift_notes (
611+ target_values = small_account_reference_target_values ,
612+ current_values = market_values ,
613+ current_quantities = current_quantities ,
614+ prices = reference_prices ,
615+ submitted_orders = submitted ,
616+ total_value = total_value ,
617+ cash_value = float (portfolio .get ("liquid_cash" ) or 0.0 ),
618+ )
619+ execution_notes = tuple (execution_notes ) + tuple (drift_notes )
476620
477621 return ExecutionCycleResult (
478622 submitted_orders = tuple (submitted ),
0 commit comments