@@ -139,6 +139,7 @@ class ExecutionCycleResult:
139139SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
140140MIN_NOTIONAL_BUY_USD = 1.0
141141SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset ({"TQQQ" , "SOXL" })
142+ _SMALL_ACCOUNT_RETENTION_MIN_TARGET_SHARE_RATIO_DEFAULT = 0.85
142143SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
143144 "SOXX" : 0.90 ,
144145}
@@ -272,20 +273,40 @@ def substitute_small_safe_haven_targets_with_cash(
272273 return adjusted_plan
273274
274275
275- def _should_retain_existing_whole_share (symbol , * , target_value , price ) -> bool :
276+ def _should_retain_existing_whole_share (symbol , * , target_value , price , quantity = 0.0 ) -> bool :
277+ """Decide whether an existing whole-share position should be retained.
278+
279+ Universal rule: if the account already holds this symbol (>0 shares) and the
280+ strategy wants to keep a meaningful fraction of a share (target >= 85% of
281+ 1-share price), retain the position. This prevents the sell-then-fail-to-rebuy
282+ cycle for small accounts where target < 1 share but still close to it.
283+
284+ Genuine reductions (target << 1 share) are NOT blocked.
285+ The hardcoded lists act as overrides for symbols that need a different threshold.
286+ """
276287 normalized_symbol = str (symbol or "" ).strip ().upper ()
288+ held = float (quantity or 0.0 )
289+ target = float (target_value or 0.0 )
290+ quote_price = max (0.0 , float (price or 0.0 ))
291+
292+ # Universal: held + positive target + target close to 1-share price -> retain
293+ if held > 0.0 and target > 0.0 and quote_price > 0.0 :
294+ if target >= quote_price * _SMALL_ACCOUNT_RETENTION_MIN_TARGET_SHARE_RATIO_DEFAULT :
295+ return True
296+
297+ # Legacy whitelist -- unconditional retention (safety net)
277298 if normalized_symbol in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS :
278299 return True
279300
301+ # Legacy per-symbol ratio-based retention (override / tighter threshold)
280302 min_target_share_ratio = (
281303 SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL .get (normalized_symbol )
282304 )
283305 if min_target_share_ratio is None :
284306 return False
285- quote_price = max (0.0 , float (price or 0.0 ))
286307 if quote_price <= 0.0 :
287308 return False
288- return max ( 0.0 , float ( target_value or 0.0 )) >= quote_price * float (min_target_share_ratio )
309+ return target >= quote_price * float (min_target_share_ratio )
289310
290311
291312def _should_bootstrap_whole_share_buy (symbol , * , target_value , limit_price ) -> bool :
@@ -364,7 +385,7 @@ def _apply_small_account_whole_share_compatibility(
364385 )
365386 # Skip bootstrap if the account cannot afford even 1 share at limit price.
366387 _can_afford_one_share = limit_price > 0.0 and _estimated_buying_power >= limit_price
367- if not _should_retain_existing_whole_share (symbol , target_value = target_value , price = price ):
388+ if not _should_retain_existing_whole_share (symbol , target_value = target_value , price = price , quantity = quantities . get ( symbol , 0.0 ) ):
368389 if (
369390 quantities .get (symbol , 0.0 ) <= 0.0
370391 and 0.0 < target_value < limit_price
0 commit comments