Files
BytbitBTC/signals/signals.py
2026-01-18 14:11:33 +02:00

951 lines
33 KiB
Python

#!/usr/bin/env python3
"""
BTCUSDT Signal Generator - Multi-Personality Edition
Generates trading signals from candles.db and analysis.db
Runs both scalping and swing personalities simultaneously
Streams signals via Unix Domain Socket
"""
import sqlite3
import pandas as pd
import numpy as np
import talib
import json
import time
import logging
import signal
import sys
import os
import socket
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Dict, List
import threading
from collections import deque
# Configuration
class Config:
def __init__(self, config_path: str = "config.json"):
self.config_path = config_path
self.reload()
def reload(self):
"""Reload configuration from file"""
with open(self.config_path, "r") as f:
data = json.load(f)
self.candles_db = data.get("candles_db", "../onramp/candles.db")
self.analysis_db = data.get("analysis_db", "../analysis/analysis.db")
self.socket_path = data.get("socket_path", "/tmp/signals.sock")
self.health_socket_path = data.get(
"health_socket_path", "/tmp/signals_health.sock"
)
self.control_socket_path = data.get(
"control_socket_path", "/tmp/signals_control.sock"
)
self.log_file = data.get("log_file", "logs/signal_generator.log")
self.log_to_stdout = data.get("log_to_stdout", True)
self.poll_interval = data.get("poll_interval", 0.5)
self.timeframes = data.get("timeframes", ["1m", "5m"])
self.lookback = data.get("lookback", 200)
# Signal thresholds (can be personality-specific)
self.min_confidence = data.get("min_confidence", 0.6)
self.cooldown_seconds = data.get("cooldown_seconds", 60)
# Personality-specific weights
self.weights = data.get(
"weights",
{
"scalping": {
"ema_cross": 0.3,
"stoch": 0.25,
"rsi": 0.2,
"volume": 0.15,
"macd": 0.1,
},
"swing": {
"regime": 0.35,
"bb_squeeze": 0.25,
"macd": 0.2,
"flow": 0.15,
"rsi": 0.05,
},
},
)
# Logging setup
def setup_logging(config: Config):
Path(config.log_file).parent.mkdir(parents=True, exist_ok=True)
handlers = [logging.FileHandler(config.log_file)]
if config.log_to_stdout:
handlers.append(logging.StreamHandler(sys.stdout))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=handlers,
)
return logging.getLogger(__name__)
# Signal Generator Class (per-personality)
class PersonalityEngine:
"""Single personality signal generation engine"""
def __init__(self, personality: str, config: Config, logger: logging.Logger):
self.personality = personality
self.config = config
self.logger = logger
self.last_signal_time = {}
self.signal_history = deque(maxlen=100)
self.stats = {
"total_signals": 0,
"buy_signals": 0,
"sell_signals": 0,
"last_signal_time": None,
"errors": 0,
}
self.lock = threading.Lock()
def fetch_and_enrich(self, timeframe: str) -> Optional[pd.DataFrame]:
"""Fetch data from databases and enrich with additional indicators"""
try:
conn_c = sqlite3.connect(
f"file:{self.config.candles_db}?mode=ro",
uri=True,
timeout=10,
)
# ATTACH the analysis database so we can JOIN across them
conn_c.execute(
f"ATTACH DATABASE 'file:{self.config.analysis_db}?mode=ro' AS analysis_db"
)
query = """
SELECT
c.timeframe, c.timestamp, c.open, c.high, c.low, c.close,
c.volume, c.buy_volume,
a.ema_9, a.ema_21, a.sma_50, a.sma_200,
a.rsi_14, a.macd, a.macd_signal, a.macd_hist,
a.bb_upper, a.bb_middle, a.bb_lower, a.bb_squeeze,
a.volume_ma_20
FROM candles c
JOIN analysis_db.analysis a
ON c.timeframe = a.timeframe
AND c.timestamp = a.timestamp
WHERE c.timeframe = ?
ORDER BY c.timestamp DESC
LIMIT ?
"""
df = pd.read_sql_query(
query, conn_c, params=(timeframe, self.config.lookback)
)
conn_c.close()
if df.empty:
return None
# Sort chronologically
df = df.sort_values("timestamp").reset_index(drop=True)
df["datetime"] = pd.to_datetime(df["timestamp"], unit="s")
# Filter only closed candles (exclude current forming candle)
current_time = int(time.time())
if timeframe == "1m":
window = 60
elif timeframe == "5m":
window = 300
elif timeframe == "15m":
window = 900
elif timeframe == "1h":
window = 3600
else:
window = 60
df = df[df["timestamp"] < (current_time - window)]
if len(df) < 50:
self.logger.debug(f"[{self.personality}] Not enough data for {timeframe}: {len(df)} rows")
return None
# Drop rows with NULL in critical columns
df = df.dropna(subset=["open", "high", "low", "close", "volume"])
if len(df) < 50:
self.logger.debug(
f"[{self.personality}] Not enough valid data after NULL filtering for {timeframe}"
)
return None
# Add Stochastic Oscillator
df["stoch_k"], df["stoch_d"] = talib.STOCH(
df["high"].values,
df["low"].values,
df["close"].values,
fastk_period=14,
slowk_period=3,
slowd_period=3,
)
# Calculate buy/sell ratio
df["buy_ratio"] = df["buy_volume"] / df["volume"].replace(0, np.nan)
df["net_flow"] = df["buy_volume"] - (df["volume"] - df["buy_volume"])
return df
except Exception as e:
self.logger.error(f"[{self.personality}] Error fetching data for {timeframe}: {e}")
with self.lock:
self.stats["errors"] += 1
return None
def generate_signal_scalping(
self, df: pd.DataFrame, timeframe: str
) -> Optional[Dict]:
"""Generate signal using scalping personality"""
if len(df) < 21:
self.logger.debug(f"[{self.personality}/{timeframe}] Insufficient data: {len(df)} rows")
return None
latest = df.iloc[-1]
prev = df.iloc[-2]
# Check for NULL indicators
required_cols = [
"ema_9",
"ema_21",
"rsi_14",
"stoch_k",
"stoch_d",
"macd",
"macd_signal",
]
if any(pd.isna(latest[col]) for col in required_cols):
self.logger.debug(f"[{self.personality}/{timeframe}] Skipping: missing required indicators")
return None
score = 0
reasons = []
signal_type = None
weights = self.config.weights["scalping"]
# EMA Crossover (9/21)
ema_cross_up = (
latest["ema_9"] > latest["ema_21"] and prev["ema_9"] <= prev["ema_21"]
)
ema_cross_down = (
latest["ema_9"] < latest["ema_21"] and prev["ema_9"] >= prev["ema_21"]
)
if ema_cross_up:
score += weights["ema_cross"]
reasons.append("EMA9 crossed above EMA21")
signal_type = "BUY"
elif ema_cross_down:
score += weights["ema_cross"]
reasons.append("EMA9 crossed below EMA21")
signal_type = "SELL"
# Stochastic
if signal_type == "BUY":
if latest["stoch_k"] > latest["stoch_d"] and latest["stoch_k"] < 30:
score += weights["stoch"]
reasons.append("Stochastic oversold crossover")
elif signal_type == "SELL":
if latest["stoch_k"] < latest["stoch_d"] and latest["stoch_k"] > 70:
score += weights["stoch"]
reasons.append("Stochastic overbought crossover")
# RSI
if signal_type == "BUY" and latest["rsi_14"] < 40:
score += weights["rsi"]
reasons.append(f"RSI undersold ({latest['rsi_14']:.1f})")
elif signal_type == "SELL" and latest["rsi_14"] > 60:
score += weights["rsi"]
reasons.append(f"RSI oversold ({latest['rsi_14']:.1f})")
# Volume surge
if latest["volume"] > 1.5 * latest["volume_ma_20"]:
score += weights["volume"]
reasons.append("Volume surge detected")
# MACD confirmation
if signal_type == "BUY" and latest["macd"] > latest["macd_signal"]:
score += weights["macd"]
reasons.append("MACD bullish")
elif signal_type == "SELL" and latest["macd"] < latest["macd_signal"]:
score += weights["macd"]
reasons.append("MACD bearish")
if signal_type and score >= self.config.min_confidence:
return {
"signal": signal_type,
"timeframe": timeframe,
"confidence": round(score, 3),
"price": float(latest["close"]),
"timestamp": int(latest["timestamp"]),
"reasons": reasons,
"personality": "scalping",
}
return None
def generate_signal_swing(self, df: pd.DataFrame, timeframe: str) -> Optional[Dict]:
"""Generate signal using swing personality"""
if len(df) < 200:
return None
latest = df.iloc[-1]
prev = df.iloc[-2]
# Check for NULL indicators
required_cols = [
"sma_50",
"sma_200",
"bb_upper",
"bb_lower",
"bb_squeeze",
"macd",
"macd_signal",
"buy_ratio",
]
if any(pd.isna(latest[col]) for col in required_cols):
self.logger.debug(f"[{self.personality}/{timeframe}] Skipping: missing required indicators")
return None
score = 0
reasons = []
signal_type = None
weights = self.config.weights["swing"]
# Regime filter (SMA50/200)
bull_regime = latest["close"] > latest["sma_50"] > latest["sma_200"]
bear_regime = latest["close"] < latest["sma_50"] < latest["sma_200"]
if bull_regime:
signal_type = "BUY"
score += weights["regime"]
reasons.append("Bull regime (price > SMA50 > SMA200)")
elif bear_regime:
signal_type = "SELL"
score += weights["regime"]
reasons.append("Bear regime (price < SMA50 < SMA200)")
# Bollinger Squeeze breakout
if latest["bb_squeeze"] == 1 or prev["bb_squeeze"] == 1:
if signal_type == "BUY" and latest["close"] > latest["bb_upper"]:
score += weights["bb_squeeze"]
reasons.append("BB squeeze breakout to upside")
elif signal_type == "SELL" and latest["close"] < latest["bb_lower"]:
score += weights["bb_squeeze"]
reasons.append("BB squeeze breakout to downside")
# MACD crossover
if (
signal_type == "BUY"
and latest["macd"] > latest["macd_signal"]
and prev["macd"] <= prev["macd_signal"]
):
score += weights["macd"]
reasons.append("MACD bullish crossover")
elif (
signal_type == "SELL"
and latest["macd"] < latest["macd_signal"]
and prev["macd"] >= prev["macd_signal"]
):
score += weights["macd"]
reasons.append("MACD bearish crossover")
# Net flow
if signal_type == "BUY" and latest["buy_ratio"] > 0.55:
score += weights["flow"]
reasons.append(f"Buy pressure ({latest['buy_ratio']:.2%})")
elif signal_type == "SELL" and latest["buy_ratio"] < 0.45:
score += weights["flow"]
reasons.append(f"Sell pressure ({latest['buy_ratio']:.2%})")
# RSI (light filter for swing)
if signal_type == "BUY" and latest["rsi_14"] < 50:
score += weights["rsi"]
reasons.append("RSI not overbought")
elif signal_type == "SELL" and latest["rsi_14"] > 50:
score += weights["rsi"]
reasons.append("RSI not oversold")
if signal_type and score >= self.config.min_confidence:
return {
"signal": signal_type,
"timeframe": timeframe,
"confidence": round(score, 3),
"price": float(latest["close"]),
"timestamp": int(latest["timestamp"]),
"reasons": reasons,
"personality": "swing",
}
return None
def generate_signal(self, timeframe: str) -> Optional[Dict]:
"""Main signal generation dispatcher"""
# Check cooldown
cooldown_key = f"{self.personality}_{timeframe}"
if cooldown_key in self.last_signal_time:
elapsed = time.time() - self.last_signal_time[cooldown_key]
if elapsed < self.config.cooldown_seconds:
return None
df = self.fetch_and_enrich(timeframe)
if df is None:
return None
if self.personality == "scalping":
signal = self.generate_signal_scalping(df, timeframe)
elif self.personality == "swing":
signal = self.generate_signal_swing(df, timeframe)
else:
self.logger.error(f"Unknown personality: {self.personality}")
return None
if signal:
self.last_signal_time[cooldown_key] = time.time()
signal["generated_at"] = datetime.now(timezone.utc).isoformat()
# Update stats
with self.lock:
self.stats["total_signals"] += 1
if signal["signal"] == "BUY":
self.stats["buy_signals"] += 1
else:
self.stats["sell_signals"] += 1
self.stats["last_signal_time"] = signal["generated_at"]
self.signal_history.append(signal)
return signal
# Main Signal Generator Coordinator
class SignalGenerator:
def __init__(self, config: Config, logger: logging.Logger):
self.config = config
self.logger = logger
self.running = False
self.debug_mode = False
# Create personality engines
self.engines = {
"scalping": PersonalityEngine("scalping", config, logger),
"swing": PersonalityEngine("swing", config, logger),
}
self.global_stats = {
"uptime_start": datetime.now(timezone.utc),
"config_reloads": 0,
}
# Unix socket
self.socket = None
self.connections = []
self.connections_lock = threading.Lock()
# Health check socket
self.health_socket = None
# Control socket
self.control_socket = None
# Thread pool
self.threads = []
def broadcast_signal(self, signal: Dict):
"""Broadcast signal to all connected clients"""
message = json.dumps(signal) + "\n"
message_bytes = message.encode("utf-8")
with self.connections_lock:
disconnected = []
for conn in self.connections:
try:
conn.sendall(message_bytes)
self.logger.info(
f"[{signal['personality']}] Sent {signal['signal']} signal: "
f"{signal['timeframe']} @ {signal['price']} (conf: {signal['confidence']})"
)
except Exception as e:
self.logger.warning(f"Failed to send to client: {e}")
disconnected.append(conn)
# Remove disconnected clients
for conn in disconnected:
try:
conn.close()
except:
pass
self.connections.remove(conn)
def setup_signal_socket(self):
"""Setup Unix domain socket for signal streaming"""
try:
if os.path.exists(self.config.socket_path):
os.remove(self.config.socket_path)
self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.socket.bind(self.config.socket_path)
self.socket.listen(5)
self.socket.settimeout(0.1)
self.logger.info(f"Signal socket listening on {self.config.socket_path}")
except Exception as e:
self.logger.error(f"Failed to setup signal socket: {e}")
raise
def setup_health_socket(self):
"""Setup Unix domain socket for health checks"""
try:
if os.path.exists(self.config.health_socket_path):
os.remove(self.config.health_socket_path)
self.health_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.health_socket.bind(self.config.health_socket_path)
self.health_socket.listen(5)
self.health_socket.settimeout(0.1)
self.logger.info(
f"Health socket listening on {self.config.health_socket_path}"
)
except Exception as e:
self.logger.error(f"Failed to setup health socket: {e}")
raise
def setup_control_socket(self):
"""Setup Unix domain socket for control commands"""
try:
if os.path.exists(self.config.control_socket_path):
os.remove(self.config.control_socket_path)
self.control_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.control_socket.bind(self.config.control_socket_path)
self.control_socket.listen(5)
self.control_socket.settimeout(0.1)
self.logger.info(
f"Control socket listening on {self.config.control_socket_path}"
)
except Exception as e:
self.logger.error(f"Failed to setup control socket: {e}")
raise
def check_database_status(self) -> Dict:
"""Check database connectivity and data availability"""
status = {
"candles_db": {"accessible": False, "row_count": 0, "timeframes": {}},
"analysis_db": {"accessible": False, "row_count": 0, "timeframes": {}},
}
# Check candles DB
try:
conn_c = sqlite3.connect(
f"file:{self.config.candles_db}?mode=ro", uri=True, timeout=5
)
cursor = conn_c.cursor()
cursor.execute("SELECT COUNT(*) FROM candles")
status["candles_db"]["row_count"] = cursor.fetchone()[0]
status["candles_db"]["accessible"] = True
for tf in self.config.timeframes:
cursor.execute(
"SELECT COUNT(*), MAX(timestamp) FROM candles WHERE timeframe = ?",
(tf,),
)
count, max_ts = cursor.fetchone()
status["candles_db"]["timeframes"][tf] = {
"count": count,
"latest_timestamp": max_ts,
"age_seconds": int(time.time() - max_ts) if max_ts else None,
}
conn_c.close()
except Exception as e:
status["candles_db"]["error"] = str(e)
# Check analysis DB
try:
conn_a = sqlite3.connect(
f"file:{self.config.analysis_db}?mode=ro", uri=True, timeout=5
)
cursor = conn_a.cursor()
cursor.execute("SELECT COUNT(*) FROM analysis")
status["analysis_db"]["row_count"] = cursor.fetchone()[0]
status["analysis_db"]["accessible"] = True
for tf in self.config.timeframes:
cursor.execute(
"SELECT COUNT(*), MAX(timestamp) FROM analysis WHERE timeframe = ?",
(tf,),
)
count, max_ts = cursor.fetchone()
status["analysis_db"]["timeframes"][tf] = {
"count": count,
"latest_timestamp": max_ts,
"age_seconds": int(time.time() - max_ts) if max_ts else None,
}
conn_a.close()
except Exception as e:
status["analysis_db"]["error"] = str(e)
return status
def handle_health_checks(self):
"""Handle incoming health check requests"""
try:
conn, _ = self.health_socket.accept()
uptime = datetime.now(timezone.utc) - self.global_stats["uptime_start"]
db_status = self.check_database_status()
# Aggregate stats from both engines
total_stats = {
"total_signals": 0,
"buy_signals": 0,
"sell_signals": 0,
"errors": 0,
}
personality_stats = {}
recent_signals = []
for name, engine in self.engines.items():
with engine.lock:
personality_stats[name] = {
"total_signals": engine.stats["total_signals"],
"buy_signals": engine.stats["buy_signals"],
"sell_signals": engine.stats["sell_signals"],
"last_signal": engine.stats["last_signal_time"],
"errors": engine.stats["errors"],
"recent_signals": list(engine.signal_history)[-5:],
}
total_stats["total_signals"] += engine.stats["total_signals"]
total_stats["buy_signals"] += engine.stats["buy_signals"]
total_stats["sell_signals"] += engine.stats["sell_signals"]
total_stats["errors"] += engine.stats["errors"]
recent_signals.extend(list(engine.signal_history)[-5:])
# Sort recent signals by timestamp
recent_signals.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
health = {
"status": "running",
"mode": "multi-personality",
"personalities": ["scalping", "swing"],
"timeframes": self.config.timeframes,
"uptime_seconds": int(uptime.total_seconds()),
"total_stats": total_stats,
"personality_stats": personality_stats,
"connected_clients": len(self.connections),
"recent_signals": recent_signals[:10],
"databases": db_status,
"config": {
"min_confidence": self.config.min_confidence,
"cooldown_seconds": self.config.cooldown_seconds,
"lookback": self.config.lookback,
"weights": self.config.weights,
"reloads": self.global_stats["config_reloads"],
},
"debug_mode": self.debug_mode,
}
conn.sendall(json.dumps(health, indent=2).encode("utf-8") + b"\n")
conn.close()
except socket.timeout:
pass
except Exception as e:
self.logger.debug(f"Health check error: {e}")
def handle_control_commands(self):
"""Handle incoming control commands"""
try:
conn, _ = self.control_socket.accept()
data = conn.recv(4096).decode("utf-8").strip()
if not data:
conn.close()
return
try:
cmd = json.loads(data)
response = self.process_command(cmd)
except json.JSONDecodeError:
response = {"status": "error", "message": "Invalid JSON"}
conn.sendall(json.dumps(response, indent=2).encode("utf-8") + b"\n")
conn.close()
except socket.timeout:
pass
except Exception as e:
self.logger.debug(f"Control command error: {e}")
def process_command(self, cmd: Dict) -> Dict:
"""Process control commands"""
action = cmd.get("action")
if action == "reload":
try:
old_confidence = self.config.min_confidence
self.config.reload()
self.global_stats["config_reloads"] += 1
self.logger.info(f"Config reloaded: min_confidence={self.config.min_confidence}")
return {
"status": "success",
"message": "Configuration reloaded",
"changes": {
"min_confidence": {
"old": old_confidence,
"new": self.config.min_confidence,
},
},
}
except Exception as e:
return {"status": "error", "message": str(e)}
elif action == "set_confidence":
try:
confidence = float(cmd.get("value"))
if 0 <= confidence <= 1:
self.config.min_confidence = confidence
self.logger.info(f"Min confidence changed to: {confidence}")
return {
"status": "success",
"message": f"Min confidence set to {confidence}",
}
else:
return {
"status": "error",
"message": "Confidence must be between 0 and 1",
}
except (TypeError, ValueError):
return {"status": "error", "message": "Invalid confidence value"}
elif action == "set_cooldown":
try:
cooldown = int(cmd.get("value"))
if cooldown >= 0:
self.config.cooldown_seconds = cooldown
self.logger.info(f"Cooldown changed to: {cooldown}s")
return {
"status": "success",
"message": f"Cooldown set to {cooldown}s",
}
else:
return {"status": "error", "message": "Cooldown must be >= 0"}
except (TypeError, ValueError):
return {"status": "error", "message": "Invalid cooldown value"}
elif action == "toggle_debug":
self.debug_mode = not self.debug_mode
level = logging.DEBUG if self.debug_mode else logging.INFO
self.logger.setLevel(level)
self.logger.info(f"Debug mode: {'ON' if self.debug_mode else 'OFF'}")
return {
"status": "success",
"message": f"Debug mode: {'ON' if self.debug_mode else 'OFF'}",
}
elif action == "clear_cooldowns":
for engine in self.engines.values():
engine.last_signal_time.clear()
self.logger.info("All signal cooldowns cleared")
return {"status": "success", "message": "All cooldowns cleared"}
elif action == "reset_stats":
for engine in self.engines.values():
with engine.lock:
engine.stats = {
"total_signals": 0,
"buy_signals": 0,
"sell_signals": 0,
"last_signal_time": None,
"errors": 0,
}
engine.signal_history.clear()
self.global_stats["uptime_start"] = datetime.now(timezone.utc)
self.logger.info("Statistics reset")
return {"status": "success", "message": "Statistics reset"}
else:
return {"status": "error", "message": f"Unknown action: {action}"}
def accept_connections(self):
"""Accept new client connections"""
try:
conn, _ = self.socket.accept()
with self.connections_lock:
self.connections.append(conn)
self.logger.info(
f"New client connected. Total clients: {len(self.connections)}"
)
except socket.timeout:
pass
except Exception as e:
self.logger.debug(f"Accept error: {e}")
def personality_worker(self, personality: str):
"""Worker thread for a specific personality"""
engine = self.engines[personality]
self.logger.info(f"[{personality}] Worker thread started")
while self.running:
try:
for timeframe in self.config.timeframes:
try:
signal = engine.generate_signal(timeframe)
if signal:
self.broadcast_signal(signal)
except Exception as e:
self.logger.error(f"[{personality}] Error processing {timeframe}: {e}")
with engine.lock:
engine.stats["errors"] += 1
time.sleep(self.config.poll_interval)
except Exception as e:
self.logger.error(f"[{personality}] Worker error: {e}")
with engine.lock:
engine.stats["errors"] += 1
time.sleep(1) # Brief pause on error
self.logger.info(f"[{personality}] Worker thread stopped")
def run(self):
"""Main processing loop"""
self.running = True
self.setup_signal_socket()
self.setup_health_socket()
self.setup_control_socket()
self.logger.info("Multi-personality signal generator started")
self.logger.info(f"Running personalities: scalping, swing")
self.logger.info(f"Monitoring timeframes: {', '.join(self.config.timeframes)}")
self.logger.info(f"Poll interval: {self.config.poll_interval}s")
# Start personality worker threads
for personality in ["scalping", "swing"]:
thread = threading.Thread(
target=self.personality_worker,
args=(personality,),
name=f"{personality}-worker",
daemon=True
)
thread.start()
self.threads.append(thread)
try:
# Main thread handles connections and management
while self.running:
self.accept_connections()
self.handle_health_checks()
self.handle_control_commands()
time.sleep(0.1)
except KeyboardInterrupt:
self.logger.info("Received interrupt signal")
finally:
self.cleanup()
def cleanup(self):
"""Cleanup resources"""
self.logger.info("Shutting down...")
self.running = False
# Wait for worker threads
self.logger.info("Waiting for worker threads to finish...")
for thread in self.threads:
thread.join(timeout=2.0)
# Close connections
with self.connections_lock:
for conn in self.connections:
try:
conn.close()
except:
pass
if self.socket:
self.socket.close()
if os.path.exists(self.config.socket_path):
os.remove(self.config.socket_path)
if self.health_socket:
self.health_socket.close()
if os.path.exists(self.config.health_socket_path):
os.remove(self.config.health_socket_path)
if self.control_socket:
self.control_socket.close()
if os.path.exists(self.config.control_socket_path):
os.remove(self.config.control_socket_path)
self.logger.info("Shutdown complete")
def main():
config = Config()
logger = setup_logging(config)
generator = SignalGenerator(config, logger)
# Signal handlers
def reload_config(sig, frame):
"""SIGUSR1: Reload configuration"""
logger.info("Received SIGUSR1 - Reloading configuration...")
try:
old_confidence = config.min_confidence
config.reload()
generator.global_stats["config_reloads"] += 1
logger.info(
f"Configuration reloaded successfully "
f"(min_confidence: {old_confidence} -> {config.min_confidence})"
)
except Exception as e:
logger.error(f"Failed to reload configuration: {e}")
def toggle_debug(sig, frame):
"""SIGUSR2: Toggle debug logging"""
generator.debug_mode = not generator.debug_mode
level = logging.DEBUG if generator.debug_mode else logging.INFO
logger.setLevel(level)
logger.info(f"Debug mode {'enabled' if generator.debug_mode else 'disabled'}")
def shutdown(sig, frame):
"""SIGINT/SIGTERM: Graceful shutdown"""
generator.running = False
signal.signal(signal.SIGUSR1, reload_config)
signal.signal(signal.SIGUSR2, toggle_debug)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
generator.run()
if __name__ == "__main__":
main()