Files
QuantumTerminal/backend/providers/base_provider.py
T

276 lines
8.8 KiB
Python

"""
================================================================================
Quantum Terminal — Base Provider Interface
================================================================================
Abstract contract that every data/execution provider must implement.
The data_server and config_manager talk to providers ONLY through this
interface. MT5, Binance, Polygon, or any future source plugs in by
subclassing BaseProvider and implementing the required methods.
Design principles:
- All methods are synchronous (callers use asyncio.to_thread)
- Providers manage their own connection lifecycle
- Canonical ticker names everywhere — providers map internally
- Providers declare their capabilities (data-only vs data+execution)
Usage:
from providers.base_provider import BaseProvider
from providers.mt5_provider import MT5Provider
provider = MT5Provider(account_config)
provider.connect()
ticks = provider.get_latest_ticks(["XAUUSD", "EURUSD"])
================================================================================
"""
from abc import ABC, abstractmethod
from typing import Dict, List, Optional
from models import (
TickData, BarData, AccountInfo, SymbolInfo,
OrderRequest, OrderResult, Position, PendingOrder,
)
class BaseProvider(ABC):
"""
Abstract provider interface.
Every provider has:
- A type name (e.g., "mt5", "binance")
- A unique instance ID (e.g., "mt5_primary", "binance_spot")
- Connection lifecycle (connect/disconnect/reconnect)
- Market data methods (ticks, bars, symbol info)
- Optional execution methods (orders, positions)
Providers are synchronous. The data_server wraps calls in
asyncio.to_thread() to avoid blocking the event loop.
"""
# ── Identity ──
@property
@abstractmethod
def provider_type(self) -> str:
"""Provider type identifier. E.g., 'mt5', 'binance', 'polygon'."""
...
@property
@abstractmethod
def provider_id(self) -> str:
"""Unique instance ID. E.g., 'mt5_primary'. Set from account config."""
...
@property
@abstractmethod
def label(self) -> str:
"""Human-readable label. E.g., 'MT5 — CFI (Live)'."""
...
# ── Capabilities ──
@property
def can_stream_ticks(self) -> bool:
"""Whether this provider supports live tick polling."""
return True
@property
def can_execute(self) -> bool:
"""Whether this provider supports order execution."""
return False
@property
def supported_timeframes(self) -> List[str]:
"""List of timeframe strings this provider supports."""
return ["M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1"]
# ── Connection Lifecycle ──
@property
@abstractmethod
def connected(self) -> bool:
"""Whether the provider is currently connected."""
...
@abstractmethod
def connect(self) -> bool:
"""
Establish connection to the data/execution source.
Returns True on success, False on failure.
Must be idempotent — calling connect() when already connected is safe.
"""
...
@abstractmethod
def disconnect(self) -> None:
"""Cleanly shut down the connection."""
...
def reconnect(self) -> bool:
"""Disconnect and reconnect. Override for custom reconnect logic."""
self.disconnect()
return self.connect()
def heartbeat(self) -> bool:
"""
Lightweight connection health check.
Returns True if the connection is alive, False otherwise.
If False, sets internal connected state to False so reconnect_loop picks it up.
Override in subclasses for provider-specific health checks.
"""
return self.connected
# ── Market Data ──
@abstractmethod
def get_latest_ticks(self, symbols: List[str]) -> Dict[str, TickData]:
"""
Fetch latest tick for each symbol.
Args:
symbols: List of canonical ticker names (e.g., ["XAUUSD", "EURUSD"])
Returns:
Dict mapping canonical ticker → TickData.
Missing/failed symbols are simply omitted.
"""
...
@abstractmethod
def get_bars(
self, ticker: str, timeframe: str = "M15", count: int = 200
) -> List[BarData]:
"""
Fetch recent OHLCV bars for a canonical ticker.
Args:
ticker: Canonical symbol name
timeframe: Timeframe string (M1, M5, M15, H1, H4, D1, etc.)
count: Number of bars to fetch
Returns:
List of BarData, oldest first. Empty list on failure.
"""
...
@abstractmethod
def check_new_bars(
self, symbols: List[str], timeframe: str = "M15"
) -> List[dict]:
"""
Detect newly closed bars since last check.
Returns list of dicts:
{"type": "bar", "ticker": str, "timeframe": str, "bar": BarData.to_dict()}
Implementation must track last-seen bar timestamps internally.
"""
...
@abstractmethod
def get_symbol_info(self, ticker: str) -> Optional[SymbolInfo]:
"""
Get metadata for a symbol (decimals, lot sizing, contract size, etc.)
Returns None if symbol not found.
"""
...
def get_all_symbol_info(self, symbols: List[str]) -> Dict[str, SymbolInfo]:
"""
Batch symbol info for multiple tickers.
Default implementation calls get_symbol_info() in a loop.
Override for providers that support batch queries.
"""
result = {}
for s in symbols:
info = self.get_symbol_info(s)
if info is not None:
result[s] = info
return result
# ── Account Info ──
@abstractmethod
def get_account_info(self) -> Optional[AccountInfo]:
"""
Get current account snapshot (balance, equity, margin, etc.)
Returns None if not connected or not applicable.
"""
...
# ── Execution (optional — override if can_execute is True) ──
def place_order(self, order: OrderRequest) -> OrderResult:
"""Place an order. Override in execution-capable providers."""
return OrderResult(
success=False,
error=f"Provider '{self.provider_type}' does not support execution",
)
def get_positions(self) -> List[Position]:
"""Get all open positions. Override in execution-capable providers."""
return []
def close_position(self, ticket: str, lots: Optional[float] = None) -> OrderResult:
"""
Close a position (fully or partially).
Override in execution-capable providers.
"""
return OrderResult(
success=False,
error=f"Provider '{self.provider_type}' does not support execution",
)
def get_bars_range(self, ticker, timeframe, from_dt, to_dt):
"""Fetch OHLCV bars between two datetimes.
Override in providers that support historical range queries."""
return []
def get_pending_orders(self) -> List[PendingOrder]:
"""
List all resting (non-filled) pending orders (LIMIT / STOP).
Override in execution-capable providers.
"""
return []
def cancel_order(self, ticket: str) -> OrderResult:
"""Cancel a resting pending order. Override in execution-capable providers."""
return OrderResult(
success=False,
error=f"Provider '{self.provider_type}' does not support execution",
)
def modify_order(
self, ticket: str,
price: Optional[float] = None,
stop_loss: Optional[float] = None,
take_profit: Optional[float] = None,
) -> OrderResult:
"""Modify price / SL / TP on a pending order. Override in execution-capable providers."""
return OrderResult(
success=False,
error=f"Provider '{self.provider_type}' does not support execution",
)
# ── Symbol Resolution ──
@abstractmethod
def resolve_symbol(self, canonical: str) -> Optional[str]:
"""
Map a canonical ticker name to the provider's native symbol.
Returns None if the symbol is not available in this provider.
"""
...
def get_available_symbols(self) -> List[str]:
"""
Return list of all canonical symbols this provider can serve.
Default: empty (override to support symbol discovery).
"""
return []
# ── String Representation ──
def __repr__(self) -> str:
status = "connected" if self.connected else "disconnected"
return f"<{self.__class__.__name__} id='{self.provider_id}' [{status}]>"