120 lines
3.7 KiB
Python
120 lines
3.7 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 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
|