| """ |
| RugCharts OHLCV Aggregation Engine |
| =================================== |
| Real-time candle building from trade events. |
| Produces OHLCV bars at 1m, 5m, 15m, 1h, 4h, 1d timeframes. |
| |
| Stored in Redis sorted sets for O(log N) range queries. |
| Wired into DataBus as 'ohlcv' chain. |
| """ |
|
|
| import json |
| import logging |
| import os |
| import time |
| from datetime import UTC, datetime |
|
|
| import redis |
|
|
| logger = logging.getLogger("ohlcv_engine") |
|
|
| REDIS_HOST = os.getenv("REDIS_HOST", "rmi-redis") |
| REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) |
| REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") |
|
|
| TIMEFRAMES = { |
| "1m": 60, |
| "5m": 300, |
| "15m": 900, |
| "1h": 3600, |
| "4h": 14400, |
| "1d": 86400, |
| } |
|
|
| CACHE_TTL = { |
| "1m": 300, |
| "5m": 900, |
| "15m": 1800, |
| "1h": 3600, |
| "4h": 14400, |
| "1d": 86400, |
| } |
|
|
| MAX_CANDLES = 500 |
|
|
|
|
| def _redis(): |
| return redis.Redis( |
| host=REDIS_HOST, |
| port=REDIS_PORT, |
| password=REDIS_PASSWORD, |
| decode_responses=True, |
| socket_connect_timeout=2, |
| ) |
|
|
|
|
| def _frame_key(token: str, chain: str, timeframe: str) -> str: |
| """Redis sorted set key for OHLCV candles.""" |
| return f"ohlcv:{chain}:{token}:{timeframe}" |
|
|
|
|
| def _candle_cache_key(token: str, chain: str, timeframe: str, limit: int, end_ts: int | None = None) -> str: |
| """Cache key for bulk candle queries.""" |
| end = end_ts or int(time.time()) |
| return f"ohlcv_cache:{chain}:{token}:{timeframe}:{limit}:{end}" |
|
|
|
|
| class Candle: |
| """Single OHLCV candle.""" |
|
|
| __slots__ = ("close", "high", "low", "open", "timestamp", "trades", "volume") |
|
|
| def __init__( |
| self, |
| ts: int, |
| open_p: float = 0, |
| high: float = 0, |
| low: float = float("inf"), |
| close: float = 0, |
| volume: float = 0, |
| trades: int = 0, |
| ): |
| self.timestamp = ts |
| self.open = open_p |
| self.high = high |
| self.low = low |
| self.close = close |
| self.volume = volume |
| self.trades = trades |
|
|
| def to_dict(self) -> dict: |
| return { |
| "timestamp": self.timestamp, |
| "datetime": datetime.fromtimestamp(self.timestamp, tz=UTC).isoformat(), |
| "open": round(self.open, 8), |
| "high": round(self.high, 8), |
| "low": round(self.low, 8), |
| "close": round(self.close, 8), |
| "volume": round(self.volume, 2), |
| "trades": self.trades, |
| } |
|
|
| def to_array(self) -> list: |
| """Compact array format: [ts, o, h, l, c, v, n]""" |
| return [ |
| self.timestamp, |
| round(self.open, 8), |
| round(self.high, 8), |
| round(self.low, 8), |
| round(self.close, 8), |
| round(self.volume, 2), |
| self.trades, |
| ] |
|
|
|
|
| class OHLCVEngine: |
| """Real-time OHLCV candle builder and query engine.""" |
|
|
| def __init__(self): |
| self._active_candles: dict[str, Candle] = {} |
|
|
| def _active_key(self, token: str, chain: str, timeframe: str, bucket_ts: int) -> str: |
| return f"{chain}:{token}:{timeframe}:{bucket_ts}" |
|
|
| def ingest_trade( |
| self, |
| token: str, |
| chain: str, |
| price: float, |
| volume: float, |
| timestamp: int | None = None, |
| commit: bool = True, |
| ) -> dict[str, Candle]: |
| """Ingest a single trade, updating all timeframe candles. |
| |
| Returns dict of timeframe β updated Candle. |
| """ |
| ts = timestamp or int(time.time()) |
| updated = {} |
|
|
| for tf_name, tf_seconds in TIMEFRAMES.items(): |
| bucket_ts = (ts // tf_seconds) * tf_seconds |
| active_key = self._active_key(token, chain, tf_name, bucket_ts) |
|
|
| candle = self._active_candles.get(active_key) |
| if candle is None or candle.timestamp != bucket_ts: |
| |
| if candle and commit: |
| self._persist_candle(token, chain, tf_name, candle) |
| candle = Candle( |
| ts=bucket_ts, |
| open_p=price, |
| high=price, |
| low=price, |
| close=price, |
| volume=volume, |
| trades=1, |
| ) |
| self._active_candles[active_key] = candle |
| else: |
| |
| if candle.open == 0: |
| candle.open = price |
| candle.high = max(candle.high, price) |
| candle.low = min(candle.low, price) |
| candle.close = price |
| candle.volume += volume |
| candle.trades += 1 |
|
|
| updated[tf_name] = candle |
|
|
| return updated |
|
|
| def _persist_candle(self, token: str, chain: str, timeframe: str, candle: Candle): |
| """Write candle to Redis sorted set.""" |
| try: |
| r = _redis() |
| key = _frame_key(token, chain, timeframe) |
| |
| r.zadd(key, {json.dumps(candle.to_dict()): candle.timestamp}) |
| |
| r.zremrangebyrank(key, 0, -(MAX_CANDLES + 1)) |
| r.expire(key, CACHE_TTL.get(timeframe, 3600)) |
| r.close() |
| except Exception as e: |
| logger.warning(f"Failed to persist candle: {e}") |
|
|
| def flush_all(self): |
| """Persist all active candles to Redis.""" |
| for active_key, candle in list(self._active_candles.items()): |
| parts = active_key.split(":") |
| if len(parts) >= 4: |
| chain, token, tf_name = parts[0], parts[1], parts[2] |
| self._persist_candle(token, chain, tf_name, candle) |
| self._active_candles.clear() |
|
|
| def get_candles( |
| self, |
| token: str, |
| chain: str, |
| timeframe: str = "1h", |
| limit: int = 100, |
| end_ts: int | None = None, |
| ) -> list[dict]: |
| """Retrieve OHLCV candles for a token. |
| |
| Args: |
| token: Token address |
| chain: Blockchain ID |
| timeframe: '1m', '5m', '15m', '1h', '4h', '1d' |
| limit: Max candles (default 100, max 500) |
| end_ts: End timestamp (default: now). Returns candles up to this time. |
| """ |
| if timeframe not in TIMEFRAMES: |
| timeframe = "1h" |
|
|
| limit = min(limit, MAX_CANDLES) |
| end = end_ts or int(time.time()) |
|
|
| |
| cache_key = _candle_cache_key(token, chain, timeframe, limit, end) |
| try: |
| r = _redis() |
| cached = r.get(cache_key) |
| if cached: |
| r.close() |
| return json.loads(cached) |
| except Exception: |
| pass |
|
|
| try: |
| r = _redis() if "r" not in dir() or not r else r |
| key = _frame_key(token, chain, timeframe) |
| |
| raw = r.zrangebyscore(key, 0, end, start=0, num=limit) |
|
|
| candles = [] |
| for item in raw: |
| try: |
| c = json.loads(item) |
| candles.append(c) |
| except Exception: |
| pass |
|
|
| |
| candles.sort(key=lambda x: x["timestamp"]) |
|
|
| |
| tf_seconds = TIMEFRAMES[timeframe] |
| bucket_ts = (end // tf_seconds) * tf_seconds |
| active_key = self._active_key(token, chain, timeframe, bucket_ts) |
| active = self._active_candles.get(active_key) |
| if active and active.timestamp <= end: |
| |
| if not candles or candles[-1]["timestamp"] != active.timestamp: |
| candles.append(active.to_dict()) |
|
|
| |
| r.setex(cache_key, 60, json.dumps(candles[-limit:] if len(candles) > limit else candles)) |
| r.close() |
|
|
| return candles[-limit:] if len(candles) > limit else candles |
|
|
| except Exception as e: |
| logger.warning(f"OHLCV query failed: {e}") |
| return [] |
|
|
| def get_latest_candle(self, token: str, chain: str, timeframe: str = "1h") -> dict | None: |
| """Get the most recent candle (may be still-open).""" |
| candles = self.get_candles(token, chain, timeframe, limit=1) |
| return candles[0] if candles else None |
|
|
| def get_price_change(self, token: str, chain: str, periods: int = 24, timeframe: str = "1h") -> dict: |
| """Calculate price change over N periods.""" |
| candles = self.get_candles(token, chain, timeframe, limit=periods + 1) |
| if len(candles) < 2: |
| return {"change_pct": 0, "candles": 0} |
|
|
| first = candles[0]["close"] |
| last = candles[-1]["close"] |
| change_pct = ((last - first) / first * 100) if first > 0 else 0 |
|
|
| return { |
| "change_pct": round(change_pct, 2), |
| "open": first, |
| "close": last, |
| "high": max(c["high"] for c in candles), |
| "low": min(c["low"] for c in candles), |
| "volume": sum(c["volume"] for c in candles), |
| "candles": len(candles), |
| } |
|
|
| def stats(self) -> dict: |
| """Engine statistics.""" |
| return { |
| "active_candles": len(self._active_candles), |
| "timeframes": list(TIMEFRAMES.keys()), |
| "max_candles_per_query": MAX_CANDLES, |
| } |
|
|
|
|
| |
|
|
| ohlcv_engine = OHLCVEngine() |
|
|
|
|
| |
|
|
|
|
| async def fetch_ohlcv( |
| token: str = "", chain: str = "ethereum", timeframe: str = "1h", limit: int = 100, **kw |
| ) -> dict | None: |
| """DataBus provider for OHLCV candle data. |
| |
| Args: |
| token: Token address (use mint= or address= as aliases) |
| chain: Blockchain (ethereum, solana, bsc, base, etc.) |
| timeframe: 1m, 5m, 15m, 1h, 4h, 1d |
| limit: Number of candles (default 100, max 500) |
| """ |
| address = token or kw.get("mint", "") or kw.get("address", "") |
| if not address: |
| return None |
|
|
| candles = ohlcv_engine.get_candles(address, chain, timeframe, limit) |
|
|
| |
| summary = {} |
| if candles: |
| prices = [c["close"] for c in candles] |
| volumes = [c["volume"] for c in candles] |
| summary = { |
| "current_price": prices[-1], |
| "price_change_pct": round(((prices[-1] - prices[0]) / prices[0] * 100), 2) if prices[0] > 0 else 0, |
| "high_24h": max(c["high"] for c in candles), |
| "low_24h": min(c["low"] for c in candles), |
| "volume_24h": sum(volumes), |
| "total_trades": sum(c["trades"] for c in candles), |
| } |
|
|
| |
| authenticity = None |
| try: |
| from app.databus.volume_authenticity import quick_authenticity_score |
|
|
| auth = quick_authenticity_score( |
| volume_24h=summary.get("volume_24h", 0), |
| liquidity=float(kw.get("liquidity_usd", 0)), |
| unique_wallets=int(kw.get("unique_wallets", 0)), |
| tx_count=summary.get("total_trades", 0), |
| ) |
| authenticity = { |
| "fake_volume_pct": auth.get("fake_volume_pct", 0), |
| "authentic_score": auth.get("authentic_score", 100), |
| "risk_level": auth.get("risk_level", "UNKNOWN"), |
| } |
| except Exception: |
| pass |
|
|
| return { |
| "candles": candles, |
| "summary": summary, |
| "authenticity": authenticity, |
| "timeframe": timeframe, |
| "token": address, |
| "chain": chain, |
| "source": "ohlcv_engine", |
| } |
|
|
|
|
| async def ingest_trade_data( |
| token: str = "", |
| chain: str = "ethereum", |
| price: float = 0, |
| volume: float = 0, |
| timestamp: int | None = None, |
| **kw, |
| ) -> dict | None: |
| """DataBus provider: Ingest a trade and update all OHLCV candles.""" |
| address = token or kw.get("address", "") or kw.get("token", "") |
| if not address or price <= 0: |
| return None |
|
|
| price = float(price) |
| volume = float(volume) |
|
|
| updated = ohlcv_engine.ingest_trade(address, chain, price, volume, timestamp) |
|
|
| return { |
| "status": "ingested", |
| "token": address, |
| "chain": chain, |
| "price": price, |
| "volume": volume, |
| "updated_timeframes": list(updated.keys()), |
| "source": "ohlcv_engine", |
| } |
|
|