""" Demo data source with synthetic data. Generates realistic-looking OHLCV data plus additional columns for testing. """ import asyncio import logging import math import random import time from typing import Callable, Dict, List, Optional from ..base import DataSource logger = logging.getLogger(__name__) from ..schema import ( Bar, ColumnInfo, DatafeedConfig, HistoryResult, Resolution, SearchResult, SymbolInfo, ) class DemoDataSource(DataSource): """ Demo data source that generates synthetic time-series data. Provides: - Standard OHLCV columns - Additional demo columns (RSI, sentiment, volume_profile) - Real-time updates via polling simulation """ def __init__(self): self._subscriptions: Dict[str, asyncio.Task] = {} self._symbols = { "DEMO:BTC/USD": { "name": "Bitcoin", "type": "crypto", "base_price": 50000.0, "volatility": 0.02, }, "DEMO:ETH/USD": { "name": "Ethereum", "type": "crypto", "base_price": 3000.0, "volatility": 0.03, }, "DEMO:SOL/USD": { "name": "Solana", "type": "crypto", "base_price": 100.0, "volatility": 0.04, }, } async def get_config(self) -> DatafeedConfig: return DatafeedConfig( name="Demo DataSource", description="Synthetic data generator for testing. Provides OHLCV plus additional indicator columns.", supported_resolutions=[ Resolution.M1, Resolution.M5, Resolution.M15, Resolution.H1, Resolution.D1, ], supports_search=True, supports_time=True, exchanges=["DEMO"], symbols_types=["crypto"], ) async def search_symbols( self, query: str, type: Optional[str] = None, exchange: Optional[str] = None, limit: int = 30, ) -> List[SearchResult]: query_lower = query.lower() results = [] for symbol, info in self._symbols.items(): if query_lower in symbol.lower() or query_lower in info["name"].lower(): if type and info["type"] != type: continue results.append( SearchResult( symbol=info['name'], # Clean user-facing format (e.g., "Bitcoin") ticker=symbol, # Keep DEMO:BTC/USD format for routing full_name=f"{info['name']} (DEMO)", description=f"Demo {info['name']} pair", exchange="DEMO", type=info["type"], ) ) return results[:limit] async def resolve_symbol(self, symbol: str) -> SymbolInfo: if symbol not in self._symbols: raise ValueError(f"Symbol '{symbol}' not found") info = self._symbols[symbol] base, quote = symbol.split(":")[1].split("/") return SymbolInfo( symbol=info["name"], # Clean user-facing format (e.g., "Bitcoin") ticker=symbol, # Keep DEMO:BTC/USD format for routing name=info["name"], description=f"Demo {info['name']} spot price with synthetic indicators", type=info["type"], exchange="DEMO", timezone="Etc/UTC", session="24x7", supported_resolutions=[Resolution.M1, Resolution.M5, Resolution.M15, Resolution.H1, Resolution.D1], has_intraday=True, has_daily=True, has_weekly_and_monthly=False, columns=[ ColumnInfo( name="open", type="float", description=f"Opening price in {quote}", unit=quote, ), ColumnInfo( name="high", type="float", description=f"Highest price in {quote}", unit=quote, ), ColumnInfo( name="low", type="float", description=f"Lowest price in {quote}", unit=quote, ), ColumnInfo( name="close", type="float", description=f"Closing price in {quote}", unit=quote, ), ColumnInfo( name="volume", type="float", description=f"Trading volume in {base}", unit=base, ), ColumnInfo( name="rsi", type="float", description="Relative Strength Index (14-period), range 0-100", unit=None, ), ColumnInfo( name="sentiment", type="float", description="Synthetic social sentiment score, range -1.0 to 1.0", unit=None, ), ColumnInfo( name="volume_profile", type="float", description="Volume as percentage of 24h average", unit="%", ), ], time_column="time", has_ohlcv=True, pricescale=100, minmov=1, base_currency=base, quote_currency=quote, ) async def get_bars( self, symbol: str, resolution: str, from_time: int, to_time: int, countback: Optional[int] = None, ) -> HistoryResult: if symbol not in self._symbols: raise ValueError(f"Symbol '{symbol}' not found") logger.info( f"DemoDataSource.get_bars: symbol={symbol}, resolution={resolution}, " f"from_time={from_time}, to_time={to_time}, countback={countback}" ) info = self._symbols[symbol] symbol_meta = await self.resolve_symbol(symbol) # Convert resolution to seconds resolution_seconds = self._resolution_to_seconds(resolution) # Generate bars bars = [] # Align current_time to resolution, but ensure it's >= from_time current_time = from_time - (from_time % resolution_seconds) if current_time < from_time: current_time += resolution_seconds price = info["base_price"] bar_count = 0 max_bars = countback if countback else 5000 while current_time <= to_time and bar_count < max_bars: bar_data = self._generate_bar(current_time, price, info["volatility"], resolution_seconds) # Only include bars within the requested range if from_time <= current_time <= to_time: bars.append(Bar(time=current_time * 1000, data=bar_data)) # Convert to milliseconds bar_count += 1 price = bar_data["close"] # Next bar starts from previous close current_time += resolution_seconds logger.info( f"DemoDataSource.get_bars: Generated {len(bars)} bars. " f"First: {bars[0].time if bars else 'N/A'}, Last: {bars[-1].time if bars else 'N/A'}" ) # Determine if there's more data (for pagination) next_time = current_time if current_time <= to_time else None return HistoryResult( symbol=symbol, resolution=resolution, bars=bars, columns=symbol_meta.columns, nextTime=next_time, ) async def subscribe_bars( self, symbol: str, resolution: str, on_tick: Callable[[dict], None], ) -> str: if symbol not in self._symbols: raise ValueError(f"Symbol '{symbol}' not found") subscription_id = f"{symbol}:{resolution}:{time.time()}" # Start background task to simulate real-time updates task = asyncio.create_task( self._tick_generator(symbol, resolution, on_tick, subscription_id) ) self._subscriptions[subscription_id] = task return subscription_id async def unsubscribe_bars(self, subscription_id: str) -> None: task = self._subscriptions.pop(subscription_id, None) if task: task.cancel() try: await task except asyncio.CancelledError: pass def _resolution_to_seconds(self, resolution: str) -> int: """Convert resolution string to seconds""" if resolution.endswith("D"): return int(resolution[:-1]) * 86400 elif resolution.endswith("W"): return int(resolution[:-1]) * 604800 elif resolution.endswith("M"): return int(resolution[:-1]) * 2592000 # Approximate month else: # Assume minutes return int(resolution) * 60 def _generate_bar(self, timestamp: int, base_price: float, volatility: float, period_seconds: int) -> dict: """Generate a single synthetic OHLCV bar""" # Random walk for the period open_price = base_price # Generate intra-period price movement num_ticks = max(10, period_seconds // 60) # More ticks for longer periods prices = [open_price] for _ in range(num_ticks): change = random.gauss(0, volatility / math.sqrt(num_ticks)) prices.append(prices[-1] * (1 + change)) close_price = prices[-1] high_price = max(prices) low_price = min(prices) # Volume with some randomness base_volume = 1000000 * (period_seconds / 60) # Scale with period volume = base_volume * random.uniform(0.5, 2.0) # Additional synthetic indicators rsi = 30 + random.random() * 40 # Biased toward middle range sentiment = math.sin(timestamp / 3600) * 0.5 + random.gauss(0, 0.2) # Hourly cycle + noise sentiment = max(-1.0, min(1.0, sentiment)) volume_profile = 100 * random.uniform(0.5, 1.5) return { "open": round(open_price, 2), "high": round(high_price, 2), "low": round(low_price, 2), "close": round(close_price, 2), "volume": round(volume, 2), "rsi": round(rsi, 2), "sentiment": round(sentiment, 3), "volume_profile": round(volume_profile, 2), } async def _tick_generator( self, symbol: str, resolution: str, on_tick: Callable[[dict], None], subscription_id: str, ): """Background task that generates periodic ticks""" info = self._symbols[symbol] resolution_seconds = self._resolution_to_seconds(resolution) # Start from current aligned time current_time = int(time.time()) current_time = current_time - (current_time % resolution_seconds) price = info["base_price"] try: while True: # Wait until next bar await asyncio.sleep(resolution_seconds) current_time += resolution_seconds bar_data = self._generate_bar(current_time, price, info["volatility"], resolution_seconds) price = bar_data["close"] # Call the tick handler tick_data = {"time": current_time, **bar_data} on_tick(tick_data) except asyncio.CancelledError: # Subscription cancelled pass