1031 lines
32 KiB
Python
1031 lines
32 KiB
Python
"""Abstract Broker Base Interface.
|
|
|
|
This module defines the abstract base class for all broker implementations.
|
|
Concrete broker implementations (Alpaca, IBKR, Paper) inherit from this class
|
|
and implement the abstract methods for their specific APIs.
|
|
|
|
Issue #22: [EXEC-21] Broker base interface - abstract broker class
|
|
|
|
Design Principles:
|
|
- Uniform interface across all brokers
|
|
- Async-first for I/O operations
|
|
- Type-safe with dataclasses
|
|
- Support for multiple asset classes
|
|
- Extensible for broker-specific features
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from enum import Enum
|
|
from typing import Any, Dict, List, Optional, Union
|
|
import uuid
|
|
|
|
|
|
class AssetClass(Enum):
|
|
"""Supported asset classes."""
|
|
EQUITY = "equity" # Stocks
|
|
ETF = "etf" # Exchange-traded funds
|
|
OPTION = "option" # Options contracts
|
|
FUTURE = "future" # Futures contracts
|
|
CRYPTO = "crypto" # Cryptocurrency
|
|
FOREX = "forex" # Foreign exchange
|
|
BOND = "bond" # Fixed income
|
|
INDEX = "index" # Market indices
|
|
|
|
|
|
class OrderSide(Enum):
|
|
"""Order side (buy or sell)."""
|
|
BUY = "buy"
|
|
SELL = "sell"
|
|
|
|
|
|
class OrderType(Enum):
|
|
"""Order type.
|
|
|
|
MARKET: Execute at current market price
|
|
LIMIT: Execute at specified price or better
|
|
STOP: Trigger market order at stop price
|
|
STOP_LIMIT: Trigger limit order at stop price
|
|
TRAILING_STOP: Stop that trails price by specified amount/percent
|
|
"""
|
|
MARKET = "market"
|
|
LIMIT = "limit"
|
|
STOP = "stop"
|
|
STOP_LIMIT = "stop_limit"
|
|
TRAILING_STOP = "trailing_stop"
|
|
|
|
|
|
class TimeInForce(Enum):
|
|
"""Time in force (order duration).
|
|
|
|
DAY: Valid until end of regular trading hours
|
|
GTC: Good till cancelled
|
|
IOC: Immediate or cancel (partial fills allowed)
|
|
FOK: Fill or kill (all or nothing)
|
|
OPG: On open (execute at market open)
|
|
CLS: On close (execute at market close)
|
|
GTD: Good till date
|
|
"""
|
|
DAY = "day"
|
|
GTC = "gtc"
|
|
IOC = "ioc"
|
|
FOK = "fok"
|
|
OPG = "opg"
|
|
CLS = "cls"
|
|
GTD = "gtd"
|
|
|
|
|
|
class OrderStatus(Enum):
|
|
"""Order execution status.
|
|
|
|
PENDING_NEW: Order submitted, awaiting confirmation
|
|
NEW: Order accepted by broker
|
|
PARTIALLY_FILLED: Order partially executed
|
|
FILLED: Order fully executed
|
|
PENDING_CANCEL: Cancel request submitted
|
|
CANCELLED: Order cancelled
|
|
REJECTED: Order rejected by broker
|
|
EXPIRED: Order expired (time in force elapsed)
|
|
REPLACED: Order was replaced by new order
|
|
"""
|
|
PENDING_NEW = "pending_new"
|
|
NEW = "new"
|
|
PARTIALLY_FILLED = "partially_filled"
|
|
FILLED = "filled"
|
|
PENDING_CANCEL = "pending_cancel"
|
|
CANCELLED = "cancelled"
|
|
REJECTED = "rejected"
|
|
EXPIRED = "expired"
|
|
REPLACED = "replaced"
|
|
|
|
|
|
class PositionSide(Enum):
|
|
"""Position side."""
|
|
LONG = "long"
|
|
SHORT = "short"
|
|
|
|
|
|
@dataclass
|
|
class OrderRequest:
|
|
"""Request to submit an order.
|
|
|
|
Attributes:
|
|
symbol: Trading symbol
|
|
side: Buy or sell
|
|
quantity: Number of shares/contracts
|
|
order_type: Type of order
|
|
limit_price: Limit price (for limit/stop-limit orders)
|
|
stop_price: Stop price (for stop/stop-limit orders)
|
|
time_in_force: Order duration
|
|
client_order_id: Optional client-defined order ID
|
|
extended_hours: Allow extended hours trading
|
|
trail_amount: Trail amount for trailing stop (absolute)
|
|
trail_percent: Trail percent for trailing stop
|
|
take_profit_price: Take profit price (OCO orders)
|
|
stop_loss_price: Stop loss price (OCO orders)
|
|
metadata: Additional broker-specific metadata
|
|
"""
|
|
symbol: str
|
|
side: OrderSide
|
|
quantity: Decimal
|
|
order_type: OrderType = OrderType.MARKET
|
|
limit_price: Optional[Decimal] = None
|
|
stop_price: Optional[Decimal] = None
|
|
time_in_force: TimeInForce = TimeInForce.DAY
|
|
client_order_id: Optional[str] = None
|
|
extended_hours: bool = False
|
|
trail_amount: Optional[Decimal] = None
|
|
trail_percent: Optional[Decimal] = None
|
|
take_profit_price: Optional[Decimal] = None
|
|
stop_loss_price: Optional[Decimal] = None
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def __post_init__(self):
|
|
"""Generate client order ID if not provided."""
|
|
if self.client_order_id is None:
|
|
self.client_order_id = str(uuid.uuid4())
|
|
|
|
# Validate order type requirements
|
|
if self.order_type == OrderType.LIMIT and self.limit_price is None:
|
|
raise ValueError("Limit orders require limit_price")
|
|
if self.order_type == OrderType.STOP and self.stop_price is None:
|
|
raise ValueError("Stop orders require stop_price")
|
|
if self.order_type == OrderType.STOP_LIMIT:
|
|
if self.limit_price is None or self.stop_price is None:
|
|
raise ValueError("Stop-limit orders require both limit_price and stop_price")
|
|
if self.order_type == OrderType.TRAILING_STOP:
|
|
if self.trail_amount is None and self.trail_percent is None:
|
|
raise ValueError("Trailing stop orders require trail_amount or trail_percent")
|
|
|
|
@classmethod
|
|
def market(
|
|
cls,
|
|
symbol: str,
|
|
side: OrderSide,
|
|
quantity: Union[Decimal, float, int],
|
|
time_in_force: TimeInForce = TimeInForce.DAY,
|
|
**kwargs,
|
|
) -> "OrderRequest":
|
|
"""Create a market order request."""
|
|
return cls(
|
|
symbol=symbol,
|
|
side=side,
|
|
quantity=Decimal(str(quantity)),
|
|
order_type=OrderType.MARKET,
|
|
time_in_force=time_in_force,
|
|
**kwargs,
|
|
)
|
|
|
|
@classmethod
|
|
def limit(
|
|
cls,
|
|
symbol: str,
|
|
side: OrderSide,
|
|
quantity: Union[Decimal, float, int],
|
|
limit_price: Union[Decimal, float, int],
|
|
time_in_force: TimeInForce = TimeInForce.GTC,
|
|
**kwargs,
|
|
) -> "OrderRequest":
|
|
"""Create a limit order request."""
|
|
return cls(
|
|
symbol=symbol,
|
|
side=side,
|
|
quantity=Decimal(str(quantity)),
|
|
order_type=OrderType.LIMIT,
|
|
limit_price=Decimal(str(limit_price)),
|
|
time_in_force=time_in_force,
|
|
**kwargs,
|
|
)
|
|
|
|
@classmethod
|
|
def stop(
|
|
cls,
|
|
symbol: str,
|
|
side: OrderSide,
|
|
quantity: Union[Decimal, float, int],
|
|
stop_price: Union[Decimal, float, int],
|
|
time_in_force: TimeInForce = TimeInForce.GTC,
|
|
**kwargs,
|
|
) -> "OrderRequest":
|
|
"""Create a stop order request."""
|
|
return cls(
|
|
symbol=symbol,
|
|
side=side,
|
|
quantity=Decimal(str(quantity)),
|
|
order_type=OrderType.STOP,
|
|
stop_price=Decimal(str(stop_price)),
|
|
time_in_force=time_in_force,
|
|
**kwargs,
|
|
)
|
|
|
|
@classmethod
|
|
def stop_limit(
|
|
cls,
|
|
symbol: str,
|
|
side: OrderSide,
|
|
quantity: Union[Decimal, float, int],
|
|
stop_price: Union[Decimal, float, int],
|
|
limit_price: Union[Decimal, float, int],
|
|
time_in_force: TimeInForce = TimeInForce.GTC,
|
|
**kwargs,
|
|
) -> "OrderRequest":
|
|
"""Create a stop-limit order request."""
|
|
return cls(
|
|
symbol=symbol,
|
|
side=side,
|
|
quantity=Decimal(str(quantity)),
|
|
order_type=OrderType.STOP_LIMIT,
|
|
stop_price=Decimal(str(stop_price)),
|
|
limit_price=Decimal(str(limit_price)),
|
|
time_in_force=time_in_force,
|
|
**kwargs,
|
|
)
|
|
|
|
@classmethod
|
|
def trailing_stop(
|
|
cls,
|
|
symbol: str,
|
|
side: OrderSide,
|
|
quantity: Union[Decimal, float, int],
|
|
trail_percent: Optional[Union[Decimal, float]] = None,
|
|
trail_amount: Optional[Union[Decimal, float]] = None,
|
|
time_in_force: TimeInForce = TimeInForce.GTC,
|
|
**kwargs,
|
|
) -> "OrderRequest":
|
|
"""Create a trailing stop order request."""
|
|
return cls(
|
|
symbol=symbol,
|
|
side=side,
|
|
quantity=Decimal(str(quantity)),
|
|
order_type=OrderType.TRAILING_STOP,
|
|
trail_percent=Decimal(str(trail_percent)) if trail_percent else None,
|
|
trail_amount=Decimal(str(trail_amount)) if trail_amount else None,
|
|
time_in_force=time_in_force,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Order:
|
|
"""Order information returned from broker.
|
|
|
|
Attributes:
|
|
broker_order_id: Broker-assigned order ID
|
|
client_order_id: Client-assigned order ID
|
|
symbol: Trading symbol
|
|
side: Buy or sell
|
|
quantity: Ordered quantity
|
|
order_type: Type of order
|
|
status: Current order status
|
|
limit_price: Limit price (if applicable)
|
|
stop_price: Stop price (if applicable)
|
|
time_in_force: Order duration
|
|
filled_quantity: Quantity filled so far
|
|
filled_avg_price: Average fill price
|
|
created_at: Order creation timestamp
|
|
updated_at: Last update timestamp
|
|
submitted_at: Submission timestamp
|
|
filled_at: Fill completion timestamp (if filled)
|
|
cancelled_at: Cancellation timestamp (if cancelled)
|
|
expired_at: Expiration timestamp (if expired)
|
|
extended_hours: Whether extended hours allowed
|
|
trail_amount: Trail amount (if trailing stop)
|
|
trail_percent: Trail percent (if trailing stop)
|
|
legs: Child orders (for bracket/OCO orders)
|
|
reject_reason: Reason for rejection (if rejected)
|
|
metadata: Additional broker-specific data
|
|
"""
|
|
broker_order_id: str
|
|
client_order_id: str
|
|
symbol: str
|
|
side: OrderSide
|
|
quantity: Decimal
|
|
order_type: OrderType
|
|
status: OrderStatus
|
|
limit_price: Optional[Decimal] = None
|
|
stop_price: Optional[Decimal] = None
|
|
time_in_force: TimeInForce = TimeInForce.DAY
|
|
filled_quantity: Decimal = field(default_factory=lambda: Decimal("0"))
|
|
filled_avg_price: Optional[Decimal] = None
|
|
created_at: Optional[datetime] = None
|
|
updated_at: Optional[datetime] = None
|
|
submitted_at: Optional[datetime] = None
|
|
filled_at: Optional[datetime] = None
|
|
cancelled_at: Optional[datetime] = None
|
|
expired_at: Optional[datetime] = None
|
|
extended_hours: bool = False
|
|
trail_amount: Optional[Decimal] = None
|
|
trail_percent: Optional[Decimal] = None
|
|
legs: List["Order"] = field(default_factory=list)
|
|
reject_reason: Optional[str] = None
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
@property
|
|
def is_open(self) -> bool:
|
|
"""Check if order is still open."""
|
|
return self.status in (
|
|
OrderStatus.PENDING_NEW,
|
|
OrderStatus.NEW,
|
|
OrderStatus.PARTIALLY_FILLED,
|
|
OrderStatus.PENDING_CANCEL,
|
|
)
|
|
|
|
@property
|
|
def is_filled(self) -> bool:
|
|
"""Check if order is completely filled."""
|
|
return self.status == OrderStatus.FILLED
|
|
|
|
@property
|
|
def is_cancelled(self) -> bool:
|
|
"""Check if order is cancelled."""
|
|
return self.status == OrderStatus.CANCELLED
|
|
|
|
@property
|
|
def remaining_quantity(self) -> Decimal:
|
|
"""Calculate remaining unfilled quantity."""
|
|
return self.quantity - self.filled_quantity
|
|
|
|
@property
|
|
def fill_percent(self) -> float:
|
|
"""Calculate fill percentage."""
|
|
if self.quantity == 0:
|
|
return 0.0
|
|
return float(self.filled_quantity / self.quantity * 100)
|
|
|
|
|
|
@dataclass
|
|
class Position:
|
|
"""Current position in an asset.
|
|
|
|
Attributes:
|
|
symbol: Trading symbol
|
|
quantity: Position quantity (positive for long, negative for short)
|
|
side: Position side (long/short)
|
|
avg_entry_price: Average entry price
|
|
current_price: Current market price
|
|
market_value: Current market value
|
|
cost_basis: Total cost basis
|
|
unrealized_pnl: Unrealized profit/loss
|
|
unrealized_pnl_percent: Unrealized P&L as percentage
|
|
realized_pnl: Realized profit/loss (if tracked)
|
|
asset_class: Asset class
|
|
exchange: Exchange where traded
|
|
asset_id: Broker's asset ID
|
|
metadata: Additional broker-specific data
|
|
"""
|
|
symbol: str
|
|
quantity: Decimal
|
|
side: PositionSide
|
|
avg_entry_price: Decimal
|
|
current_price: Decimal
|
|
market_value: Decimal
|
|
cost_basis: Decimal
|
|
unrealized_pnl: Decimal
|
|
unrealized_pnl_percent: Decimal
|
|
realized_pnl: Optional[Decimal] = None
|
|
asset_class: AssetClass = AssetClass.EQUITY
|
|
exchange: Optional[str] = None
|
|
asset_id: Optional[str] = None
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
@property
|
|
def is_long(self) -> bool:
|
|
"""Check if position is long."""
|
|
return self.side == PositionSide.LONG
|
|
|
|
@property
|
|
def is_short(self) -> bool:
|
|
"""Check if position is short."""
|
|
return self.side == PositionSide.SHORT
|
|
|
|
@property
|
|
def abs_quantity(self) -> Decimal:
|
|
"""Get absolute quantity."""
|
|
return abs(self.quantity)
|
|
|
|
|
|
@dataclass
|
|
class AccountInfo:
|
|
"""Broker account information.
|
|
|
|
Attributes:
|
|
account_id: Broker account ID
|
|
account_type: Account type (e.g., 'cash', 'margin')
|
|
status: Account status
|
|
currency: Base currency
|
|
cash: Available cash balance
|
|
portfolio_value: Total portfolio value
|
|
buying_power: Available buying power
|
|
equity: Account equity
|
|
margin_used: Margin currently in use
|
|
margin_available: Available margin
|
|
initial_margin: Initial margin requirement
|
|
maintenance_margin: Maintenance margin requirement
|
|
pending_transfer_in: Pending incoming transfers
|
|
pending_transfer_out: Pending outgoing transfers
|
|
day_trades_remaining: PDT day trades remaining (if applicable)
|
|
is_pattern_day_trader: Whether flagged as PDT
|
|
created_at: Account creation date
|
|
metadata: Additional broker-specific data
|
|
"""
|
|
account_id: str
|
|
account_type: str
|
|
status: str
|
|
currency: str = "USD"
|
|
cash: Decimal = field(default_factory=lambda: Decimal("0"))
|
|
portfolio_value: Decimal = field(default_factory=lambda: Decimal("0"))
|
|
buying_power: Decimal = field(default_factory=lambda: Decimal("0"))
|
|
equity: Decimal = field(default_factory=lambda: Decimal("0"))
|
|
margin_used: Optional[Decimal] = None
|
|
margin_available: Optional[Decimal] = None
|
|
initial_margin: Optional[Decimal] = None
|
|
maintenance_margin: Optional[Decimal] = None
|
|
pending_transfer_in: Optional[Decimal] = None
|
|
pending_transfer_out: Optional[Decimal] = None
|
|
day_trades_remaining: Optional[int] = None
|
|
is_pattern_day_trader: bool = False
|
|
created_at: Optional[datetime] = None
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
"""Check if account is active."""
|
|
return self.status.lower() in ("active", "approved", "enabled")
|
|
|
|
|
|
@dataclass
|
|
class Quote:
|
|
"""Current quote/price data.
|
|
|
|
Attributes:
|
|
symbol: Trading symbol
|
|
bid_price: Current bid price
|
|
bid_size: Bid size
|
|
ask_price: Current ask price
|
|
ask_size: Ask size
|
|
last_price: Last trade price
|
|
last_size: Last trade size
|
|
volume: Trading volume
|
|
timestamp: Quote timestamp
|
|
exchange: Exchange code
|
|
conditions: Trade conditions
|
|
metadata: Additional data
|
|
"""
|
|
symbol: str
|
|
bid_price: Optional[Decimal] = None
|
|
bid_size: Optional[Decimal] = None
|
|
ask_price: Optional[Decimal] = None
|
|
ask_size: Optional[Decimal] = None
|
|
last_price: Optional[Decimal] = None
|
|
last_size: Optional[Decimal] = None
|
|
volume: Optional[int] = None
|
|
timestamp: Optional[datetime] = None
|
|
exchange: Optional[str] = None
|
|
conditions: List[str] = field(default_factory=list)
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
@property
|
|
def mid_price(self) -> Optional[Decimal]:
|
|
"""Calculate mid price between bid and ask."""
|
|
if self.bid_price is not None and self.ask_price is not None:
|
|
return (self.bid_price + self.ask_price) / 2
|
|
return self.last_price
|
|
|
|
@property
|
|
def spread(self) -> Optional[Decimal]:
|
|
"""Calculate bid-ask spread."""
|
|
if self.bid_price is not None and self.ask_price is not None:
|
|
return self.ask_price - self.bid_price
|
|
return None
|
|
|
|
@property
|
|
def spread_percent(self) -> Optional[float]:
|
|
"""Calculate spread as percentage of mid price."""
|
|
if self.spread is not None and self.mid_price is not None and self.mid_price > 0:
|
|
return float(self.spread / self.mid_price * 100)
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class AssetInfo:
|
|
"""Asset/instrument information.
|
|
|
|
Attributes:
|
|
symbol: Trading symbol
|
|
name: Full name
|
|
asset_class: Asset class
|
|
exchange: Primary exchange
|
|
tradable: Whether currently tradable
|
|
marginable: Whether marginable
|
|
shortable: Whether shortable
|
|
easy_to_borrow: Whether easy to borrow for shorting
|
|
fractionable: Whether fractional shares allowed
|
|
min_order_size: Minimum order size
|
|
min_trade_increment: Minimum trade increment
|
|
price_increment: Price increment (tick size)
|
|
maintenance_margin_req: Maintenance margin requirement
|
|
attributes: Additional attributes list
|
|
metadata: Additional broker-specific data
|
|
"""
|
|
symbol: str
|
|
name: str
|
|
asset_class: AssetClass = AssetClass.EQUITY
|
|
exchange: Optional[str] = None
|
|
tradable: bool = True
|
|
marginable: bool = True
|
|
shortable: bool = True
|
|
easy_to_borrow: bool = True
|
|
fractionable: bool = False
|
|
min_order_size: Optional[Decimal] = None
|
|
min_trade_increment: Optional[Decimal] = None
|
|
price_increment: Optional[Decimal] = None
|
|
maintenance_margin_req: Optional[Decimal] = None
|
|
attributes: List[str] = field(default_factory=list)
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
class BrokerError(Exception):
|
|
"""Base exception for broker errors."""
|
|
|
|
def __init__(self, message: str, code: Optional[str] = None, details: Optional[Dict] = None):
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.details = details or {}
|
|
|
|
|
|
class ConnectionError(BrokerError):
|
|
"""Error connecting to broker."""
|
|
pass
|
|
|
|
|
|
class AuthenticationError(BrokerError):
|
|
"""Authentication failed."""
|
|
pass
|
|
|
|
|
|
class OrderError(BrokerError):
|
|
"""Error submitting or managing order."""
|
|
pass
|
|
|
|
|
|
class InsufficientFundsError(OrderError):
|
|
"""Insufficient funds for order."""
|
|
pass
|
|
|
|
|
|
class InvalidOrderError(OrderError):
|
|
"""Invalid order parameters."""
|
|
pass
|
|
|
|
|
|
class PositionError(BrokerError):
|
|
"""Error with position operations."""
|
|
pass
|
|
|
|
|
|
class RateLimitError(BrokerError):
|
|
"""Rate limit exceeded."""
|
|
|
|
def __init__(self, message: str, retry_after: Optional[float] = None, **kwargs):
|
|
super().__init__(message, **kwargs)
|
|
self.retry_after = retry_after
|
|
|
|
|
|
class BrokerBase(ABC):
|
|
"""Abstract base class for broker implementations.
|
|
|
|
All broker implementations must inherit from this class and implement
|
|
the abstract methods. This provides a uniform interface for the trading
|
|
system regardless of which broker is used.
|
|
|
|
Example:
|
|
>>> class AlpacaBroker(BrokerBase):
|
|
... async def connect(self) -> bool:
|
|
... # Connect to Alpaca API
|
|
... return True
|
|
... # ... implement other abstract methods
|
|
>>>
|
|
>>> broker = AlpacaBroker(api_key="...", api_secret="...")
|
|
>>> await broker.connect()
|
|
>>> order = await broker.submit_order(
|
|
... OrderRequest.market("AAPL", OrderSide.BUY, 100)
|
|
... )
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
supported_asset_classes: Optional[List[AssetClass]] = None,
|
|
paper_trading: bool = False,
|
|
**kwargs,
|
|
):
|
|
"""Initialize broker base.
|
|
|
|
Args:
|
|
name: Broker name
|
|
supported_asset_classes: List of supported asset classes
|
|
paper_trading: Whether this is paper trading mode
|
|
**kwargs: Additional broker-specific configuration
|
|
"""
|
|
self._name = name
|
|
self._supported_asset_classes = supported_asset_classes or [AssetClass.EQUITY]
|
|
self._paper_trading = paper_trading
|
|
self._connected = False
|
|
self._config = kwargs
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Get broker name."""
|
|
return self._name
|
|
|
|
@property
|
|
def supported_asset_classes(self) -> List[AssetClass]:
|
|
"""Get list of supported asset classes."""
|
|
return self._supported_asset_classes
|
|
|
|
@property
|
|
def is_paper_trading(self) -> bool:
|
|
"""Check if broker is in paper trading mode."""
|
|
return self._paper_trading
|
|
|
|
@property
|
|
def is_connected(self) -> bool:
|
|
"""Check if broker is connected."""
|
|
return self._connected
|
|
|
|
def supports_asset_class(self, asset_class: AssetClass) -> bool:
|
|
"""Check if broker supports a specific asset class.
|
|
|
|
Args:
|
|
asset_class: Asset class to check
|
|
|
|
Returns:
|
|
True if supported, False otherwise
|
|
"""
|
|
return asset_class in self._supported_asset_classes
|
|
|
|
# ==========================================================================
|
|
# Connection Management
|
|
# ==========================================================================
|
|
|
|
@abstractmethod
|
|
async def connect(self) -> bool:
|
|
"""Connect to broker API.
|
|
|
|
Returns:
|
|
True if connection successful, False otherwise
|
|
|
|
Raises:
|
|
ConnectionError: If connection fails
|
|
AuthenticationError: If authentication fails
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def disconnect(self) -> None:
|
|
"""Disconnect from broker API."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def is_market_open(self) -> bool:
|
|
"""Check if market is currently open.
|
|
|
|
Returns:
|
|
True if market is open, False otherwise
|
|
"""
|
|
pass
|
|
|
|
# ==========================================================================
|
|
# Account Information
|
|
# ==========================================================================
|
|
|
|
@abstractmethod
|
|
async def get_account(self) -> AccountInfo:
|
|
"""Get account information.
|
|
|
|
Returns:
|
|
AccountInfo object with account details
|
|
|
|
Raises:
|
|
ConnectionError: If not connected
|
|
BrokerError: If account retrieval fails
|
|
"""
|
|
pass
|
|
|
|
# ==========================================================================
|
|
# Order Management
|
|
# ==========================================================================
|
|
|
|
@abstractmethod
|
|
async def submit_order(self, request: OrderRequest) -> Order:
|
|
"""Submit a new order.
|
|
|
|
Args:
|
|
request: Order request details
|
|
|
|
Returns:
|
|
Order object representing submitted order
|
|
|
|
Raises:
|
|
ConnectionError: If not connected
|
|
InvalidOrderError: If order parameters invalid
|
|
InsufficientFundsError: If insufficient buying power
|
|
OrderError: If order submission fails
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def cancel_order(self, order_id: str) -> Order:
|
|
"""Cancel an existing order.
|
|
|
|
Args:
|
|
order_id: Broker order ID to cancel
|
|
|
|
Returns:
|
|
Updated order object
|
|
|
|
Raises:
|
|
OrderError: If cancellation fails
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def replace_order(
|
|
self,
|
|
order_id: str,
|
|
quantity: Optional[Decimal] = None,
|
|
limit_price: Optional[Decimal] = None,
|
|
stop_price: Optional[Decimal] = None,
|
|
time_in_force: Optional[TimeInForce] = None,
|
|
) -> Order:
|
|
"""Replace/modify an existing order.
|
|
|
|
Args:
|
|
order_id: Broker order ID to replace
|
|
quantity: New quantity (optional)
|
|
limit_price: New limit price (optional)
|
|
stop_price: New stop price (optional)
|
|
time_in_force: New time in force (optional)
|
|
|
|
Returns:
|
|
New order object
|
|
|
|
Raises:
|
|
OrderError: If replacement fails
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_order(self, order_id: str) -> Order:
|
|
"""Get order by ID.
|
|
|
|
Args:
|
|
order_id: Broker order ID
|
|
|
|
Returns:
|
|
Order object
|
|
|
|
Raises:
|
|
OrderError: If order not found
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_orders(
|
|
self,
|
|
status: Optional[OrderStatus] = None,
|
|
limit: int = 100,
|
|
symbols: Optional[List[str]] = None,
|
|
) -> List[Order]:
|
|
"""Get orders with optional filters.
|
|
|
|
Args:
|
|
status: Filter by order status
|
|
limit: Maximum number of orders to return
|
|
symbols: Filter by symbols
|
|
|
|
Returns:
|
|
List of Order objects
|
|
"""
|
|
pass
|
|
|
|
async def cancel_all_orders(self, symbols: Optional[List[str]] = None) -> List[Order]:
|
|
"""Cancel all open orders.
|
|
|
|
Args:
|
|
symbols: Optional list of symbols to cancel orders for
|
|
|
|
Returns:
|
|
List of cancelled orders
|
|
"""
|
|
open_orders = await self.get_orders(
|
|
status=OrderStatus.NEW,
|
|
symbols=symbols,
|
|
)
|
|
|
|
# Also get partially filled orders
|
|
partial_orders = await self.get_orders(
|
|
status=OrderStatus.PARTIALLY_FILLED,
|
|
symbols=symbols,
|
|
)
|
|
|
|
cancelled = []
|
|
for order in open_orders + partial_orders:
|
|
try:
|
|
cancelled_order = await self.cancel_order(order.broker_order_id)
|
|
cancelled.append(cancelled_order)
|
|
except OrderError:
|
|
# Order may have been filled between query and cancel
|
|
pass
|
|
|
|
return cancelled
|
|
|
|
# ==========================================================================
|
|
# Position Management
|
|
# ==========================================================================
|
|
|
|
@abstractmethod
|
|
async def get_positions(self) -> List[Position]:
|
|
"""Get all current positions.
|
|
|
|
Returns:
|
|
List of Position objects
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_position(self, symbol: str) -> Optional[Position]:
|
|
"""Get position for a specific symbol.
|
|
|
|
Args:
|
|
symbol: Trading symbol
|
|
|
|
Returns:
|
|
Position object or None if no position
|
|
"""
|
|
pass
|
|
|
|
async def close_position(
|
|
self,
|
|
symbol: str,
|
|
quantity: Optional[Decimal] = None,
|
|
) -> Order:
|
|
"""Close a position partially or completely.
|
|
|
|
Args:
|
|
symbol: Symbol to close
|
|
quantity: Quantity to close (None for entire position)
|
|
|
|
Returns:
|
|
Order object for the closing trade
|
|
|
|
Raises:
|
|
PositionError: If position doesn't exist
|
|
"""
|
|
position = await self.get_position(symbol)
|
|
if position is None:
|
|
raise PositionError(f"No position found for {symbol}")
|
|
|
|
close_qty = quantity if quantity is not None else position.abs_quantity
|
|
|
|
# Determine side based on position
|
|
side = OrderSide.SELL if position.is_long else OrderSide.BUY
|
|
|
|
return await self.submit_order(
|
|
OrderRequest.market(symbol, side, close_qty)
|
|
)
|
|
|
|
async def close_all_positions(self) -> List[Order]:
|
|
"""Close all positions.
|
|
|
|
Returns:
|
|
List of orders for closing trades
|
|
"""
|
|
positions = await self.get_positions()
|
|
orders = []
|
|
|
|
for position in positions:
|
|
try:
|
|
order = await self.close_position(position.symbol)
|
|
orders.append(order)
|
|
except (OrderError, PositionError):
|
|
# Position may have been closed between query and close
|
|
pass
|
|
|
|
return orders
|
|
|
|
# ==========================================================================
|
|
# Market Data
|
|
# ==========================================================================
|
|
|
|
@abstractmethod
|
|
async def get_quote(self, symbol: str) -> Quote:
|
|
"""Get current quote for a symbol.
|
|
|
|
Args:
|
|
symbol: Trading symbol
|
|
|
|
Returns:
|
|
Quote object with bid/ask/last prices
|
|
"""
|
|
pass
|
|
|
|
async def get_quotes(self, symbols: List[str]) -> Dict[str, Quote]:
|
|
"""Get quotes for multiple symbols.
|
|
|
|
Default implementation calls get_quote for each symbol.
|
|
Override for batch operations if supported by broker.
|
|
|
|
Args:
|
|
symbols: List of trading symbols
|
|
|
|
Returns:
|
|
Dict mapping symbol to Quote
|
|
"""
|
|
quotes = {}
|
|
for symbol in symbols:
|
|
try:
|
|
quotes[symbol] = await self.get_quote(symbol)
|
|
except BrokerError:
|
|
pass
|
|
return quotes
|
|
|
|
@abstractmethod
|
|
async def get_asset(self, symbol: str) -> AssetInfo:
|
|
"""Get asset information.
|
|
|
|
Args:
|
|
symbol: Trading symbol
|
|
|
|
Returns:
|
|
AssetInfo object with asset details
|
|
"""
|
|
pass
|
|
|
|
# ==========================================================================
|
|
# Utility Methods
|
|
# ==========================================================================
|
|
|
|
async def validate_order(self, request: OrderRequest) -> List[str]:
|
|
"""Validate an order request before submission.
|
|
|
|
Args:
|
|
request: Order request to validate
|
|
|
|
Returns:
|
|
List of validation error messages (empty if valid)
|
|
"""
|
|
errors = []
|
|
|
|
# Check basic parameters
|
|
if request.quantity <= 0:
|
|
errors.append("Quantity must be positive")
|
|
|
|
# Check asset is tradable
|
|
try:
|
|
asset = await self.get_asset(request.symbol)
|
|
if not asset.tradable:
|
|
errors.append(f"{request.symbol} is not currently tradable")
|
|
except BrokerError:
|
|
errors.append(f"Could not validate asset {request.symbol}")
|
|
|
|
# Check limit price for limit orders
|
|
if request.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT):
|
|
if request.limit_price is None or request.limit_price <= 0:
|
|
errors.append("Limit price must be positive for limit orders")
|
|
|
|
# Check stop price for stop orders
|
|
if request.order_type in (OrderType.STOP, OrderType.STOP_LIMIT, OrderType.TRAILING_STOP):
|
|
if request.order_type != OrderType.TRAILING_STOP:
|
|
if request.stop_price is None or request.stop_price <= 0:
|
|
errors.append("Stop price must be positive for stop orders")
|
|
|
|
# Check buying power
|
|
if request.side == OrderSide.BUY:
|
|
try:
|
|
account = await self.get_account()
|
|
quote = await self.get_quote(request.symbol)
|
|
estimated_cost = request.quantity * (
|
|
request.limit_price or quote.ask_price or quote.last_price or Decimal("0")
|
|
)
|
|
if estimated_cost > account.buying_power:
|
|
errors.append(
|
|
f"Insufficient buying power. Required: {estimated_cost}, "
|
|
f"Available: {account.buying_power}"
|
|
)
|
|
except BrokerError:
|
|
pass # Skip buying power check if we can't get data
|
|
|
|
return errors
|
|
|
|
def __repr__(self) -> str:
|
|
"""String representation."""
|
|
return (
|
|
f"{self.__class__.__name__}("
|
|
f"name='{self._name}', "
|
|
f"paper_trading={self._paper_trading}, "
|
|
f"connected={self._connected})"
|
|
)
|