@@ -423,6 +423,100 @@ def _should_bootstrap_whole_share_buy(symbol, *, target_value, limit_price) -> b
423423 return max (0.0 , float (target_value or 0.0 )) >= effective_limit_price * float (min_target_share_ratio )
424424
425425
426+ def _should_top_up_existing_whole_share_buy (
427+ symbol ,
428+ * ,
429+ target_value ,
430+ current_value ,
431+ quantity = 0.0 ,
432+ limit_price ,
433+ quantity_step : float = 1.0 ,
434+ ) -> bool :
435+ del symbol
436+ if abs (float (quantity_step or 0.0 ) - 1.0 ) > 1e-9 :
437+ return False
438+ held_quantity = max (0.0 , float (quantity or 0.0 ))
439+ effective_limit_price = max (0.0 , float (limit_price or 0.0 ))
440+ if held_quantity < 1.0 or effective_limit_price <= 0.0 :
441+ return False
442+ remaining_value = max (0.0 , float (target_value or 0.0 ) - float (current_value or 0.0 ))
443+ if remaining_value <= 0.0 or remaining_value >= effective_limit_price :
444+ return False
445+ held_whole_shares = int (held_quantity )
446+ if held_whole_shares <= 0 :
447+ return False
448+ target_quantity = max (0.0 , float (target_value or 0.0 )) / effective_limit_price
449+ return target_quantity >= held_whole_shares + 0.5
450+
451+
452+ def _planned_whole_share_buy_quantity (
453+ symbol ,
454+ * ,
455+ target_value ,
456+ current_value ,
457+ quantity = 0.0 ,
458+ buy_budget ,
459+ available_buying_power ,
460+ ref_price ,
461+ quantity_step : float = 1.0 ,
462+ allow_top_up = False ,
463+ ):
464+ effective_ref_price = max (0.0 , float (ref_price or 0.0 ))
465+ if effective_ref_price <= 0.0 :
466+ return 0
467+ planned_quantity = _normalize_buy_quantity (
468+ max (0.0 , float (buy_budget or 0.0 )) / effective_ref_price ,
469+ quantity_step = quantity_step ,
470+ )
471+ if planned_quantity > 0 :
472+ return planned_quantity
473+ if not allow_top_up :
474+ return 0
475+ if max (0.0 , float (available_buying_power or 0.0 )) < effective_ref_price :
476+ return 0
477+ if _should_top_up_existing_whole_share_buy (
478+ symbol ,
479+ target_value = target_value ,
480+ current_value = current_value ,
481+ quantity = quantity ,
482+ limit_price = effective_ref_price ,
483+ quantity_step = quantity_step ,
484+ ):
485+ return _normalize_buy_quantity (1.0 , quantity_step = quantity_step )
486+ return 0
487+
488+
489+ def _filled_sell_release_value (
490+ * ,
491+ trade_context ,
492+ submitted_sell_orders ,
493+ fetch_order_status ,
494+ ) -> float :
495+ if fetch_order_status is None :
496+ return 0.0
497+ released_value = 0.0
498+ for order in tuple (submitted_sell_orders or ()):
499+ broker_order_id = str ((order or {}).get ("broker_order_id" ) or "" ).strip ()
500+ if not broker_order_id :
501+ continue
502+ try :
503+ status_payload = fetch_order_status (trade_context , broker_order_id )
504+ except Exception :
505+ continue
506+ if not isinstance (status_payload , Mapping ):
507+ continue
508+ status = str (status_payload .get ("status" ) or "" ).strip ()
509+ try :
510+ executed_qty = max (0.0 , float (status_payload .get ("executed_qty" ) or 0.0 ))
511+ executed_price = max (0.0 , float (status_payload .get ("executed_price" ) or 0.0 ))
512+ except (TypeError , ValueError ):
513+ continue
514+ if status not in {"Filled" , "PartiallyFilled" , "Partial" } and executed_qty <= 0.0 :
515+ continue
516+ released_value += executed_qty * executed_price
517+ return released_value
518+
519+
426520def _normalize_cash_by_currency (raw_cash ) -> dict [str , float ]:
427521 if not isinstance (raw_cash , Mapping ):
428522 return {}
@@ -864,6 +958,7 @@ def execute_rebalance_cycle(
864958 market_data_port ,
865959 estimate_max_purchase_quantity ,
866960 execution_port ,
961+ fetch_order_status = None ,
867962 post_submit_order = None ,
868963 notify_issue ,
869964 translator ,
@@ -889,6 +984,7 @@ def execute_rebalance_cycle(
889984 note_logs : list [str ] = []
890985 submitted_orders : list [dict ] = []
891986 dry_run_orders : list [dict ] = []
987+ submitted_sell_orders : list [dict [str , Any ]] = []
892988 quote_snapshots_by_symbol : dict [str , dict ] = {}
893989 small_account_cash_note_keys : set [str ] = set ()
894990 small_account_bootstrap_note_keys : set [str ] = set ()
@@ -1080,6 +1176,8 @@ def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, su
10801176 if report .broker_order_id :
10811177 order_payload ["broker_order_id" ] = report .broker_order_id
10821178 submitted_orders .append (order_payload )
1179+ if str (side or "" ).strip ().lower () == "sell" :
1180+ submitted_sell_orders .append (order_payload )
10831181 if post_submit_order is not None :
10841182 try :
10851183 post_submit_order (trade_context , order_intent , report )
@@ -1309,12 +1407,21 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13091407 refresh_interval = max (0.0 , float (post_sell_refresh_interval_sec or 0.0 ))
13101408 best_refreshed_state = None
13111409 best_investable_cash = previous_investable_cash
1410+ projected_sell_release_value = 0.0
13121411 for attempt in range (refresh_attempts ):
13131412 if attempt > 0 :
13141413 sleeper (refresh_interval )
13151414 refreshed_state = fetch_replanned_state ()
13161415 refreshed_execution = refreshed_state [2 ]
13171416 refreshed_investable_cash = float (refreshed_execution ["investable_cash" ])
1417+ projected_sell_release_value = max (
1418+ projected_sell_release_value ,
1419+ _filled_sell_release_value (
1420+ trade_context = trade_context ,
1421+ submitted_sell_orders = submitted_sell_orders ,
1422+ fetch_order_status = fetch_order_status ,
1423+ ),
1424+ )
13181425 if best_refreshed_state is None or refreshed_investable_cash > best_investable_cash :
13191426 best_refreshed_state = refreshed_state
13201427 best_investable_cash = refreshed_investable_cash
@@ -1370,7 +1477,10 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13701477 target_values = dict (allocation ["targets" ])
13711478 available_cash = float (portfolio ["liquid_cash" ])
13721479 cash_by_currency = _normalize_cash_by_currency (portfolio .get ("cash_by_currency" ))
1373- investable_cash = float (execution ["investable_cash" ])
1480+ investable_cash = max (
1481+ float (execution ["investable_cash" ]),
1482+ previous_investable_cash + projected_sell_release_value ,
1483+ )
13741484 if fractional_buy_execution :
13751485 current_min_trade = max (float (execution ["current_min_trade" ]), MIN_FRACTIONAL_BUY_NOTIONAL_USD )
13761486 else :
@@ -1415,14 +1525,25 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
14151525 if can_buy_value >= MIN_FRACTIONAL_BUY_NOTIONAL_USD :
14161526 estimated_buy_cost += can_buy_value
14171527 continue
1418- if can_buy_value <= price :
1419- continue
1420- limit_price = _limit_buy_price (
1421- symbol , price , limit_buy_premium , limit_buy_premium_by_symbol
1528+ is_limit_order = symbol in limit_order_symbols or symbol == cash_sweep_symbol
1529+ ref_price = (
1530+ _limit_buy_price (symbol , price , limit_buy_premium , limit_buy_premium_by_symbol )
1531+ if is_limit_order
1532+ else round (price , 2 )
1533+ )
1534+ quantity = _planned_whole_share_buy_quantity (
1535+ symbol ,
1536+ target_value = target_values [symbol ],
1537+ current_value = market_values [symbol ],
1538+ quantity = quantities .get (symbol , 0.0 ),
1539+ buy_budget = can_buy_value ,
1540+ available_buying_power = investable_cash ,
1541+ ref_price = ref_price ,
1542+ quantity_step = _buy_step_for (market_symbol (symbol )),
1543+ allow_top_up = sell_submitted ,
14221544 )
1423- quantity = int (can_buy_value // limit_price ) if limit_price > 0 else 0
14241545 if quantity > 0 :
1425- estimated_buy_cost += quantity * limit_price
1546+ estimated_buy_cost += quantity * ref_price
14261547 if estimated_buy_cost > investable_cash :
14271548 buys_blocked_reason = "pending_sell_release"
14281549 message = translator (
@@ -1455,29 +1576,45 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
14551576 if price is None :
14561577 continue
14571578 can_buy_value = min (diff , investable_cash )
1579+ planned_quantity = 0
1580+ effective_can_buy_value = can_buy_value
1581+ is_limit_order = (
1582+ False
1583+ if fractional_buy_execution
1584+ else (symbol in limit_order_symbols or symbol == cash_sweep_symbol )
1585+ )
1586+ ref_price = (
1587+ _limit_buy_price (symbol , price , limit_buy_premium , limit_buy_premium_by_symbol )
1588+ if is_limit_order
1589+ else round (price , 2 )
1590+ )
1591+ if not fractional_buy_execution :
1592+ planned_quantity = _planned_whole_share_buy_quantity (
1593+ symbol ,
1594+ target_value = target_values [symbol ],
1595+ current_value = market_values [symbol ],
1596+ quantity = quantities .get (symbol , 0.0 ),
1597+ buy_budget = can_buy_value ,
1598+ available_buying_power = investable_cash ,
1599+ ref_price = ref_price ,
1600+ quantity_step = _buy_step_for (market_symbol (symbol )),
1601+ allow_top_up = sell_submitted ,
1602+ )
1603+ if planned_quantity > 0 :
1604+ effective_can_buy_value = max (can_buy_value , planned_quantity * ref_price )
14581605 can_afford_buy = (
14591606 can_buy_value >= MIN_FRACTIONAL_BUY_NOTIONAL_USD
14601607 if fractional_buy_execution
1461- else can_buy_value > price
1608+ else planned_quantity > 0
14621609 )
14631610 if can_afford_buy :
1464- is_limit_order = (
1465- False
1466- if fractional_buy_execution
1467- else (symbol in limit_order_symbols or symbol == cash_sweep_symbol )
1468- )
14691611 limit_order_kind = "limit" if is_limit_order else "market"
1470- limit_ref_price = (
1471- _limit_buy_price (symbol , price , limit_buy_premium , limit_buy_premium_by_symbol )
1472- if is_limit_order
1473- else round (price , 2 )
1474- )
14751612 limit_candidate = _estimate_buy_quantity_candidate (
14761613 trade_context ,
14771614 market_symbol (symbol ),
14781615 limit_order_kind ,
1479- limit_ref_price ,
1480- can_buy_value = can_buy_value ,
1616+ ref_price ,
1617+ can_buy_value = effective_can_buy_value ,
14811618 estimate_max_purchase_quantity = estimate_max_purchase_quantity ,
14821619 notify_issue = notify_issue ,
14831620 dry_run_only = dry_run_only ,
@@ -1492,7 +1629,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
14921629 quantity_step = _buy_step_for (market_symbol (symbol )),
14931630 )
14941631 order_kind = limit_order_kind
1495- ref_price = limit_ref_price
14961632 quantity = limit_quantity
14971633 cost_estimate = 0.0
14981634 if quantity <= 0 :
0 commit comments