Initial commit: Quantum Terminal — Free & Open Source Trading Platform
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
================================================================================
|
||||
Quantum Terminal — Provider Registry
|
||||
================================================================================
|
||||
Maps provider type names to their implementation classes.
|
||||
|
||||
To add a new provider:
|
||||
1. Create providers/my_provider.py implementing BaseProvider
|
||||
2. Add "my_provider": MyProvider to PROVIDER_REGISTRY below
|
||||
3. Add account config in user_config.json under providers.accounts
|
||||
|
||||
That's it — the config_manager will instantiate and manage it.
|
||||
================================================================================
|
||||
"""
|
||||
|
||||
from providers.base_provider import BaseProvider
|
||||
from providers.mt5_provider import MT5Provider
|
||||
|
||||
# ── Rithmic provider (graceful if async_rithmic not installed) ──
|
||||
try:
|
||||
from providers.rithmic_provider import RithmicProvider
|
||||
RITHMIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
RithmicProvider = None
|
||||
RITHMIC_AVAILABLE = False
|
||||
|
||||
# ── Provider type → class mapping ──
|
||||
# Key = the "type" field in account config
|
||||
# Value = class that implements BaseProvider
|
||||
PROVIDER_REGISTRY = {
|
||||
"mt5": MT5Provider,
|
||||
}
|
||||
|
||||
# Register Rithmic only if async_rithmic is installed
|
||||
if RITHMIC_AVAILABLE:
|
||||
PROVIDER_REGISTRY["rithmic"] = RithmicProvider
|
||||
|
||||
|
||||
def create_provider(account_config: dict) -> BaseProvider:
|
||||
"""
|
||||
Factory: create a provider instance from account config dict.
|
||||
|
||||
Expected config shape:
|
||||
{
|
||||
"id": "mt5_primary",
|
||||
"type": "mt5",
|
||||
"label": "MT5 — CFI (Live)",
|
||||
"terminal_path": null,
|
||||
"aliases": {}
|
||||
}
|
||||
|
||||
Raises KeyError if provider type is not registered.
|
||||
"""
|
||||
provider_type = account_config.get("type", "")
|
||||
if provider_type not in PROVIDER_REGISTRY:
|
||||
registered = ", ".join(PROVIDER_REGISTRY.keys())
|
||||
raise KeyError(
|
||||
f"Unknown provider type: '{provider_type}'. "
|
||||
f"Registered types: {registered}"
|
||||
)
|
||||
|
||||
cls = PROVIDER_REGISTRY[provider_type]
|
||||
return cls(account_config)
|
||||
|
||||
|
||||
def list_provider_types() -> list:
|
||||
"""Return list of registered provider type names."""
|
||||
return list(PROVIDER_REGISTRY.keys())
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
================================================================================
|
||||
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}]>"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,758 @@
|
||||
"""
|
||||
================================================================================
|
||||
Quantum Terminal — Rithmic Provider
|
||||
================================================================================
|
||||
Implements BaseProvider for Rithmic futures data feed.
|
||||
|
||||
Uses the async_rithmic library (Protocol Buffer API over WebSocket) to:
|
||||
- Stream live BBO ticks for futures instruments
|
||||
- Stream live time bars (M1/M5/M15/M30/H1)
|
||||
- Fetch historical bars on demand
|
||||
- Auto-resolve front month contracts (ES → ESM6, etc.)
|
||||
|
||||
Architecture:
|
||||
async_rithmic requires running inside a proper asyncio event loop.
|
||||
This provider exposes async_connect()/async_disconnect() methods that
|
||||
run directly in the caller's event loop (FastAPI's uvicorn loop).
|
||||
|
||||
data_server.py lifespan calls:
|
||||
await provider.async_connect() # in the main event loop
|
||||
|
||||
Sync methods (get_latest_ticks, get_bars, etc.) read from caches
|
||||
populated by streaming callbacks running in the same event loop.
|
||||
|
||||
For non-async contexts (test scripts), connect() falls back to
|
||||
asyncio.run() which works but blocks the calling thread.
|
||||
|
||||
Config dict keys:
|
||||
id: str — unique provider ID (e.g., "rithmic_default")
|
||||
type: str — "rithmic"
|
||||
label: str — display name (e.g., "Rithmic — Paper Trading")
|
||||
enabled: bool — True
|
||||
user: str — Rithmic username (from local_config.ini)
|
||||
password: str — Rithmic password (from local_config.ini)
|
||||
system_name: str — "Rithmic Test" / "Rithmic 01" etc.
|
||||
url: str — server URL (e.g., "rituz00100.rithmic.com:443")
|
||||
app_name: str — "Quantum Terminal" (default)
|
||||
app_version: str — "1.0" (default)
|
||||
symbol_map: dict — canonical → {base, exchange} overrides (optional)
|
||||
|
||||
Dependencies:
|
||||
pip install async_rithmic
|
||||
================================================================================
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from models import (
|
||||
TickData, BarData, AccountInfo, SymbolInfo,
|
||||
OrderRequest, OrderResult, Position,
|
||||
)
|
||||
from providers.base_provider import BaseProvider
|
||||
|
||||
log = logging.getLogger("provider.rithmic")
|
||||
|
||||
# ── async_rithmic imported lazily ──
|
||||
try:
|
||||
from async_rithmic import (
|
||||
RithmicClient,
|
||||
TimeBarType,
|
||||
DataType,
|
||||
LastTradePresenceBits,
|
||||
BestBidOfferPresenceBits,
|
||||
)
|
||||
RITHMIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
RITHMIC_AVAILABLE = False
|
||||
RithmicClient = None
|
||||
TimeBarType = None
|
||||
DataType = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 1. SYMBOL MAPPING — Canonical → Rithmic
|
||||
# ============================================================
|
||||
|
||||
# Maps Quantum Terminal canonical tickers to Rithmic base symbols + exchange.
|
||||
# get_front_month_contract() auto-resolves the actual contract code
|
||||
# (e.g., "ES" → "ESM6" for June 2026).
|
||||
#
|
||||
# IMPORTANT: Futures canonical tickers are SEPARATE from CFD tickers.
|
||||
# ES = CME E-mini S&P 500 futures (Rithmic)
|
||||
# US500 = S&P 500 CFD (MT5/CFI)
|
||||
# They coexist in the universe as independent instruments.
|
||||
RITHMIC_SYMBOL_MAP = {
|
||||
# Equity index futures
|
||||
"ES": {"base": "ES", "exchange": "CME"},
|
||||
"NQ": {"base": "NQ", "exchange": "CME"},
|
||||
"YM": {"base": "YM", "exchange": "CBOT"},
|
||||
# Metal futures
|
||||
"GC": {"base": "GC", "exchange": "COMEX"},
|
||||
"SI": {"base": "SI", "exchange": "COMEX"},
|
||||
# Energy futures
|
||||
"CL": {"base": "CL", "exchange": "NYMEX"},
|
||||
"BZ": {"base": "BZ", "exchange": "NYMEX"},
|
||||
# European index futures (Eurex — may not be available on all accounts)
|
||||
"FDAX": {"base": "FDAX", "exchange": "EUREX"},
|
||||
"Z": {"base": "Z", "exchange": "LIFFE"},
|
||||
}
|
||||
|
||||
# Maps our timeframe strings to (TimeBarType enum name, period) pairs.
|
||||
RITHMIC_TF_MAP = {
|
||||
"M1": ("MINUTE_BAR", 1),
|
||||
"M5": ("MINUTE_BAR", 5),
|
||||
"M15": ("MINUTE_BAR", 15),
|
||||
"M30": ("MINUTE_BAR", 30),
|
||||
"H1": ("MINUTE_BAR", 60),
|
||||
"H4": ("MINUTE_BAR", 240),
|
||||
"D1": ("DAILY_BAR", 1),
|
||||
}
|
||||
|
||||
# Futures contract specs (static — used for SymbolInfo).
|
||||
FUTURES_SPECS = {
|
||||
"ES": {"decimals": 2, "tick_size": 0.25, "tick_value": 12.50,
|
||||
"contract_size": 50.0, "description": "E-mini S&P 500",
|
||||
"currency": "USD", "min_lot": 1, "lot_step": 1, "max_lot": 100},
|
||||
"NQ": {"decimals": 2, "tick_size": 0.25, "tick_value": 5.00,
|
||||
"contract_size": 20.0, "description": "E-mini NASDAQ-100",
|
||||
"currency": "USD", "min_lot": 1, "lot_step": 1, "max_lot": 100},
|
||||
"YM": {"decimals": 0, "tick_size": 1.0, "tick_value": 5.00,
|
||||
"contract_size": 5.0, "description": "E-mini Dow",
|
||||
"currency": "USD", "min_lot": 1, "lot_step": 1, "max_lot": 100},
|
||||
"GC": {"decimals": 2, "tick_size": 0.10, "tick_value": 10.00,
|
||||
"contract_size": 100.0, "description": "Gold Futures",
|
||||
"currency": "USD", "min_lot": 1, "lot_step": 1, "max_lot": 100},
|
||||
"SI": {"decimals": 3, "tick_size": 0.005, "tick_value": 25.00,
|
||||
"contract_size": 5000.0, "description": "Silver Futures",
|
||||
"currency": "USD", "min_lot": 1, "lot_step": 1, "max_lot": 100},
|
||||
"CL": {"decimals": 2, "tick_size": 0.01, "tick_value": 10.00,
|
||||
"contract_size": 1000.0, "description": "Crude Oil WTI",
|
||||
"currency": "USD", "min_lot": 1, "lot_step": 1, "max_lot": 100},
|
||||
"BZ": {"decimals": 2, "tick_size": 0.01, "tick_value": 10.00,
|
||||
"contract_size": 1000.0, "description": "Brent Crude Oil",
|
||||
"currency": "USD", "min_lot": 1, "lot_step": 1, "max_lot": 100},
|
||||
"FDAX": {"decimals": 1, "tick_size": 0.5, "tick_value": 12.50,
|
||||
"contract_size": 25.0, "description": "DAX Futures",
|
||||
"currency": "EUR", "min_lot": 1, "lot_step": 1, "max_lot": 50},
|
||||
"Z": {"decimals": 1, "tick_size": 0.5, "tick_value": 5.00,
|
||||
"contract_size": 10.0, "description": "FTSE 100 Futures",
|
||||
"currency": "GBP", "min_lot": 1, "lot_step": 1, "max_lot": 50},
|
||||
}
|
||||
|
||||
# Minutes per timeframe — used to compute bar fetch windows
|
||||
TF_MINUTES = {
|
||||
"M1": 1, "M5": 5, "M15": 15, "M30": 30,
|
||||
"H1": 60, "H4": 240, "D1": 1440, "W1": 10080,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. RITHMIC PROVIDER
|
||||
# ============================================================
|
||||
|
||||
class RithmicProvider(BaseProvider):
|
||||
"""
|
||||
Rithmic futures data provider.
|
||||
|
||||
Data-only provider (can_execute=False for now). Streams live
|
||||
ticks and bars via async_rithmic, serves data through the
|
||||
synchronous BaseProvider interface.
|
||||
|
||||
IMPORTANT: async_rithmic must run inside a proper asyncio event
|
||||
loop. Use async_connect() from FastAPI's lifespan, or connect()
|
||||
which falls back to asyncio.run() for standalone scripts.
|
||||
"""
|
||||
|
||||
def __init__(self, account_config: Dict[str, Any]):
|
||||
self._id = account_config.get("id", "rithmic_default")
|
||||
self._label = account_config.get("label", "Rithmic")
|
||||
self._user = account_config.get("user", "")
|
||||
self._password = account_config.get("password", "")
|
||||
self._system_name = account_config.get("system_name", "Rithmic Test")
|
||||
self._url = account_config.get("url", "")
|
||||
self._app_name = account_config.get("app_name", "Quantum Terminal")
|
||||
self._app_version = account_config.get("app_version", "1.0")
|
||||
|
||||
# Symbol map: merge defaults with user overrides
|
||||
self._symbol_map = dict(RITHMIC_SYMBOL_MAP)
|
||||
user_map = account_config.get("symbol_map", {})
|
||||
self._symbol_map.update(user_map)
|
||||
|
||||
# Connection state
|
||||
self._connected = False
|
||||
self._client: Optional[Any] = None # RithmicClient instance
|
||||
|
||||
# Reference to the event loop we're running in (set during connect)
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
# Resolved front month contracts: canonical → "ESM6" etc.
|
||||
self._front_months: Dict[str, str] = {}
|
||||
# Reverse map: "ESM6" → "ES"
|
||||
self._reverse_map: Dict[str, str] = {}
|
||||
|
||||
# Tick cache: canonical → TickData (written from async callbacks,
|
||||
# read from sync methods — both in the same thread in production)
|
||||
self._tick_cache: Dict[str, TickData] = {}
|
||||
|
||||
# Bar buffer: "TICKER_TF" → deque of BarData (ring buffer)
|
||||
self._bar_buffers: Dict[str, deque] = {}
|
||||
self._max_bar_buffer = 500
|
||||
|
||||
# Bar tracking for check_new_bars
|
||||
self._last_bar_times: Dict[str, str] = {}
|
||||
|
||||
# Subscribed symbols (canonical names that resolved successfully)
|
||||
self._subscribed: List[str] = []
|
||||
|
||||
# ── Identity ──
|
||||
|
||||
@property
|
||||
def provider_type(self) -> str:
|
||||
return "rithmic"
|
||||
|
||||
@property
|
||||
def provider_id(self) -> str:
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self._label
|
||||
|
||||
# ── Capabilities ──
|
||||
|
||||
@property
|
||||
def can_execute(self) -> bool:
|
||||
return False # Data-only for now
|
||||
|
||||
@property
|
||||
def can_stream_ticks(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supported_timeframes(self) -> List[str]:
|
||||
return list(RITHMIC_TF_MAP.keys())
|
||||
|
||||
# ── Connection Lifecycle ──
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Synchronous connect — for use in non-async contexts (test scripts).
|
||||
|
||||
In production (data_server.py), use async_connect() instead.
|
||||
This method uses asyncio.run() which blocks the calling thread.
|
||||
"""
|
||||
if not RITHMIC_AVAILABLE:
|
||||
log.warning("async_rithmic package not installed — "
|
||||
"run: pip install async_rithmic")
|
||||
return False
|
||||
|
||||
if self._connected:
|
||||
return True
|
||||
|
||||
if not self._user or not self._password or not self._url:
|
||||
log.error("Rithmic credentials missing — "
|
||||
"check [rithmic] in local_config.ini")
|
||||
return False
|
||||
|
||||
try:
|
||||
return asyncio.run(self.async_connect())
|
||||
except Exception as e:
|
||||
log.error(f"Rithmic connect error: {e}")
|
||||
return False
|
||||
|
||||
async def async_connect(self) -> bool:
|
||||
"""
|
||||
Async connect — runs in the caller's event loop.
|
||||
|
||||
Called from data_server.py lifespan:
|
||||
connected = await provider.async_connect()
|
||||
|
||||
1. Create RithmicClient
|
||||
2. Connect to server
|
||||
3. Resolve front month contracts
|
||||
4. Subscribe to BBO ticks + M1 time bars
|
||||
"""
|
||||
if not RITHMIC_AVAILABLE:
|
||||
log.warning("async_rithmic not installed")
|
||||
return False
|
||||
|
||||
if self._connected:
|
||||
return True
|
||||
|
||||
if not self._user or not self._password or not self._url:
|
||||
log.error("Rithmic credentials missing")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
self._client = RithmicClient(
|
||||
user=self._user,
|
||||
password=self._password,
|
||||
system_name=self._system_name,
|
||||
app_name=self._app_name,
|
||||
app_version=self._app_version,
|
||||
url=self._url,
|
||||
)
|
||||
|
||||
await self._client.connect()
|
||||
log.info("Rithmic client connected to server")
|
||||
|
||||
# Register event callbacks
|
||||
self._client.on_tick += self._on_tick
|
||||
self._client.on_time_bar += self._on_time_bar
|
||||
|
||||
# Resolve front month contracts
|
||||
for canonical, mapping in self._symbol_map.items():
|
||||
base = mapping["base"]
|
||||
exchange = mapping["exchange"]
|
||||
try:
|
||||
front = await self._client.get_front_month_contract(
|
||||
base, exchange
|
||||
)
|
||||
self._front_months[canonical] = front
|
||||
self._reverse_map[front] = canonical
|
||||
log.info(f" {canonical} -> {front} ({exchange})")
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f" {canonical} -> FAILED to resolve "
|
||||
f"{base}@{exchange}: {e}"
|
||||
)
|
||||
|
||||
# Subscribe to live data for resolved symbols
|
||||
for canonical, contract in self._front_months.items():
|
||||
exchange = self._symbol_map[canonical]["exchange"]
|
||||
try:
|
||||
# Subscribe to BBO ticks
|
||||
data_type = DataType.LAST_TRADE | DataType.BBO
|
||||
await self._client.subscribe_to_market_data(
|
||||
contract, exchange, data_type
|
||||
)
|
||||
|
||||
# Subscribe to M1 time bars
|
||||
await self._client.subscribe_to_time_bar_data(
|
||||
contract, exchange, TimeBarType.MINUTE_BAR, 1
|
||||
)
|
||||
|
||||
self._subscribed.append(canonical)
|
||||
|
||||
# Initialize bar buffer
|
||||
self._bar_buffers[f"{canonical}_M1"] = deque(
|
||||
maxlen=self._max_bar_buffer
|
||||
)
|
||||
|
||||
log.info(
|
||||
f" Subscribed: {canonical} ({contract}@{exchange})"
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f" Subscribe failed for {canonical} "
|
||||
f"({contract}@{exchange}): {e}"
|
||||
)
|
||||
|
||||
if self._subscribed:
|
||||
self._connected = True
|
||||
log.info(
|
||||
f"Rithmic connected: {self._system_name} | "
|
||||
f"Subscribed: {len(self._subscribed)} symbols | "
|
||||
f"URL: {self._url}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
log.error("No symbols subscribed — connection not useful")
|
||||
await self._async_disconnect()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Rithmic async connect failed: {e}")
|
||||
return False
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""
|
||||
Synchronous disconnect — for use in non-async contexts.
|
||||
|
||||
In production, use async_disconnect() instead, or this method
|
||||
will schedule the disconnect in the running event loop.
|
||||
"""
|
||||
if not self._connected:
|
||||
return
|
||||
|
||||
# If we have a reference to the event loop and it's running,
|
||||
# schedule the async disconnect
|
||||
if self._loop and self._loop.is_running():
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._async_disconnect(), self._loop
|
||||
)
|
||||
try:
|
||||
future.result(timeout=10)
|
||||
except Exception as e:
|
||||
log.warning(f"Rithmic disconnect error: {e}")
|
||||
else:
|
||||
# No running loop — try asyncio.run as last resort
|
||||
try:
|
||||
asyncio.run(self._async_disconnect())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._connected = False
|
||||
self._subscribed.clear()
|
||||
self._front_months.clear()
|
||||
self._reverse_map.clear()
|
||||
self._tick_cache.clear()
|
||||
self._bar_buffers.clear()
|
||||
self._client = None
|
||||
log.info("Rithmic disconnected")
|
||||
|
||||
async def async_disconnect(self) -> None:
|
||||
"""Async disconnect — called from lifespan shutdown."""
|
||||
await self._async_disconnect()
|
||||
self._connected = False
|
||||
self._subscribed.clear()
|
||||
self._front_months.clear()
|
||||
self._reverse_map.clear()
|
||||
self._tick_cache.clear()
|
||||
self._bar_buffers.clear()
|
||||
self._client = None
|
||||
log.info("Rithmic disconnected")
|
||||
|
||||
def heartbeat(self) -> bool:
|
||||
"""Check if the Rithmic connection is alive."""
|
||||
# async_rithmic handles heartbeats internally
|
||||
return self._connected
|
||||
|
||||
# ── Market Data ──
|
||||
|
||||
def get_latest_ticks(self, symbols: List[str]) -> Dict[str, TickData]:
|
||||
"""Return latest cached ticks for requested symbols."""
|
||||
result = {}
|
||||
for s in symbols:
|
||||
if s in self._tick_cache:
|
||||
result[s] = self._tick_cache[s]
|
||||
return result
|
||||
|
||||
def get_bars(
|
||||
self, ticker: str, timeframe: str = "M15", count: int = 200
|
||||
) -> List[BarData]:
|
||||
"""
|
||||
Fetch bars for a canonical ticker.
|
||||
|
||||
Checks local buffer first. If not enough bars, fetches from
|
||||
Rithmic history plant via the event loop.
|
||||
"""
|
||||
if ticker not in self._front_months:
|
||||
return []
|
||||
|
||||
# Check buffer first
|
||||
buf_key = f"{ticker}_{timeframe}"
|
||||
if buf_key in self._bar_buffers:
|
||||
bars = list(self._bar_buffers[buf_key])
|
||||
if len(bars) >= count:
|
||||
return bars[-count:]
|
||||
|
||||
# Fetch from history — need the event loop
|
||||
if not self._loop or not self._loop.is_running():
|
||||
return []
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._async_get_bars(ticker, timeframe, count),
|
||||
self._loop,
|
||||
)
|
||||
return future.result(timeout=30)
|
||||
except Exception as e:
|
||||
log.warning(f"[{ticker}] Historical bar fetch failed: {e}")
|
||||
return []
|
||||
|
||||
def check_new_bars(
|
||||
self, symbols: List[str], timeframe: str = "M15"
|
||||
) -> List[dict]:
|
||||
"""Return bars received since last check."""
|
||||
new_bars = []
|
||||
for s in symbols:
|
||||
buf_key = f"{s}_{timeframe}"
|
||||
if buf_key not in self._bar_buffers:
|
||||
continue
|
||||
|
||||
track_key = f"{s}_{timeframe}"
|
||||
last_time = self._last_bar_times.get(track_key)
|
||||
buf = self._bar_buffers[buf_key]
|
||||
|
||||
for bar in buf:
|
||||
if last_time is None or bar.time > last_time:
|
||||
new_bars.append({
|
||||
"type": "bar",
|
||||
"ticker": s,
|
||||
"timeframe": timeframe,
|
||||
"bar": bar.to_dict(),
|
||||
})
|
||||
self._last_bar_times[track_key] = bar.time
|
||||
|
||||
return new_bars
|
||||
|
||||
def get_symbol_info(self, ticker: str) -> Optional[SymbolInfo]:
|
||||
"""Return static futures contract metadata."""
|
||||
if ticker not in self._symbol_map:
|
||||
return None
|
||||
|
||||
mapping = self._symbol_map[ticker]
|
||||
base = mapping["base"]
|
||||
spec = FUTURES_SPECS.get(base)
|
||||
if not spec:
|
||||
return None
|
||||
|
||||
broker_symbol = self._front_months.get(ticker, base)
|
||||
|
||||
return SymbolInfo(
|
||||
ticker=ticker,
|
||||
broker_symbol=broker_symbol,
|
||||
asset_class="FUTURES",
|
||||
decimals=spec["decimals"],
|
||||
description=spec["description"],
|
||||
trade_allowed=False,
|
||||
min_lot=spec["min_lot"],
|
||||
max_lot=spec["max_lot"],
|
||||
lot_step=spec["lot_step"],
|
||||
contract_size=spec["contract_size"],
|
||||
currency_profit=spec["currency"],
|
||||
currency_margin=spec["currency"],
|
||||
tick_size=spec["tick_size"],
|
||||
tick_value=spec["tick_value"],
|
||||
)
|
||||
|
||||
def get_account_info(self) -> Optional[AccountInfo]:
|
||||
"""Not applicable for data-only provider."""
|
||||
return None
|
||||
|
||||
# ── Symbol Resolution ──
|
||||
|
||||
def resolve_symbol(self, canonical: str) -> Optional[str]:
|
||||
"""Map canonical ticker to resolved Rithmic contract code."""
|
||||
return self._front_months.get(canonical)
|
||||
|
||||
def resolve_universe(self, universe: List[str]) -> List[str]:
|
||||
"""Return which universe tickers this provider can serve."""
|
||||
return [t for t in universe if t in self._front_months]
|
||||
|
||||
def get_available_symbols(self) -> List[str]:
|
||||
"""Return canonical symbols that were successfully subscribed."""
|
||||
return list(self._subscribed)
|
||||
|
||||
# ============================================================
|
||||
# INTERNAL — Async Operations
|
||||
# ============================================================
|
||||
|
||||
async def _async_disconnect(self) -> None:
|
||||
"""Async cleanup: unsubscribe and disconnect."""
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
try:
|
||||
for canonical in self._subscribed:
|
||||
if canonical not in self._front_months:
|
||||
continue
|
||||
contract = self._front_months[canonical]
|
||||
exchange = self._symbol_map[canonical]["exchange"]
|
||||
try:
|
||||
await self._client.unsubscribe_from_market_data(
|
||||
contract, exchange,
|
||||
DataType.LAST_TRADE | DataType.BBO,
|
||||
)
|
||||
await self._client.unsubscribe_from_time_bar_data(
|
||||
contract, exchange, TimeBarType.MINUTE_BAR, 1,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await self._client.disconnect()
|
||||
except Exception as e:
|
||||
log.warning(f"Rithmic async disconnect error: {e}")
|
||||
|
||||
async def _async_get_bars(
|
||||
self, ticker: str, timeframe: str, count: int
|
||||
) -> List[BarData]:
|
||||
"""Fetch historical time bars from Rithmic history plant."""
|
||||
if ticker not in self._front_months or not self._client:
|
||||
return []
|
||||
|
||||
contract = self._front_months[ticker]
|
||||
exchange = self._symbol_map[ticker]["exchange"]
|
||||
|
||||
tf_info = RITHMIC_TF_MAP.get(timeframe)
|
||||
if not tf_info:
|
||||
log.warning(f"[{ticker}] Unsupported timeframe: {timeframe}")
|
||||
return []
|
||||
|
||||
bar_type_name, period = tf_info
|
||||
|
||||
if bar_type_name == "MINUTE_BAR":
|
||||
bar_type = TimeBarType.MINUTE_BAR
|
||||
elif bar_type_name == "DAILY_BAR":
|
||||
bar_type = TimeBarType.DAILY_BAR
|
||||
else:
|
||||
bar_type = TimeBarType.MINUTE_BAR
|
||||
|
||||
# Calculate time window
|
||||
minutes_per_bar = TF_MINUTES.get(timeframe, 15)
|
||||
total_minutes = int(count * minutes_per_bar * 1.2) # 20% buffer
|
||||
end_time = datetime.now(timezone.utc)
|
||||
start_time = end_time - timedelta(minutes=total_minutes)
|
||||
|
||||
try:
|
||||
raw_bars = await self._client.get_time_bars(
|
||||
contract, exchange, bar_type, period,
|
||||
start_time, end_time,
|
||||
)
|
||||
|
||||
bars = []
|
||||
for rb in raw_bars:
|
||||
bar = self._parse_bar(rb)
|
||||
if bar:
|
||||
bars.append(bar)
|
||||
|
||||
bars.sort(key=lambda b: b.time)
|
||||
bars = bars[-count:]
|
||||
|
||||
# Cache in buffer
|
||||
buf_key = f"{ticker}_{timeframe}"
|
||||
self._bar_buffers[buf_key] = deque(bars, maxlen=self._max_bar_buffer)
|
||||
|
||||
log.info(
|
||||
f"[{ticker}] Fetched {len(bars)} {timeframe} bars "
|
||||
f"from Rithmic history"
|
||||
)
|
||||
return bars
|
||||
|
||||
except AttributeError:
|
||||
log.warning(
|
||||
f"[{ticker}] get_time_bars() not available. "
|
||||
f"Bars will accumulate from live stream."
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
log.warning(f"[{ticker}] Historical bar fetch error: {e}")
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# INTERNAL — Streaming Callbacks
|
||||
# ============================================================
|
||||
|
||||
async def _on_tick(self, data: dict) -> None:
|
||||
"""Callback for live tick data. Updates tick cache."""
|
||||
try:
|
||||
security_code = data.get("symbol", "")
|
||||
canonical = self._reverse_map.get(security_code)
|
||||
if not canonical:
|
||||
return
|
||||
|
||||
data_type = data.get("data_type")
|
||||
presence = data.get("presence_bits", 0)
|
||||
|
||||
# Preserve existing values
|
||||
existing = self._tick_cache.get(canonical)
|
||||
bid = existing.bid if existing else 0.0
|
||||
ask = existing.ask if existing else 0.0
|
||||
last = existing.last if existing else 0.0
|
||||
|
||||
if data_type == DataType.BBO:
|
||||
if presence & BestBidOfferPresenceBits.BID:
|
||||
bid = float(data.get("bid_price", bid))
|
||||
if presence & BestBidOfferPresenceBits.ASK:
|
||||
ask = float(data.get("ask_price", ask))
|
||||
elif data_type == DataType.LAST_TRADE:
|
||||
if presence & LastTradePresenceBits.LAST_TRADE:
|
||||
last = float(data.get("trade_price", last))
|
||||
|
||||
if bid > 0 or ask > 0 or last > 0:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
spread = round(ask - bid, 6) if (bid > 0 and ask > 0) else 0.0
|
||||
if last == 0 and bid > 0:
|
||||
last = (bid + ask) / 2
|
||||
|
||||
self._tick_cache[canonical] = TickData(
|
||||
ticker=canonical,
|
||||
bid=bid,
|
||||
ask=ask,
|
||||
last=last,
|
||||
time=now,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Tick callback error: {e}")
|
||||
|
||||
async def _on_time_bar(self, data: dict) -> None:
|
||||
"""Callback for live time bar data. Adds to ring buffer."""
|
||||
try:
|
||||
security_code = data.get("symbol", "")
|
||||
canonical = self._reverse_map.get(security_code)
|
||||
if not canonical:
|
||||
return
|
||||
|
||||
bar = self._parse_bar(data)
|
||||
if not bar:
|
||||
return
|
||||
|
||||
period = data.get("period", 1)
|
||||
tf_key = self._period_to_tf(period)
|
||||
buf_key = f"{canonical}_{tf_key}"
|
||||
|
||||
if buf_key not in self._bar_buffers:
|
||||
self._bar_buffers[buf_key] = deque(
|
||||
maxlen=self._max_bar_buffer
|
||||
)
|
||||
self._bar_buffers[buf_key].append(bar)
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Time bar callback error: {e}")
|
||||
|
||||
# ============================================================
|
||||
# INTERNAL — Helpers
|
||||
# ============================================================
|
||||
|
||||
def _parse_bar(self, data: dict) -> Optional[BarData]:
|
||||
"""Parse a raw Rithmic bar dict into a BarData object."""
|
||||
try:
|
||||
open_p = float(data.get("open_price", data.get("open", 0)))
|
||||
high_p = float(data.get("high_price", data.get("high", 0)))
|
||||
low_p = float(data.get("low_price", data.get("low", 0)))
|
||||
close_p = float(data.get("close_price", data.get("close", 0)))
|
||||
volume = int(data.get("volume", 0))
|
||||
|
||||
bar_time = data.get("bar_end_time", data.get("time", ""))
|
||||
if isinstance(bar_time, datetime):
|
||||
time_str = bar_time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
elif isinstance(bar_time, (int, float)):
|
||||
time_str = datetime.fromtimestamp(
|
||||
bar_time, tz=timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
else:
|
||||
time_str = str(bar_time)
|
||||
|
||||
if open_p == 0 and close_p == 0:
|
||||
return None
|
||||
|
||||
return BarData(
|
||||
time=time_str,
|
||||
open=open_p,
|
||||
high=high_p,
|
||||
low=low_p,
|
||||
close=close_p,
|
||||
volume=volume,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _period_to_tf(period: int) -> str:
|
||||
"""Convert minute period to our timeframe string."""
|
||||
tf_map = {1: "M1", 5: "M5", 15: "M15", 30: "M30", 60: "H1", 240: "H4"}
|
||||
return tf_map.get(period, "M1")
|
||||
@@ -0,0 +1,454 @@
|
||||
# version: v1
|
||||
"""
|
||||
================================================================================
|
||||
Quantum Terminal — Tradovate Provider (POC)
|
||||
|
||||
Proof-of-work integration with Tradovate for users who don't run MT5.
|
||||
|
||||
What this does:
|
||||
· Authenticates against /auth/accesstokenrequest (REST)
|
||||
· Resolves CFD-style tickers (XAUUSD, US500, ...) → Tradovate futures roots
|
||||
(GC, ES, ...) → front-month contracts (GCM6, ESM6, ...)
|
||||
· Fetches historical bars over the market-data WebSocket (md/getChart)
|
||||
|
||||
What this does NOT do (deferred to later phases):
|
||||
· Live tick streaming (comes next)
|
||||
· Order placement (futures orders are quite different from CFDs)
|
||||
· Price conversion between CFD and futures price space
|
||||
· Rollover-adjusted continuous contracts
|
||||
· Settlement/margin accounting
|
||||
|
||||
Reversibility: this file is standalone. Nothing in MT5 or the base terminal
|
||||
touches it. Delete the file + one route-include line and Tradovate goes
|
||||
away entirely.
|
||||
================================================================================
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
log = logging.getLogger("tradovate_provider")
|
||||
|
||||
# ─── Dependencies (both already in consumer_venv) ────────────────────────
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
httpx = None
|
||||
log.warning("httpx not installed — Tradovate provider will be inert")
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
websockets = None
|
||||
log.warning("websockets not installed — Tradovate provider will be inert")
|
||||
|
||||
|
||||
# ─── Endpoint config ─────────────────────────────────────────────────────
|
||||
ENDPOINTS = {
|
||||
"demo": {
|
||||
"rest": "https://demo.tradovateapi.com/v1",
|
||||
"md_ws": "wss://md-demo.tradovateapi.com/v1/websocket",
|
||||
},
|
||||
"live": {
|
||||
"rest": "https://live.tradovateapi.com/v1",
|
||||
"md_ws": "wss://md.tradovateapi.com/v1/websocket",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── CFD → Futures root map (POC v1) ────────────────────────────────────
|
||||
# Add more entries as we validate them. Unmapped tickers raise a clean
|
||||
# "symbol not supported by Tradovate" error rather than crashing.
|
||||
CFD_TO_FUTURES_ROOT = {
|
||||
"XAUUSD": "GC", # Gold futures (100 oz, COMEX)
|
||||
"XAGUSD": "SI", # Silver futures (5,000 oz, COMEX)
|
||||
"XTIUSD": "CL", # Crude oil futures (1,000 bbl, NYMEX)
|
||||
"US500": "ES", # E-mini S&P 500 (CME)
|
||||
"USTEC": "NQ", # E-mini Nasdaq 100 (CME)
|
||||
"GER40": "FDAX", # DAX futures (Eurex) — requires exchange subscription
|
||||
"BTCUSD": "BTC", # Micro Bitcoin or BTC futures (CME)
|
||||
# FX / UK100 / SOLUSD intentionally unmapped for POC.
|
||||
}
|
||||
|
||||
# ─── Futures month codes ────────────────────────────────────────────────
|
||||
MONTH_CODES = {1:"F", 2:"G", 3:"H", 4:"J", 5:"K", 6:"M",
|
||||
7:"N", 8:"Q", 9:"U", 10:"V", 11:"X", 12:"Z"}
|
||||
|
||||
# Which months does each product expire in?
|
||||
# Quarterlies = Mar/Jun/Sep/Dec, monthly = every month, bi-monthly = even months.
|
||||
PRODUCT_EXPIRY_MONTHS = {
|
||||
"ES": [3, 6, 9, 12], # E-mini S&P — quarterly
|
||||
"NQ": [3, 6, 9, 12],
|
||||
"GC": [2, 4, 6, 8, 10, 12], # Gold — bi-monthly
|
||||
"SI": [1, 3, 5, 7, 9, 12], # Silver — not every month
|
||||
"CL": list(range(1, 13)), # Crude — monthly
|
||||
"BTC": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], # Bitcoin — monthly
|
||||
"FDAX":[3, 6, 9, 12],
|
||||
}
|
||||
|
||||
|
||||
# ─── Timeframe → Tradovate chart description ────────────────────────────
|
||||
def _tf_to_chart_desc(timeframe: str) -> Dict[str, Any]:
|
||||
tf_map = {
|
||||
"M1": ("MinuteBar", 1),
|
||||
"M5": ("MinuteBar", 5),
|
||||
"M15": ("MinuteBar", 15),
|
||||
"M30": ("MinuteBar", 30),
|
||||
"H1": ("MinuteBar", 60),
|
||||
"H4": ("MinuteBar", 240),
|
||||
"D1": ("DailyBar", 1),
|
||||
}
|
||||
underlying, size = tf_map.get(timeframe.upper(), ("MinuteBar", 15))
|
||||
return {
|
||||
"underlyingType": underlying,
|
||||
"elementSize": size,
|
||||
"elementSizeUnit": "UnderlyingUnits",
|
||||
"withHistogram": False,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradovateAuth:
|
||||
access_token: str = ""
|
||||
md_access_token: str = ""
|
||||
user_id: int = 0
|
||||
name: str = ""
|
||||
has_live: bool = False
|
||||
user_status: str = ""
|
||||
expires_at: float = 0.0 # epoch seconds
|
||||
md_expires_at: float = 0.0
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
return bool(self.access_token) and time.time() < self.expires_at - 30
|
||||
|
||||
|
||||
class TradovateProvider:
|
||||
"""POC Tradovate provider. Not a full BaseProvider subclass yet —
|
||||
we'll upgrade to that once the smoke test passes. For now it exposes
|
||||
the methods tradovate_routes.py needs to service the POC endpoint."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self._config = config
|
||||
self._auth = TradovateAuth()
|
||||
self._last_error: str = ""
|
||||
|
||||
# ── Config updates (called from PATCH /api/tradovate/config) ──
|
||||
def update_config(self, new_config: Dict[str, Any]) -> None:
|
||||
self._config.update(new_config or {})
|
||||
# Invalidate cached auth so the next connect uses new creds.
|
||||
self._auth = TradovateAuth()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._auth.is_valid()
|
||||
|
||||
@property
|
||||
def is_delayed(self) -> bool:
|
||||
# Free Tradovate demo accounts are delayed 10 minutes for most CME
|
||||
# products. hasLive=True means the user has paid live market data;
|
||||
# False means delayed. Real instrument-by-instrument delay info comes
|
||||
# from md/getContract-like queries — POC just surfaces the boolean.
|
||||
return not self._auth.has_live
|
||||
|
||||
@property
|
||||
def status_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"connected": self.connected,
|
||||
"user_id": self._auth.user_id,
|
||||
"name": self._auth.name,
|
||||
"user_status": self._auth.user_status,
|
||||
"has_live": self._auth.has_live,
|
||||
"delayed": self.is_delayed,
|
||||
"env": self._config.get("env", "demo"),
|
||||
"expires_at": self._auth.expires_at,
|
||||
"error": self._last_error,
|
||||
}
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────
|
||||
async def authenticate(self) -> Dict[str, Any]:
|
||||
self._last_error = ""
|
||||
if httpx is None:
|
||||
self._last_error = "httpx not installed in venv"
|
||||
return {"success": False, "error": self._last_error}
|
||||
cfg = self._config
|
||||
env = (cfg.get("env") or "demo").lower()
|
||||
if env not in ENDPOINTS:
|
||||
self._last_error = f"unknown env '{env}' (expected demo or live)"
|
||||
return {"success": False, "error": self._last_error}
|
||||
required = ["app_id", "cid", "sec", "username", "password"]
|
||||
missing = [k for k in required if not cfg.get(k)]
|
||||
if missing:
|
||||
self._last_error = f"missing creds: {', '.join(missing)}"
|
||||
return {"success": False, "error": self._last_error}
|
||||
|
||||
url = ENDPOINTS[env]["rest"] + "/auth/accesstokenrequest"
|
||||
body = {
|
||||
"name": cfg["username"],
|
||||
"password": cfg["password"],
|
||||
"appId": cfg["app_id"],
|
||||
"appVersion": cfg.get("app_version", "1.0"),
|
||||
"cid": int(cfg["cid"]),
|
||||
"sec": cfg["sec"],
|
||||
"deviceId": cfg.get("device_id", "QuantumTerminal-consumer-poc"),
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.post(url, json=body)
|
||||
if r.status_code != 200:
|
||||
self._last_error = f"HTTP {r.status_code}: {r.text[:200]}"
|
||||
return {"success": False, "error": self._last_error}
|
||||
data = r.json()
|
||||
except Exception as e:
|
||||
self._last_error = f"auth request failed: {e}"
|
||||
log.error(self._last_error)
|
||||
return {"success": False, "error": self._last_error}
|
||||
|
||||
# Tradovate returns error info inside a 200 response for bad creds
|
||||
err_code = data.get("errorText") or data.get("p-ticket")
|
||||
if err_code and not data.get("accessToken"):
|
||||
self._last_error = f"tradovate rejected auth: {err_code}"
|
||||
return {"success": False, "error": self._last_error}
|
||||
|
||||
# Parse token expiration — Tradovate returns ISO8601 in expirationTime
|
||||
def _parse_iso(s):
|
||||
if not s:
|
||||
return time.time() + 4000 # ~66 min fallback
|
||||
try:
|
||||
s = s.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(s).timestamp()
|
||||
except Exception:
|
||||
return time.time() + 4000
|
||||
|
||||
self._auth = TradovateAuth(
|
||||
access_token = data.get("accessToken", ""),
|
||||
md_access_token= data.get("mdAccessToken", ""),
|
||||
user_id = data.get("userId", 0) or 0,
|
||||
name = data.get("name", "") or cfg["username"],
|
||||
has_live = bool(data.get("hasLive", False)),
|
||||
user_status = data.get("userStatus", "") or "",
|
||||
expires_at = _parse_iso(data.get("expirationTime")),
|
||||
md_expires_at = _parse_iso(data.get("expirationTime")),
|
||||
)
|
||||
if not self._auth.access_token:
|
||||
self._last_error = "no accessToken in response"
|
||||
return {"success": False, "error": self._last_error}
|
||||
return {"success": True, **self.status_dict}
|
||||
|
||||
def disconnect(self) -> None:
|
||||
self._auth = TradovateAuth()
|
||||
self._last_error = ""
|
||||
|
||||
# ── Symbol resolution ─────────────────────────────────────────
|
||||
def _resolve_root(self, ticker: str) -> Optional[str]:
|
||||
t = (ticker or "").upper()
|
||||
if t in CFD_TO_FUTURES_ROOT:
|
||||
return CFD_TO_FUTURES_ROOT[t]
|
||||
# Pass-through: if the user passes a root directly (e.g. "GC"), accept
|
||||
if t in PRODUCT_EXPIRY_MONTHS:
|
||||
return t
|
||||
return None
|
||||
|
||||
def _front_month_contract(self, root: str, today: Optional[datetime] = None) -> str:
|
||||
"""Cheap calendar-based front-month resolver. Good enough for POC.
|
||||
For production we should hit /contract/suggest for accurate roll
|
||||
timing (contracts roll a few days before the last notice date).
|
||||
"""
|
||||
months = PRODUCT_EXPIRY_MONTHS.get(root)
|
||||
if not months:
|
||||
# Default to quarterly if unknown
|
||||
months = [3, 6, 9, 12]
|
||||
now = today or datetime.now(timezone.utc)
|
||||
y, m = now.year, now.month
|
||||
# Pick the next expiry month that's >= current month. Add 5-day lead
|
||||
# to avoid the last-trading-day rush — we want the LIQUID contract.
|
||||
lead_day = now.day >= 10
|
||||
candidate_month = None
|
||||
for mm in months:
|
||||
if mm > m or (mm == m and not lead_day):
|
||||
candidate_month = mm
|
||||
break
|
||||
if candidate_month is None:
|
||||
# Wrap to next year's first expiry month
|
||||
candidate_month = months[0]
|
||||
y += 1
|
||||
return f"{root}{MONTH_CODES[candidate_month]}{y % 10}"
|
||||
|
||||
async def resolve_contract_via_api(self, root: str) -> Optional[str]:
|
||||
"""Ask Tradovate to suggest the current tradeable contract. Used as
|
||||
a fallback/verification — if this disagrees with the calendar
|
||||
heuristic we prefer this answer."""
|
||||
if httpx is None or not self._auth.access_token:
|
||||
return None
|
||||
url = ENDPOINTS[self._config.get("env", "demo")]["rest"] + "/contract/suggest"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(
|
||||
url, params={"t": root, "l": 5},
|
||||
headers={"Authorization": f"Bearer {self._auth.access_token}"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
contracts = r.json() or []
|
||||
# Return the shortest-name contract (usually the front month).
|
||||
if contracts:
|
||||
contracts.sort(key=lambda c: len(c.get("name", "")))
|
||||
return contracts[0].get("name")
|
||||
except Exception as e:
|
||||
log.warning(f"contract/suggest failed for {root}: {e}")
|
||||
return None
|
||||
|
||||
# ── Market-data WebSocket helpers ─────────────────────────────
|
||||
async def _ws_fetch_bars(self, contract: str, timeframe: str, count: int) -> List[Dict[str, Any]]:
|
||||
"""Open a one-shot WebSocket, auth, request bars, return them."""
|
||||
if websockets is None or not self._auth.md_access_token:
|
||||
raise RuntimeError("not authenticated or websockets missing")
|
||||
env = self._config.get("env", "demo")
|
||||
url = ENDPOINTS[env]["md_ws"]
|
||||
|
||||
# Tradovate WebSocket protocol: each message is a plaintext frame
|
||||
# <endpoint>\n<id>\n\n<body>
|
||||
# Responses come as JSON arrays prefixed by "a" (for "array frames")
|
||||
# and single-letter frames "o" (open), "h" (heartbeat), "c" (close).
|
||||
def _encode(endpoint: str, msg_id: int, body: Any = "") -> str:
|
||||
body_str = json.dumps(body) if not isinstance(body, str) else body
|
||||
return f"{endpoint}\n{msg_id}\n\n{body_str}"
|
||||
|
||||
bars: List[Dict[str, Any]] = []
|
||||
base_price = 0.0
|
||||
tick_size = 0.01
|
||||
tick_mult = 1.0
|
||||
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
|
||||
# 1st server frame should be "o"
|
||||
open_frame = await asyncio.wait_for(ws.recv(), timeout=10.0)
|
||||
if not (isinstance(open_frame, str) and open_frame.startswith("o")):
|
||||
raise RuntimeError(f"unexpected open frame: {open_frame!r}")
|
||||
|
||||
# Authorize with MD token
|
||||
await ws.send(_encode("authorize", 1, self._auth.md_access_token))
|
||||
|
||||
# Request chart
|
||||
chart_req = {
|
||||
"symbol": contract,
|
||||
"chartDescription": _tf_to_chart_desc(timeframe),
|
||||
"timeRange": {"asMuchAsElements": max(1, min(int(count), 2000))},
|
||||
}
|
||||
await ws.send(_encode("md/getChart", 2, chart_req))
|
||||
|
||||
# Read frames until we receive the charts packet for our id
|
||||
deadline = time.time() + 20.0
|
||||
subscription_id: Optional[int] = None
|
||||
while time.time() < deadline and len(bars) < count:
|
||||
try:
|
||||
frame = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if not isinstance(frame, str):
|
||||
continue
|
||||
# "h" = heartbeat — send one back to stay alive
|
||||
if frame == "h":
|
||||
await ws.send("[]")
|
||||
continue
|
||||
if frame.startswith("a"):
|
||||
try:
|
||||
arr = json.loads(frame[1:])
|
||||
except Exception:
|
||||
continue
|
||||
for item in arr or []:
|
||||
# Response to md/getChart (id=2) returns subscription id
|
||||
if item.get("i") == 2 and item.get("s") == 200:
|
||||
body = item.get("d") or {}
|
||||
subscription_id = body.get("subscriptionId") or body.get("id")
|
||||
# Streaming chart data comes with "e": "chart"
|
||||
if item.get("e") == "chart":
|
||||
cd = item.get("d") or {}
|
||||
charts = cd.get("charts") or []
|
||||
for ch in charts:
|
||||
base_price = ch.get("bp", base_price)
|
||||
tick_size = ch.get("ts", tick_size) or tick_size
|
||||
tick_mult = ch.get("tm", tick_mult) or tick_mult
|
||||
raw_bars = ch.get("bars") or []
|
||||
for b in raw_bars:
|
||||
bars.append(_decode_bar(b, base_price, tick_size, tick_mult))
|
||||
|
||||
# Clean up — unsubscribe if we got a subscription id
|
||||
if subscription_id is not None:
|
||||
try:
|
||||
await ws.send(_encode("md/cancelChart", 3, {"subscriptionId": subscription_id}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
self._last_error = f"ws fetch failed: {e}"
|
||||
log.error(self._last_error)
|
||||
raise
|
||||
|
||||
return bars[-count:] if len(bars) > count else bars
|
||||
|
||||
async def get_bars(self, ticker: str, timeframe: str = "M15", count: int = 200) -> List[Dict[str, Any]]:
|
||||
"""Main entry point. Maps ticker → contract, fetches bars via WS."""
|
||||
if not self._auth.is_valid():
|
||||
raise RuntimeError("not authenticated")
|
||||
root = self._resolve_root(ticker)
|
||||
if not root:
|
||||
raise ValueError(f"{ticker} not mapped to a Tradovate futures root")
|
||||
# Try API-based resolution first, fall back to calendar heuristic.
|
||||
contract = await self.resolve_contract_via_api(root)
|
||||
if not contract:
|
||||
contract = self._front_month_contract(root)
|
||||
bars = await self._ws_fetch_bars(contract, timeframe, count)
|
||||
return bars
|
||||
|
||||
|
||||
# ─── Bar decoding helper ──────────────────────────────────────────
|
||||
def _decode_bar(b: Dict[str, Any], base_price: float, tick_size: float, tick_mult: float) -> Dict[str, Any]:
|
||||
"""Tradovate returns bars with fields encoded as offsets from a base
|
||||
price in tick units. Decode to absolute OHLC floats."""
|
||||
# Some Tradovate responses send bars already in absolute prices; others
|
||||
# send deltas. Handle both by checking if base_price + tick_size make
|
||||
# the output make sense.
|
||||
def _px(val):
|
||||
if val is None:
|
||||
return None
|
||||
# Heuristic: if val is very small (|val| < 1e6) and base_price > 0
|
||||
# and tick_size > 0, treat as offset. Otherwise treat as absolute.
|
||||
if base_price > 0 and tick_size > 0 and abs(val) < 1_000_000:
|
||||
return base_price + (val * tick_size * (tick_mult or 1))
|
||||
return float(val)
|
||||
|
||||
ts = b.get("timestamp") or b.get("t")
|
||||
# timestamp can be ISO string or epoch ms
|
||||
if isinstance(ts, (int, float)):
|
||||
iso = datetime.fromtimestamp(ts / (1000 if ts > 1e11 else 1), tz=timezone.utc) \
|
||||
.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
elif isinstance(ts, str):
|
||||
iso = ts.replace("Z", "").split(".")[0]
|
||||
else:
|
||||
iso = ""
|
||||
|
||||
return {
|
||||
"time": iso,
|
||||
"open": _px(b.get("open")) or 0.0,
|
||||
"high": _px(b.get("high")) or 0.0,
|
||||
"low": _px(b.get("low")) or 0.0,
|
||||
"close": _px(b.get("close")) or 0.0,
|
||||
"volume": int(b.get("upVolume", 0) or 0) + int(b.get("downVolume", 0) or 0)
|
||||
or int(b.get("volume", 0) or 0),
|
||||
}
|
||||
|
||||
|
||||
# ─── Singleton accessor (one provider instance per process) ─────
|
||||
_instance: Optional[TradovateProvider] = None
|
||||
|
||||
def get_tradovate_provider(config: Optional[Dict[str, Any]] = None) -> TradovateProvider:
|
||||
global _instance
|
||||
if _instance is None:
|
||||
_instance = TradovateProvider(config or {})
|
||||
elif config:
|
||||
_instance.update_config(config)
|
||||
return _instance
|
||||
Reference in New Issue
Block a user