Add web dashboard (Redis + FastAPI + WebSocket), scope XAU penalties
Signal daemon (signal_web.py): candle-boundary scanner mirroring telegram_bot.py, publishes serialized SignalReport JSON to Redis. Web API (web/app.py): thin FastAPI reader behind Apache, serves /api/signals REST + /ws WebSocket from Redis pub/sub. Frontend (web/static/index.html): 3-pair grid, color-coded signals, indicator breakdown per timeframe, trade setup display. Also: XAU-specific penalty scoping in signal_aggregator (softer confidence penalties for gold, original strict values for forex), pair-aware session filter routing XAU to its own blocked hours.
This commit is contained in:
+303
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ForexBot2 — Signal Daemon
|
||||
==========================
|
||||
Candle-boundary auto-scanner that publishes signal data to Redis.
|
||||
Mirrors telegram_bot.py scan loops but outputs to Redis instead of Telegram.
|
||||
|
||||
Scan loop: M15/H1/H4 at candle boundaries + 10s buffer.
|
||||
Data flow: SignalAggregator → serialize → Redis key + pub/sub channel.
|
||||
|
||||
Usage:
|
||||
python signal_web.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal as unix_signal
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import redis
|
||||
|
||||
from config import (
|
||||
PlatformConfig, SignalStrength, Timeframe,
|
||||
EURUSD, GBPUSD, XAUUSD, PAIR_INFO,
|
||||
)
|
||||
from data_provider import ForexDataProvider
|
||||
from signal_aggregator import SignalAggregator, SignalReport
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
level=logging.INFO,
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger("signal_web")
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────
|
||||
|
||||
SCAN_PAIRS = [EURUSD, GBPUSD, XAUUSD]
|
||||
|
||||
AUTOSCAN_CONFIRMATIONS = {
|
||||
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
||||
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
||||
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
||||
}
|
||||
|
||||
AUTOSCAN_LOOPS = [
|
||||
(Timeframe.M15, 15),
|
||||
(Timeframe.H1, 60),
|
||||
(Timeframe.H4, 240),
|
||||
]
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
||||
REDIS_KEY = "forexbot2:signals"
|
||||
REDIS_CHANNEL = "forexbot2:updates"
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
# ── Shared data provider ──────────────────────────────────────────────
|
||||
|
||||
_shared_dp: Optional[ForexDataProvider] = None
|
||||
|
||||
|
||||
def _get_shared_dp() -> ForexDataProvider:
|
||||
global _shared_dp
|
||||
if _shared_dp is None:
|
||||
_shared_dp = ForexDataProvider(PlatformConfig())
|
||||
return _shared_dp
|
||||
|
||||
|
||||
# ── Redis client ──────────────────────────────────────────────────────
|
||||
|
||||
_redis: Optional[redis.Redis] = None
|
||||
|
||||
|
||||
def _init_redis() -> None:
|
||||
global _redis
|
||||
_redis = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
_redis.ping()
|
||||
logger.info(f"Redis connected: {REDIS_URL}")
|
||||
|
||||
|
||||
# ── Signal runner (mirrors telegram_bot.py) ───────────────────────────
|
||||
|
||||
TF_CONFIRMATIONS = {
|
||||
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
||||
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
||||
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
||||
}
|
||||
|
||||
|
||||
def _run_signal_sync(
|
||||
symbol: str,
|
||||
timeframe: Timeframe,
|
||||
confirmation_tfs: Optional[List[Timeframe]] = None,
|
||||
) -> SignalReport:
|
||||
if confirmation_tfs is None:
|
||||
confirmation_tfs = TF_CONFIRMATIONS.get(
|
||||
timeframe, [Timeframe.M15, Timeframe.H4]
|
||||
)
|
||||
config = PlatformConfig()
|
||||
config.primary_timeframe = timeframe
|
||||
config.confirmation_timeframes = confirmation_tfs
|
||||
aggregator = SignalAggregator(config)
|
||||
aggregator.data_provider = _get_shared_dp()
|
||||
return aggregator.analyze_pair(symbol=symbol)
|
||||
|
||||
|
||||
async def _run_signal(
|
||||
symbol: str,
|
||||
timeframe: Timeframe,
|
||||
confirmation_tfs: Optional[List[Timeframe]] = None,
|
||||
) -> SignalReport:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
|
||||
)
|
||||
|
||||
|
||||
# ── Serialization ─────────────────────────────────────────────────────
|
||||
|
||||
def _serialize_report(report: SignalReport) -> dict:
|
||||
indicators = {}
|
||||
if report.primary_analysis:
|
||||
for name, ind in report.primary_analysis.indicators.items():
|
||||
if name.startswith("_") or ind.weight == 0:
|
||||
continue
|
||||
indicators[name] = {
|
||||
"signal": ind.signal.name,
|
||||
"value": round(ind.value, 4),
|
||||
"confidence": round(ind.confidence, 1),
|
||||
"description": ind.description,
|
||||
}
|
||||
|
||||
confirmation_data = []
|
||||
for ca in report.confirmation_analyses:
|
||||
cf_indicators = {}
|
||||
for name, ind in ca.indicators.items():
|
||||
if name.startswith("_") or ind.weight == 0:
|
||||
continue
|
||||
cf_indicators[name] = {
|
||||
"signal": ind.signal.name,
|
||||
"value": round(ind.value, 4),
|
||||
"confidence": round(ind.confidence, 1),
|
||||
"description": ind.description,
|
||||
}
|
||||
confirmation_data.append({
|
||||
"timeframe": ca.timeframe,
|
||||
"signal": ca.composite_signal.name,
|
||||
"score": round(ca.composite_score, 1),
|
||||
"confidence": round(ca.confidence, 1),
|
||||
"regime": ca.market_regime,
|
||||
"indicators": cf_indicators,
|
||||
})
|
||||
|
||||
trade = None
|
||||
if report.trade_setup and report.final_signal not in (SignalStrength.NEUTRAL,):
|
||||
ts = report.trade_setup
|
||||
trade = {
|
||||
"direction": ts.direction,
|
||||
"entry": round(ts.entry_price, 5),
|
||||
"sl": round(ts.stop_loss, 5),
|
||||
"tp1": round(ts.take_profit_1, 5),
|
||||
"tp2": round(ts.take_profit_2, 5) if ts.take_profit_2 else None,
|
||||
"rr": round(ts.risk_reward, 2),
|
||||
"risk_pips": round(ts.risk_pips, 1),
|
||||
"reward_pips": round(ts.reward_pips, 1),
|
||||
}
|
||||
|
||||
return {
|
||||
"pair": report.display_name,
|
||||
"symbol": report.symbol,
|
||||
"timestamp": report.timestamp,
|
||||
"signal": report.final_signal.name,
|
||||
"score": round(report.final_score, 1),
|
||||
"confidence": round(report.final_confidence, 1),
|
||||
"quality": report.signal_quality,
|
||||
"regime": report.market_regime,
|
||||
"volatility": report.volatility_regime,
|
||||
"price": report.current_price,
|
||||
"mtf_agreement": report.mtf_agreement,
|
||||
"mtf_signals": {k: v.name for k, v in report.mtf_signals.items()},
|
||||
"mtf_scores": {k: round(v, 1) for k, v in report.mtf_scores.items()},
|
||||
"indicators": indicators,
|
||||
"confirmations": confirmation_data,
|
||||
"trade_setup": trade,
|
||||
"bullish_reasons": report.bullish_reasons,
|
||||
"bearish_reasons": report.bearish_reasons,
|
||||
"warnings": report.warnings,
|
||||
"action": report.action_recommendation,
|
||||
}
|
||||
|
||||
|
||||
# ── Redis publish ─────────────────────────────────────────────────────
|
||||
|
||||
def _publish_to_redis(snapshot: dict) -> None:
|
||||
payload = json.dumps(snapshot)
|
||||
_redis.set(REDIS_KEY, payload)
|
||||
_redis.publish(REDIS_CHANNEL, payload)
|
||||
|
||||
|
||||
# ── Scan logic ────────────────────────────────────────────────────────
|
||||
|
||||
_signal_store: Dict[str, Dict[str, dict]] = {}
|
||||
|
||||
|
||||
async def _scan_timeframe(tf: Timeframe, confirmation_tfs: List[Timeframe]) -> None:
|
||||
tasks = [_run_signal(symbol, tf, confirmation_tfs) for symbol in SCAN_PAIRS]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
logger.exception(f"Scan failed for {SCAN_PAIRS[i]} {tf.value}: {result}")
|
||||
continue
|
||||
|
||||
report: SignalReport = result
|
||||
display = report.display_name
|
||||
if display not in _signal_store:
|
||||
_signal_store[display] = {}
|
||||
_signal_store[display][tf.value] = _serialize_report(report)
|
||||
|
||||
snapshot = {
|
||||
"type": "update",
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
"data": _signal_store,
|
||||
}
|
||||
_publish_to_redis(snapshot)
|
||||
ok = len([r for r in results if not isinstance(r, Exception)])
|
||||
logger.info(f"{tf.value} scan complete — {ok} pairs updated")
|
||||
|
||||
|
||||
# ── Candle-boundary scheduler ────────────────────────────────────────
|
||||
|
||||
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
||||
now = datetime.now(timezone.utc)
|
||||
total_minutes = now.hour * 60 + now.minute
|
||||
elapsed_minutes = total_minutes % interval_minutes
|
||||
elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6
|
||||
interval_seconds = interval_minutes * 60
|
||||
remaining = interval_seconds - elapsed_seconds + buffer
|
||||
if remaining <= buffer:
|
||||
remaining += interval_seconds
|
||||
return remaining
|
||||
|
||||
|
||||
async def _candle_loop(tf: Timeframe, interval_minutes: int) -> None:
|
||||
label = tf.value
|
||||
try:
|
||||
while True:
|
||||
wait = _seconds_until_boundary(interval_minutes, buffer=10)
|
||||
logger.info(f"{label} scan: next in {wait:.0f}s")
|
||||
await asyncio.sleep(wait)
|
||||
logger.info(f"{label} scan: firing")
|
||||
await _scan_timeframe(tf, AUTOSCAN_CONFIRMATIONS[tf])
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
logger.info(f"{label} scan loop stopped")
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def main() -> None:
|
||||
_init_redis()
|
||||
|
||||
logger.info("Running initial M15 scan…")
|
||||
await _scan_timeframe(Timeframe.M15, AUTOSCAN_CONFIRMATIONS[Timeframe.M15])
|
||||
logger.info("Initial scan complete")
|
||||
|
||||
tasks = []
|
||||
for tf, interval in AUTOSCAN_LOOPS:
|
||||
task = asyncio.create_task(_candle_loop(tf, interval))
|
||||
tasks.append(task)
|
||||
logger.info(f"Started {tf.value} scan loop (every {interval}min)")
|
||||
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_event_loop()
|
||||
for sig in (unix_signal.SIGINT, unix_signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
|
||||
logger.info("Signal daemon running — Ctrl+C to stop")
|
||||
await stop.wait()
|
||||
|
||||
logger.info("Shutting down…")
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
_executor.shutdown(wait=False)
|
||||
logger.info("Signal daemon stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user