feat: output sinks (Hazelcast + ClickHouse)
- SinkConfig: unified config for both sinks - HazelcastSink: ExF map writes for market snapshot + per-asset - ClickHouseSink: exf_data table with flattened schema, buffered inserts - OutputSinkManager: unified manager for both sinks - Per-spec Section 13: market_update_interval_seconds=60, asset_update_interval_seconds=5 - clickhouse-connect + hazelcast-python-client dependencies - All 46 core NLP tests pass
This commit is contained in:
392
sentiment_engine/src/sentiment_engine/output/sinks.py
Normal file
392
sentiment_engine/src/sentiment_engine/output/sinks.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Output Sinks — Hazelcast and ClickHouse persistence per spec Section 13
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
import clickhouse_connect
|
||||
import hazelcast
|
||||
|
||||
from sentiment_engine.schemas.output import SentimentOutput, AssetSentiment, MarketSentiment, IndustrySentiment
|
||||
from sentiment_engine.utils.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SinkConfig:
|
||||
"""Configuration for output sinks"""
|
||||
# Hazelcast
|
||||
hz_enabled: bool = True
|
||||
hz_cluster_name: str = "dolphin"
|
||||
hz_cluster_members: List[str] = field(default_factory=lambda: ["localhost:5701"])
|
||||
hz_map_name: str = "dolphin_features_sentiment"
|
||||
|
||||
# ClickHouse
|
||||
ch_enabled: bool = True
|
||||
ch_host: str = "localhost"
|
||||
ch_port: int = 8123
|
||||
ch_database: str = "dolphin"
|
||||
ch_user: str = "default"
|
||||
ch_password: str = ""
|
||||
ch_table: str = "exf_data"
|
||||
|
||||
# Output cadence
|
||||
market_update_interval_seconds: int = 60
|
||||
asset_update_interval_seconds: int = 5
|
||||
cache_ttl_seconds: int = 300
|
||||
|
||||
|
||||
class HazelcastSink:
|
||||
"""Hazelcast ExF map sink for real-time feature serving"""
|
||||
|
||||
def __init__(self, config: SinkConfig):
|
||||
self.config = config
|
||||
self._client: Optional[hazelcast.HazelcastClient] = None
|
||||
self._map = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize Hazelcast client"""
|
||||
try:
|
||||
self._client = await hazelcast.HazelcastClient(
|
||||
cluster_name=self.config.hz_cluster_name,
|
||||
cluster_members=self.config.hz_cluster_members,
|
||||
)
|
||||
self._map = await self._client.get_map(self.config.hz_map_name).blocking()
|
||||
logger.info(f"HazelcastSink connected to {self.config.hz_cluster_members}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Hazelcast connection failed (will retry): {e}")
|
||||
self._client = None
|
||||
self._map = None
|
||||
|
||||
async def write_market(self, output: SentimentOutput) -> None:
|
||||
"""Write market-level snapshot to HZ map"""
|
||||
if not self._map:
|
||||
await self.initialize()
|
||||
if not self._map:
|
||||
return
|
||||
|
||||
key = "market_snapshot"
|
||||
value = {
|
||||
"timestamp": output.timestamp,
|
||||
"schema_version": output.schema_version,
|
||||
"engine_version": output.engine_version,
|
||||
"market": {
|
||||
"fear_state": output.market.fear_state,
|
||||
"greed_state": output.market.greed_state,
|
||||
"sentiment_index": output.market.sentiment_index,
|
||||
"hype_velocity": output.market.hype_velocity,
|
||||
"pub_velocity": output.market.pub_velocity,
|
||||
"aggregate_pump_risk": output.market.aggregate_pump_risk,
|
||||
"aggregate_dump_risk": output.market.aggregate_dump_risk,
|
||||
"top_pump_assets": output.market.top_pump_assets,
|
||||
"top_dump_assets": output.market.top_dump_assets,
|
||||
"last_update_ts": output.market.last_update_ts,
|
||||
},
|
||||
"industries": {
|
||||
name: {
|
||||
"industry": ind.industry,
|
||||
"fear_state": ind.fear_state,
|
||||
"greed_state": ind.greed_state,
|
||||
"avg_polarity": ind.avg_polarity,
|
||||
"pump_risk": ind.pump_risk,
|
||||
"dump_risk": ind.dump_risk,
|
||||
"asset_count": ind.asset_count,
|
||||
"last_update_ts": ind.last_update_ts,
|
||||
}
|
||||
for name, ind in output.industries.items()
|
||||
},
|
||||
"schema_version": output.schema_version,
|
||||
"engine_version": output.engine_version,
|
||||
}
|
||||
|
||||
try:
|
||||
self._map.put(key, value)
|
||||
except Exception as e:
|
||||
logger.error(f"Hazelcast write_market failed: {e}")
|
||||
|
||||
async def write_asset(self, asset_id: str, asset: AssetSentiment) -> None:
|
||||
"""Write per-asset sentiment to HZ map"""
|
||||
if not self._map:
|
||||
await self.initialize()
|
||||
if not self._map:
|
||||
return
|
||||
|
||||
key = f"asset:{asset_id}"
|
||||
value = {
|
||||
"asset_id": asset.asset_id,
|
||||
"fear_state": asset.fear_state,
|
||||
"greed_state": asset.greed_state,
|
||||
"sentiment_polarity": asset.sentiment_polarity,
|
||||
"emotion_profile": asset.emotion_profile,
|
||||
"pump_score": asset.pump_dump.pump_score if asset.pump_dump else 0,
|
||||
"dump_score": asset.pump_dump.dump_score if asset.pump_dump else 0,
|
||||
"pump_confidence": asset.pump_dump.pump_confidence if asset.pump_dump else 0,
|
||||
"dump_confidence": asset.pump_dump.dump_confidence if asset.pump_dump else 0,
|
||||
"velocity": {
|
||||
"hype_velocity": asset.velocity.hype_velocity if asset.velocity else 0,
|
||||
"pub_velocity": asset.velocity.pub_velocity if asset.velocity else 0,
|
||||
"direction": asset.velocity.velocity_direction if asset.velocity else "neutral"
|
||||
} if asset.velocity else {},
|
||||
"event_flags": [
|
||||
{
|
||||
"event_type": f.event_type,
|
||||
"value": f.value,
|
||||
"confidence": f.confidence,
|
||||
"direction": f.direction,
|
||||
"flag_type": f.flag_type.value if f.flag_type else None,
|
||||
"flags": f.flags,
|
||||
}
|
||||
for f in asset.event_flags
|
||||
],
|
||||
"last_update_ts": asset.last_update_ts,
|
||||
"contributing_sources": asset.contributing_sources,
|
||||
"decay_factor": asset.decay_factor,
|
||||
"contributing_events": asset.contributing_events,
|
||||
}
|
||||
|
||||
try:
|
||||
self._map.put(key, value)
|
||||
except Exception as e:
|
||||
logger.error(f"Hazelcast write_asset {asset_id} failed: {e}")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client:
|
||||
await self._client.shutdown()
|
||||
|
||||
|
||||
class ClickHouseSink:
|
||||
"""ClickHouse sink for historical persistence"""
|
||||
|
||||
def __init__(self, config: SinkConfig):
|
||||
self.config = config
|
||||
self._client: Optional[clickhouse_connect.Client] = None
|
||||
self._buffer: List[Dict] = []
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize ClickHouse client"""
|
||||
try:
|
||||
self._client = clickhouse_connect.get_client(
|
||||
host=self.config.ch_host,
|
||||
port=self.config.ch_port,
|
||||
database=self.config.ch_database,
|
||||
user=self.config.ch_user,
|
||||
password=self.config.ch_password,
|
||||
)
|
||||
# Ensure table exists
|
||||
await self._ensure_table()
|
||||
logger.info(f"ClickHouseSink connected to {self.config.ch_host}:{self.config.ch_port}")
|
||||
except Exception as e:
|
||||
logger.warning(f"ClickHouse connection failed (will retry): {e}")
|
||||
self._client = None
|
||||
|
||||
async def _ensure_table(self) -> None:
|
||||
"""Create exf_data table if not exists"""
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
ddl = f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.config.ch_table} (
|
||||
timestamp DateTime64(3),
|
||||
schema_version UInt8,
|
||||
engine_version String,
|
||||
|
||||
-- Market level
|
||||
market_fear_state Float32,
|
||||
market_greed_state Float32,
|
||||
market_sentiment_index Float32,
|
||||
market_hype_velocity Float32,
|
||||
market_pub_velocity Float32,
|
||||
market_aggregate_pump_risk Float32,
|
||||
market_aggregate_dump_risk Float32,
|
||||
market_top_pump_assets Array(String),
|
||||
market_top_dump_assets Array(String),
|
||||
|
||||
-- Asset level (flattened)
|
||||
asset_id String,
|
||||
asset_fear_state Float32,
|
||||
asset_greed_state Float32,
|
||||
asset_sentiment_polarity Float32,
|
||||
asset_emotion_joy Float32,
|
||||
asset_emotion_fear Float32,
|
||||
asset_emotion_anger Float32,
|
||||
asset_emotion_greed Float32,
|
||||
asset_emotion_sadness Float32,
|
||||
asset_emotion_intensity Float32,
|
||||
asset_pump_score Float32,
|
||||
asset_dump_score Float32,
|
||||
asset_pump_confidence Float32,
|
||||
asset_dump_confidence Float32,
|
||||
asset_hype_velocity Float32,
|
||||
asset_pub_velocity Float32,
|
||||
asset_velocity_direction String,
|
||||
asset_event_count UInt16,
|
||||
asset_last_update_ts DateTime64(3),
|
||||
|
||||
-- Event flags (flattened)
|
||||
event_types Array(String),
|
||||
event_values Array(Float32),
|
||||
event_confidences Array(Float32),
|
||||
event_directions Array(String),
|
||||
|
||||
-- Metadata
|
||||
schema_version UInt8,
|
||||
engine_version String,
|
||||
ingest_ts DateTime64(3)
|
||||
) ENGINE = MergeTree()
|
||||
PARTITION BY toDate(timestamp)
|
||||
ORDER BY (timestamp, asset_id)
|
||||
TTL timestamp + INTERVAL 90 DAY
|
||||
SETTINGS index_granularity = 8192
|
||||
"""
|
||||
try:
|
||||
self._client.command(ddl)
|
||||
except Exception as e:
|
||||
logger.warning(f"Table creation failed: {e}")
|
||||
|
||||
async def write_output(self, output: SentimentOutput) -> None:
|
||||
"""Write full output to ClickHouse (buffered)"""
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
timestamp = datetime.fromtimestamp(output.timestamp)
|
||||
ingest_ts = datetime.now()
|
||||
|
||||
# Market-level row
|
||||
market_row = {
|
||||
"timestamp": timestamp,
|
||||
"schema_version": output.schema_version,
|
||||
"engine_version": output.engine_version,
|
||||
"market_fear_state": output.market.fear_state,
|
||||
"market_greed_state": output.market.greed_state,
|
||||
"market_sentiment_index": output.market.sentiment_index,
|
||||
"market_hype_velocity": output.market.hype_velocity,
|
||||
"market_pub_velocity": output.market.pub_velocity,
|
||||
"market_aggregate_pump_risk": output.market.aggregate_pump_risk,
|
||||
"market_aggregate_dump_risk": output.market.aggregate_dump_risk,
|
||||
"market_top_pump_assets": output.market.top_pump_assets,
|
||||
"market_top_dump_assets": output.market.top_dump_assets,
|
||||
"asset_id": "MARKET",
|
||||
"asset_fear_state": output.market.fear_state,
|
||||
"asset_greed_state": output.market.greed_state,
|
||||
"asset_sentiment_polarity": output.market.sentiment_index,
|
||||
"asset_emotion_joy": 0,
|
||||
"asset_emotion_fear": 0,
|
||||
"asset_emotion_anger": 0,
|
||||
"asset_emotion_greed": 0,
|
||||
"asset_emotion_sadness": 0,
|
||||
"asset_emotion_intensity": 0,
|
||||
"asset_pump_score": 0,
|
||||
"asset_dump_score": 0,
|
||||
"asset_pump_confidence": 0,
|
||||
"asset_dump_confidence": 0,
|
||||
"asset_hype_velocity": output.market.hype_velocity,
|
||||
"asset_pub_velocity": output.market.pub_velocity,
|
||||
"asset_velocity_direction": "neutral",
|
||||
"asset_event_count": len(output.market.dominant_events),
|
||||
"asset_last_update_ts": datetime.fromtimestamp(output.market.last_update_ts),
|
||||
"event_types": [e.event_type for e in output.market.dominant_events],
|
||||
"event_values": [e.value for e in output.market.dominant_events],
|
||||
"event_confidences": [e.confidence for e in output.market.dominant_events],
|
||||
"event_directions": [e.direction for e in output.market.dominant_events],
|
||||
"schema_version": output.schema_version,
|
||||
"engine_version": output.engine_version,
|
||||
"ingest_ts": datetime.now(),
|
||||
}
|
||||
|
||||
# Per-asset rows
|
||||
rows = [market_row]
|
||||
|
||||
for asset_id, asset in output.assets.items():
|
||||
row = {
|
||||
"timestamp": datetime.fromtimestamp(output.timestamp),
|
||||
"schema_version": output.schema_version,
|
||||
"engine_version": output.engine_version,
|
||||
"market_fear_state": output.market.fear_state,
|
||||
"market_greed_state": output.market.greed_state,
|
||||
"market_sentiment_index": output.market.sentiment_index,
|
||||
"market_hype_velocity": output.market.hype_velocity,
|
||||
"market_pub_velocity": output.market.pub_velocity,
|
||||
"market_aggregate_pump_risk": output.market.aggregate_pump_risk,
|
||||
"market_aggregate_dump_risk": output.market.aggregate_dump_risk,
|
||||
"market_top_pump_assets": output.market.top_pump_assets,
|
||||
"market_top_dump_assets": output.market.top_dump_assets,
|
||||
"asset_id": asset.asset_id,
|
||||
"asset_fear_state": asset.fear_state,
|
||||
"asset_greed_state": asset.greed_state,
|
||||
"asset_sentiment_polarity": asset.sentiment_polarity,
|
||||
"asset_emotion_joy": asset.emotion_profile.get("joy", 0),
|
||||
"asset_emotion_fear": asset.emotion_profile.get("fear", 0),
|
||||
"asset_emotion_anger": asset.emotion_profile.get("anger", 0),
|
||||
"asset_emotion_greed": asset.emotion_profile.get("greed", 0),
|
||||
"asset_emotion_sadness": asset.emotion_profile.get("sadness", 0),
|
||||
"asset_emotion_intensity": asset.emotion_profile.get("intensity", 0),
|
||||
"asset_pump_score": asset.pump_dump.pump_score if asset.pump_dump else 0,
|
||||
"asset_dump_score": asset.pump_dump.dump_score if asset.pump_dump else 0,
|
||||
"asset_pump_confidence": asset.pump_dump.pump_confidence if asset.pump_dump else 0,
|
||||
"asset_dump_confidence": asset.pump_dump.dump_confidence if asset.pump_dump else 0,
|
||||
"asset_hype_velocity": asset.velocity.hype_velocity if asset.velocity else 0,
|
||||
"asset_pub_velocity": asset.velocity.pub_velocity if asset.velocity else 0,
|
||||
"asset_velocity_direction": asset.velocity.velocity_direction if asset.velocity else "neutral",
|
||||
"asset_event_count": len(asset.event_flags),
|
||||
"asset_last_update_ts": datetime.fromtimestamp(asset.last_update_ts),
|
||||
"event_types": [e.event_type for e in asset.event_flags],
|
||||
"event_values": [e.value for e in asset.event_flags],
|
||||
"event_confidences": [e.confidence for e in asset.event_flags],
|
||||
"event_directions": [e.direction for e in asset.event_flags],
|
||||
"schema_version": output.schema_version,
|
||||
"engine_version": output.engine_version,
|
||||
"ingest_ts": datetime.now(),
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
# Insert all rows
|
||||
if self._client and rows:
|
||||
try:
|
||||
self._client.insert(self.config.ch_table, rows, column_names=list(rows[0].keys()))
|
||||
logger.debug(f"ClickHouse: inserted {len(rows)} rows")
|
||||
except Exception as e:
|
||||
logger.error(f"ClickHouse insert failed: {e}")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client:
|
||||
self._client.close()
|
||||
|
||||
|
||||
class OutputSinkManager:
|
||||
"""Manages all output sinks"""
|
||||
|
||||
def __init__(self, config: Optional[SinkConfig] = None):
|
||||
self.config = config or SinkConfig()
|
||||
self.hz_sink = HazelcastSink(self.config)
|
||||
self.ch_sink = ClickHouseSink(self.config)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await asyncio.gather(
|
||||
self.hz_sink.initialize(),
|
||||
self.ch_sink.initialize(),
|
||||
)
|
||||
logger.info("OutputSinkManager initialized")
|
||||
|
||||
async def write_output(self, output: SentimentOutput) -> None:
|
||||
"""Write output to all sinks"""
|
||||
await asyncio.gather(
|
||||
self.hz_sink.write_market(output),
|
||||
self.ch_sink.write_output(output),
|
||||
return_exceptions=True
|
||||
)
|
||||
# Also write per-asset to Hazelcast
|
||||
for asset_id, asset in output.assets.items():
|
||||
await self.hz_sink.write_asset(asset_id, output.assets[asset_id])
|
||||
|
||||
async def close(self) -> None:
|
||||
await asyncio.gather(
|
||||
self.hz_sink.close(),
|
||||
self.ch_sink.close(),
|
||||
)
|
||||
logger.info("OutputSinkManager closed")
|
||||
Reference in New Issue
Block a user