22
33from __future__ import annotations
44
5+ import math
56import hashlib
67import json
78import tempfile
@@ -649,6 +650,37 @@ def execute_rebalance(
649650 insufficient_buying_power_symbols : list [str ] = []
650651 min_notional_symbols : list [str ] = []
651652 quantity_zero_symbols : list [str ] = []
653+ anticipated_buying_power = get_available_buying_power (
654+ ib ,
655+ account_values .get ("buying_power" , 0 ),
656+ account_ids = normalized_account_ids ,
657+ )
658+ cash_sweep_quantity = 0
659+ cash_sweep_price = float (prices .get (safe_haven_symbol , 0.0 ) or 0.0 ) if safe_haven_symbol else 0.0
660+ dry_run_sale_proceeds = 0.0
661+
662+ def cash_sweep_sale_quantity_to_fund_buy (max_quantity : int , candidate_symbols : tuple [str , ...]) -> int :
663+ if max_quantity <= 0 or not safe_haven_symbol or cash_sweep_price <= 0.0 :
664+ return 0
665+ base_buying_power = max (0.0 , float (anticipated_buying_power ))
666+ for symbol in candidate_symbols :
667+ underweight_value = target_mv [symbol ] - current_mv .get (symbol , 0.0 )
668+ if underweight_value <= threshold :
669+ continue
670+ ask = prices .get (symbol )
671+ if not ask or ask <= 0.0 :
672+ continue
673+ max_buy_quantity = int (underweight_value // ask )
674+ if max_buy_quantity <= 0 :
675+ continue
676+ required_buying_power = max_buy_quantity * ask * 1.0
677+ if base_buying_power >= required_buying_power :
678+ return 0
679+ return min (
680+ max_quantity ,
681+ max (1 , math .ceil ((required_buying_power - base_buying_power ) / cash_sweep_price )),
682+ )
683+ return 0
652684
653685 has_sell_plan = False
654686 for symbol in all_symbols :
@@ -672,11 +704,27 @@ def execute_rebalance(
672704 break
673705 quantity_zero_symbols .append (symbol )
674706
675- anticipated_buying_power = get_available_buying_power (
676- ib ,
677- account_values .get ("buying_power" , 0 ),
678- account_ids = normalized_account_ids ,
679- )
707+ funding_buy_candidates = [
708+ symbol
709+ for symbol in target_mv
710+ if symbol != safe_haven_symbol
711+ and (target_mv [symbol ] - current_mv .get (symbol , 0.0 )) > threshold
712+ and abs (target_mv [symbol ] - current_mv .get (symbol , 0.0 )) > current_min_trade
713+ ]
714+ if (
715+ not has_sell_plan
716+ and funding_buy_candidates
717+ and safe_haven_symbol
718+ and cash_sweep_price > 0.0
719+ and float (positions .get (safe_haven_symbol , {}).get ("quantity" , 0.0 ) or 0.0 ) > 0.0
720+ ):
721+ cash_sweep_quantity = cash_sweep_sale_quantity_to_fund_buy (
722+ int (float (positions .get (safe_haven_symbol , {}).get ("quantity" , 0.0 ) or 0.0 )),
723+ tuple (funding_buy_candidates ),
724+ )
725+ if cash_sweep_quantity > 0 :
726+ has_sell_plan = True
727+
680728 has_buy_plan = False
681729 for symbol , target in target_mv .items ():
682730 current = current_mv .get (symbol , 0.0 )
@@ -813,8 +861,24 @@ def execute_rebalance(
813861 for symbol in all_symbols :
814862 current = current_mv .get (symbol , 0 )
815863 target = target_mv .get (symbol , 0 )
816- if current > target + threshold :
817- price = prices .get (symbol )
864+ price = prices .get (symbol )
865+ if symbol == safe_haven_symbol and cash_sweep_quantity > 0 :
866+ if not price :
867+ execution_summary ["orders_skipped" ].append ({"symbol" : symbol , "side" : "sell" , "reason" : "missing_price" })
868+ execution_summary ["skipped_reasons" ].append (f"missing_price:{ symbol } " )
869+ continue
870+ regular_qty = _sell_order_quantity (
871+ current_value = current ,
872+ target_value = target ,
873+ price = price ,
874+ position_quantity = positions .get (symbol , {}).get ("quantity" , 0 ),
875+ quantity_step = order_quantity_step ,
876+ )
877+ qty = max (int (cash_sweep_quantity ), int (regular_qty ))
878+ if qty <= 0 :
879+ execution_summary ["orders_skipped" ].append ({"symbol" : symbol , "side" : "sell" , "reason" : "quantity_zero" })
880+ continue
881+ elif current > target + threshold :
818882 if not price :
819883 execution_summary ["orders_skipped" ].append ({"symbol" : symbol , "side" : "sell" , "reason" : "missing_price" })
820884 execution_summary ["skipped_reasons" ].append (f"missing_price:{ symbol } " )
@@ -829,52 +893,59 @@ def execute_rebalance(
829893 if qty <= 0 :
830894 execution_summary ["orders_skipped" ].append ({"symbol" : symbol , "side" : "sell" , "reason" : "quantity_zero" })
831895 continue
896+ else :
897+ continue
832898
833- if dry_run_only :
834- execution_summary ["orders_submitted" ].append (
835- {"symbol" : symbol , "side" : "sell" , "quantity" : qty , "status" : "dry_run" }
836- )
837- trade_logs .append (f"DRY_RUN sell { symbol } { format_quantity (qty )} " )
838- continue
839- report = submit_order_intent (
899+ if dry_run_only :
900+ execution_summary ["orders_submitted" ].append (
901+ {"symbol" : symbol , "side" : "sell" , "quantity" : qty , "status" : "dry_run" }
902+ )
903+ trade_logs .append (f"DRY_RUN sell { symbol } { format_quantity (qty )} " )
904+ dry_run_sale_proceeds += float (qty ) * float (price )
905+ continue
906+ report = submit_order_intent (
907+ ib ,
908+ order_intent_cls (
909+ symbol = symbol ,
910+ side = "sell" ,
911+ quantity = qty ,
912+ account_id = order_account_id ,
913+ ),
914+ )
915+ ok , status_msg = check_order_submitted (report , translator = translator )
916+ status = str (getattr (report , "status" , "" ) or "" )
917+ order_payload = {
918+ "symbol" : symbol ,
919+ "side" : "sell" ,
920+ "quantity" : qty ,
921+ "status" : status ,
922+ "broker_order_id" : getattr (report , "broker_order_id" , None ),
923+ }
924+ if status == "Filled" :
925+ execution_summary ["orders_filled" ].append (order_payload )
926+ elif status in {"PartiallyFilled" , "Partial" }:
927+ execution_summary ["orders_partially_filled" ].append (order_payload )
928+ elif ok :
929+ execution_summary ["orders_submitted" ].append (order_payload )
930+ else :
931+ execution_summary ["orders_skipped" ].append ({** order_payload , "reason" : status or "submit_failed" })
932+ execution_summary ["skipped_reasons" ].append (f"submit_failed:{ symbol } :{ status or 'unknown' } " )
933+ trade_logs .append (translator ("market_sell" , symbol = symbol , qty = format_quantity (qty )) + f" { status_msg } " )
934+ if ok :
935+ sell_executed = True
936+
937+ if dry_run_only :
938+ buying_power = max (0.0 , anticipated_buying_power + dry_run_sale_proceeds )
939+ else :
940+ if sell_executed :
941+ time .sleep (sell_settle_delay_sec )
942+ buying_power = get_available_buying_power (
840943 ib ,
841- order_intent_cls (
842- symbol = symbol ,
843- side = "sell" ,
844- quantity = qty ,
845- account_id = order_account_id ,
846- ),
944+ account_values .get ("buying_power" , 0 ),
945+ account_ids = normalized_account_ids ,
847946 )
848- ok , status_msg = check_order_submitted (report , translator = translator )
849- status = str (getattr (report , "status" , "" ) or "" )
850- order_payload = {
851- "symbol" : symbol ,
852- "side" : "sell" ,
853- "quantity" : qty ,
854- "status" : status ,
855- "broker_order_id" : getattr (report , "broker_order_id" , None ),
856- }
857- if status == "Filled" :
858- execution_summary ["orders_filled" ].append (order_payload )
859- elif status in {"PartiallyFilled" , "Partial" }:
860- execution_summary ["orders_partially_filled" ].append (order_payload )
861- elif ok :
862- execution_summary ["orders_submitted" ].append (order_payload )
863- else :
864- execution_summary ["orders_skipped" ].append ({** order_payload , "reason" : status or "submit_failed" })
865- execution_summary ["skipped_reasons" ].append (f"submit_failed:{ symbol } :{ status or 'unknown' } " )
866- trade_logs .append (translator ("market_sell" , symbol = symbol , qty = format_quantity (qty )) + f" { status_msg } " )
867- if ok :
868- sell_executed = True
869-
870- if sell_executed :
871- time .sleep (sell_settle_delay_sec )
872-
873- buying_power = anticipated_buying_power if not sell_executed else get_available_buying_power (
874- ib ,
875- account_values .get ("buying_power" , 0 ),
876- account_ids = normalized_account_ids ,
877- )
947+ else :
948+ buying_power = anticipated_buying_power
878949
879950 for symbol , target in target_mv .items ():
880951 current = current_mv .get (symbol , 0 )
0 commit comments