BotDeepSeek/orders.py
2026-08-12 01:46:19 -07:00

137 lines
4.3 KiB
Python

import asyncio
import logging
from typing import Optional
from ib_insync import IB, Contract, MarketOrder, Trade
from config import config
logger = logging.getLogger(__name__)
def create_stock_contract(symbol: str, exchange: str = "SMART", currency: str = "USD") -> Contract:
return Contract(symbol=symbol, secType="STK", exchange=exchange, currency=currency)
def trade_commission(trade: Optional[Trade]) -> float:
"""Total commission reported for a trade's fills, 0.0 if none yet.
Best effort: IB delivers commissionReport asynchronously, sometimes a moment
after the fill, so a freshly filled trade may still report 0. The ledger
treats a missing commission as unknown rather than as free, so an occasional
miss understates costs rather than corrupting anything.
"""
if trade is None:
return 0.0
total = 0.0
for f in getattr(trade, "fills", []) or []:
report = getattr(f, "commissionReport", None)
total += float(getattr(report, "commission", 0) or 0)
return total
def has_open_order(ib: IB, order_ref: str) -> bool:
"""True if an unfinished order with this orderRef already exists."""
for trade in ib.openTrades():
if (trade.order.orderRef == order_ref and not trade.isDone()
and trade.orderStatus.remaining > 0):
return True
return False
async def wait_trade_done(trade: Trade, timeout: float) -> bool:
"""Event-driven wait for a trade to reach a terminal state.
Waits on trade.statusEvent (which fires on every status update) instead of
polling, returning as soon as isDone() becomes true or the timeout elapses.
"""
done = asyncio.Event()
def _on_status(_):
if trade.isDone():
done.set()
trade.statusEvent.connect(_on_status)
try:
if trade.isDone():
return True
try:
await asyncio.wait_for(done.wait(), timeout)
except asyncio.TimeoutError:
return False
return trade.isDone()
finally:
trade.statusEvent.disconnect(_on_status)
async def execute_market_order(
ib: IB,
contract: Contract,
action: str,
quantity: float,
order_ref: str,
timeout: Optional[float] = None,
) -> Optional[Trade]:
"""Place a market order and wait for the fill.
Returns the Trade if (partially) filled, None on rejection/timeout.
On timeout the order is cancelled before giving up.
"""
if has_open_order(ib, order_ref):
logger.warning(
"Skip %s %s x%g: an open order already exists (ref=%s)",
action, contract.symbol, quantity, order_ref,
)
return None
order = MarketOrder(action, quantity)
if config.ib.account:
order.account = config.ib.account
order.orderRef = order_ref
order.tif = "DAY"
order.outsideRth = False
trade = ib.placeOrder(contract, order)
logger.info(
"Placed %s market order: %s x%g (ref=%s, orderId=%s)",
action, contract.symbol, quantity, order_ref, trade.order.orderId,
)
timeout = timeout or config.order_timeout
if not await wait_trade_done(trade, timeout):
logger.warning(
"%s %s x%g not filled within %.0fs, cancelling...",
action, contract.symbol, quantity, timeout,
)
ib.cancelOrder(order)
await wait_trade_done(trade, 2.0) # wait for the cancel to confirm
if trade.isDone():
status = trade.orderStatus.status
if status == "Filled":
logger.info(
"%s %s x%g FILLED @ %.4f",
action, contract.symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
)
return trade
logger.error(
"%s %s x%g finished with status=%s: %s",
action, contract.symbol, quantity, status,
[m.message for m in trade.log] if trade.log else "",
)
return None
if trade.orderStatus.filled > 0:
logger.info(
"%s %s partially filled x%g @ %.4f before cancel",
action, contract.symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
)
return trade
logger.error(
"%s %s x%g cancelled/unfilled (status=%s): %s",
action, contract.symbol, quantity, trade.orderStatus.status,
[m.message for m in trade.log] if trade.log else "",
)
return None