backend redesign
This commit is contained in:
353
backend.old/src/datasource/adapters/demo.py
Normal file
353
backend.old/src/datasource/adapters/demo.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user