354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""
|
|
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
|