Skip to content

Commit bd5fb9d

Browse files
committed
Surface IBKR gateway handshake timeouts separately
1 parent e18622f commit bd5fb9d

4 files changed

Lines changed: 404 additions & 53 deletions

File tree

application/execution_service.py

Lines changed: 121 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import math
56
import hashlib
67
import json
78
import 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)

main.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,25 @@ def handle_request():
641641
result=cycle_result.result,
642642
)
643643
return cycle_result.result, 200
644+
except TimeoutError as exc:
645+
append_runtime_report_error(
646+
report,
647+
stage="ibkr_connect",
648+
message=str(exc),
649+
error_type=type(exc).__name__,
650+
)
651+
finalize_runtime_report(report, status="error")
652+
log_runtime_event(
653+
log_context,
654+
"ibkr_gateway_connect_timeout",
655+
message="IBKR gateway handshake timed out",
656+
severity="ERROR",
657+
error_type=type(exc).__name__,
658+
error_message=str(exc),
659+
)
660+
error_msg = f"🚨 【IBKR 连接异常】\n{str(exc)}"
661+
publish_notification(detailed_text=error_msg, compact_text=error_msg)
662+
return "Error", 500
644663
except Exception as exc:
645664
append_runtime_report_error(
646665
report,

0 commit comments

Comments
 (0)