@@ -261,6 +261,9 @@ class ExecutionCycleResult:
261261
262262DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
263263SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
264+ MIN_FRACTIONAL_BUY_NOTIONAL_USD = 1.0
265+ DEFAULT_BUY_QUANTITY_STEP = 1.0
266+ FRACTIONAL_BUY_QUANTITY_STEP = 0.0001
264267SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset ({"TQQQ" , "SOXL" })
265268SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
266269 "SOXX" : 0.90 ,
@@ -578,6 +581,13 @@ def _normalize_trade_quantity(quantity):
578581 return _floor_whole_share_quantity (raw_quantity )
579582
580583
584+ def _normalize_buy_quantity (quantity , * , quantity_step : float ):
585+ raw_quantity = max (0.0 , float (quantity or 0.0 ))
586+ if raw_quantity <= 0.0 :
587+ return 0
588+ return normalize_order_quantity (floor_to_quantity_step (raw_quantity , quantity_step ))
589+
590+
581591def _market_symbol (symbol , * , symbol_suffix = ".US" ):
582592 normalized = str (symbol or "" ).strip ().upper ()
583593 if not normalized :
@@ -773,13 +783,15 @@ def estimate_cash_buy_quantity_safe(
773783 * ,
774784 estimate_max_purchase_quantity ,
775785 notify_issue ,
786+ estimate_kwargs = None ,
776787):
777788 try :
778789 return estimate_max_purchase_quantity (
779790 trade_context ,
780791 symbol ,
781792 order_kind = order_kind ,
782793 ref_price = ref_price ,
794+ ** dict (estimate_kwargs or {}),
783795 )
784796 except Exception :
785797 notify_issue (
@@ -799,20 +811,24 @@ def _estimate_buy_quantity_candidate(
799811 estimate_max_purchase_quantity ,
800812 notify_issue ,
801813 dry_run_only = False ,
814+ quantity_step = DEFAULT_BUY_QUANTITY_STEP ,
815+ estimate_kwargs = None ,
802816):
803- budget_quantity = floor_to_quantity_step (can_buy_value / ref_price , 1.0 )
817+ budget_quantity = floor_to_quantity_step (can_buy_value / ref_price , quantity_step )
804818 cash_limit_quantity = estimate_cash_buy_quantity_safe (
805819 trade_context ,
806820 symbol ,
807821 order_kind ,
808822 ref_price ,
809823 estimate_max_purchase_quantity = estimate_max_purchase_quantity ,
810824 notify_issue = notify_issue ,
825+ estimate_kwargs = estimate_kwargs ,
811826 )
812827 if cash_limit_quantity is None :
813828 return None
814- candidate_quantity = _normalize_trade_quantity (
829+ candidate_quantity = _normalize_buy_quantity (
815830 min (budget_quantity , float (cash_limit_quantity )),
831+ quantity_step = quantity_step ,
816832 )
817833 return candidate_quantity , budget_quantity , float (cash_limit_quantity )
818834
@@ -843,6 +859,8 @@ def execute_rebalance_cycle(
843859 min_order_notional_usd = 0.0 ,
844860 safe_haven_cash_substitute_threshold_usd = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD ,
845861 cash_only_execution = True ,
862+ fractional_buy_execution : bool = False ,
863+ buy_quantity_step : float = DEFAULT_BUY_QUANTITY_STEP ,
846864) -> ExecutionCycleResult :
847865 logs : list [str ] = []
848866 skip_logs : list [str ] = []
@@ -887,16 +905,21 @@ def record_quote_snapshot(snapshot) -> None:
887905 portfolio = portfolio ,
888906 )
889907 cash_sweep_symbol = str (portfolio .get ("cash_sweep_symbol" ) or "" ).strip ().upper ()
890- plan , allocation = _apply_small_account_whole_share_compatibility (
891- plan = plan ,
892- allocation = allocation ,
893- strategy_assets = strategy_assets ,
894- market_data_port = market_data_port ,
895- notify_issue = notify_issue ,
896- symbol_suffix = symbol_suffix ,
897- limit_buy_premium = limit_buy_premium ,
898- limit_buy_premium_by_symbol = limit_buy_premium_by_symbol ,
908+ estimate_kwargs = {"fractional_shares" : True } if fractional_buy_execution else {}
909+ effective_buy_quantity_step = (
910+ float (buy_quantity_step ) if fractional_buy_execution else DEFAULT_BUY_QUANTITY_STEP
899911 )
912+ if not fractional_buy_execution :
913+ plan , allocation = _apply_small_account_whole_share_compatibility (
914+ plan = plan ,
915+ allocation = allocation ,
916+ strategy_assets = strategy_assets ,
917+ market_data_port = market_data_port ,
918+ notify_issue = notify_issue ,
919+ symbol_suffix = symbol_suffix ,
920+ limit_buy_premium = limit_buy_premium ,
921+ limit_buy_premium_by_symbol = limit_buy_premium_by_symbol ,
922+ )
900923 record_small_account_cash_substitution_notes (
901924 note_logs ,
902925 allocation = allocation ,
@@ -918,7 +941,10 @@ def record_quote_snapshot(snapshot) -> None:
918941 cash_by_currency = _normalize_cash_by_currency (portfolio .get ("cash_by_currency" ))
919942 investable_cash = float (execution ["investable_cash" ])
920943 min_order_notional = max (0.0 , float (min_order_notional_usd or 0.0 ))
921- current_min_trade = max (float (execution ["current_min_trade" ]), min_order_notional )
944+ if fractional_buy_execution :
945+ current_min_trade = max (float (execution ["current_min_trade" ]), MIN_FRACTIONAL_BUY_NOTIONAL_USD )
946+ else :
947+ current_min_trade = max (float (execution ["current_min_trade" ]), min_order_notional )
922948 dry_run_sale_proceeds = 0.0
923949 cash_sweep_sold_this_cycle = False
924950
@@ -932,10 +958,17 @@ def append_order_id_suffix(log_message, order_id):
932958 return f"{ log_message } { suffix } "
933959
934960 def submit_order_via_port (symbol , order_type , side , quantity , log_message , * , submitted_price = None ):
961+ if fractional_buy_execution and side == "buy" :
962+ normalized_quantity = _normalize_buy_quantity (
963+ quantity ,
964+ quantity_step = effective_buy_quantity_step ,
965+ )
966+ else :
967+ normalized_quantity = _floor_whole_share_quantity (quantity )
935968 order_intent = OrderIntent (
936969 symbol = symbol ,
937970 side = side ,
938- quantity = _floor_whole_share_quantity ( quantity ) ,
971+ quantity = normalized_quantity ,
939972 order_type = order_type ,
940973 limit_price = float (submitted_price ) if submitted_price is not None else None ,
941974 )
@@ -1240,16 +1273,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
12401273 allocation ,
12411274 portfolio = portfolio ,
12421275 )
1243- plan , allocation = _apply_small_account_whole_share_compatibility (
1244- plan = plan ,
1245- allocation = allocation ,
1246- strategy_assets = tuple (allocation ["strategy_symbols" ]),
1247- market_data_port = market_data_port ,
1248- notify_issue = notify_issue ,
1249- symbol_suffix = symbol_suffix ,
1250- limit_buy_premium = limit_buy_premium ,
1251- limit_buy_premium_by_symbol = limit_buy_premium_by_symbol ,
1252- )
1276+ if not fractional_buy_execution :
1277+ plan , allocation = _apply_small_account_whole_share_compatibility (
1278+ plan = plan ,
1279+ allocation = allocation ,
1280+ strategy_assets = tuple (allocation ["strategy_symbols" ]),
1281+ market_data_port = market_data_port ,
1282+ notify_issue = notify_issue ,
1283+ symbol_suffix = symbol_suffix ,
1284+ limit_buy_premium = limit_buy_premium ,
1285+ limit_buy_premium_by_symbol = limit_buy_premium_by_symbol ,
1286+ )
12531287 record_small_account_cash_substitution_notes (
12541288 note_logs ,
12551289 allocation = allocation ,
@@ -1278,7 +1312,10 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
12781312 available_cash = float (portfolio ["liquid_cash" ])
12791313 cash_by_currency = _normalize_cash_by_currency (portfolio .get ("cash_by_currency" ))
12801314 investable_cash = float (execution ["investable_cash" ])
1281- current_min_trade = max (float (execution ["current_min_trade" ]), min_order_notional )
1315+ if fractional_buy_execution :
1316+ current_min_trade = max (float (execution ["current_min_trade" ]), MIN_FRACTIONAL_BUY_NOTIONAL_USD )
1317+ else :
1318+ current_min_trade = max (float (execution ["current_min_trade" ]), min_order_notional )
12821319
12831320 if (
12841321 available_cash <= 0.0
@@ -1313,7 +1350,13 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13131350 notify_issue = notify_issue ,
13141351 quote_recorder = record_quote_snapshot ,
13151352 )
1316- if price is None or can_buy_value <= price :
1353+ if price is None :
1354+ continue
1355+ if fractional_buy_execution :
1356+ if can_buy_value >= MIN_FRACTIONAL_BUY_NOTIONAL_USD :
1357+ estimated_buy_cost += can_buy_value
1358+ continue
1359+ if can_buy_value <= price :
13171360 continue
13181361 limit_price = _limit_buy_price (
13191362 symbol , price , limit_buy_premium , limit_buy_premium_by_symbol
@@ -1353,8 +1396,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13531396 if price is None :
13541397 continue
13551398 can_buy_value = min (diff , investable_cash )
1356- if can_buy_value > price :
1357- is_limit_order = symbol in limit_order_symbols or symbol == cash_sweep_symbol
1399+ can_afford_buy = (
1400+ can_buy_value >= MIN_FRACTIONAL_BUY_NOTIONAL_USD
1401+ if fractional_buy_execution
1402+ else can_buy_value > price
1403+ )
1404+ if can_afford_buy :
1405+ is_limit_order = (
1406+ False
1407+ if fractional_buy_execution
1408+ else (symbol in limit_order_symbols or symbol == cash_sweep_symbol )
1409+ )
13581410 limit_order_kind = "limit" if is_limit_order else "market"
13591411 limit_ref_price = (
13601412 _limit_buy_price (symbol , price , limit_buy_premium , limit_buy_premium_by_symbol )
@@ -1370,11 +1422,16 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13701422 estimate_max_purchase_quantity = estimate_max_purchase_quantity ,
13711423 notify_issue = notify_issue ,
13721424 dry_run_only = dry_run_only ,
1425+ quantity_step = effective_buy_quantity_step ,
1426+ estimate_kwargs = estimate_kwargs ,
13731427 )
13741428 if limit_candidate is None :
13751429 continue
13761430 limit_candidate_quantity , limit_budget_quantity , limit_cash_limit_quantity = limit_candidate
1377- limit_quantity = _normalize_trade_quantity (limit_candidate_quantity )
1431+ limit_quantity = _normalize_buy_quantity (
1432+ limit_candidate_quantity ,
1433+ quantity_step = effective_buy_quantity_step ,
1434+ )
13781435 order_kind = limit_order_kind
13791436 ref_price = limit_ref_price
13801437 quantity = limit_quantity
0 commit comments