Initial commit: Quantum Terminal — Free & Open Source Trading Platform
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
# version: v2
|
||||
"""
|
||||
================================================================================
|
||||
Quantum Terminal Consumer — Periodic Data Sync Poller
|
||||
================================================================================
|
||||
Background asyncio task that polls the VPS at a configurable interval and
|
||||
broadcasts a 'data_sync_complete' WebSocket event whenever fresh data arrives.
|
||||
This solves the problem where a long-running terminal would never re-check
|
||||
the server after the initial startup gate.
|
||||
|
||||
Configurable via consumer_config.ini:
|
||||
[sync]
|
||||
poll_interval_seconds = 300
|
||||
|
||||
Default interval: 300s (5 minutes). Floor: 60s (no hammering the server).
|
||||
|
||||
Rule C1 compliance: this module performs ZERO calculations. It only re-runs
|
||||
the same display-data sync as the startup gate (consumer_gate -> sync_all).
|
||||
|
||||
This module does NOT introduce any calculation triggers.
|
||||
================================================================================
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
# v2: AppData dir name (env-var driven; default "QuantumTerminal" preserves v1 path).
|
||||
APP_DIR = os.environ.get("MK_APP_DIR_NAME", "QuantumTerminal")
|
||||
import configparser
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from pulse_room.sync_hook import on_sync_complete as _pulse_on_sync_complete
|
||||
|
||||
log = logging.getLogger("mk.periodic_sync")
|
||||
|
||||
DEFAULT_INTERVAL_SECONDS = 300 # 5 minutes
|
||||
MIN_INTERVAL_SECONDS = 60 # safety floor
|
||||
|
||||
|
||||
def _read_interval() -> int:
|
||||
"""
|
||||
Read sync.poll_interval_seconds from consumer_config.ini.
|
||||
Searches the same locations as data_sync_client._read_config().
|
||||
Returns DEFAULT_INTERVAL_SECONDS if not configured.
|
||||
Floor is enforced at MIN_INTERVAL_SECONDS.
|
||||
"""
|
||||
project_root = Path(__file__).resolve().parent
|
||||
|
||||
candidates = [
|
||||
project_root / "consumer_config.ini",
|
||||
project_root.parent / "consumer_config.ini",
|
||||
]
|
||||
|
||||
import sys as _sys
|
||||
if getattr(_sys, "frozen", False):
|
||||
candidates.append(Path(_sys.executable).parent / "consumer_config.ini")
|
||||
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
candidates.append(Path(appdata) / APP_DIR / "consumer_config.ini")
|
||||
|
||||
for p in candidates:
|
||||
if not p.exists():
|
||||
continue
|
||||
try:
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(str(p), encoding="utf-8-sig")
|
||||
if cp.has_option("sync", "poll_interval_seconds"):
|
||||
val = cp.getint("sync", "poll_interval_seconds")
|
||||
return max(MIN_INTERVAL_SECONDS, val)
|
||||
break
|
||||
except Exception as e:
|
||||
log.warning(f"Could not parse sync interval from {p.name}: {e}")
|
||||
break
|
||||
|
||||
return DEFAULT_INTERVAL_SECONDS
|
||||
|
||||
|
||||
def create_periodic_sync_task(
|
||||
broadcast_event: Callable[[dict], Awaitable[None]],
|
||||
) -> Callable:
|
||||
"""
|
||||
Returns an async coroutine factory that polls sync_all() at a configurable
|
||||
interval. Pass the result to asyncio.create_task() inside the FastAPI
|
||||
lifespan.
|
||||
|
||||
Usage in data_server.py lifespan:
|
||||
from periodic_sync import create_periodic_sync_task
|
||||
sync_task = create_periodic_sync_task(manager.broadcast_event)
|
||||
tasks.append(asyncio.create_task(sync_task()))
|
||||
|
||||
Each tick the task:
|
||||
1. Calls data_sync_client.sync_all() in a worker thread
|
||||
2. Logs the result (synced / skipped / offline)
|
||||
3. If new files arrived (synced > 0), broadcasts WS event:
|
||||
{"type": "data_sync_complete", "synced": N, ...}
|
||||
Frontend (useTerminalData.js) listens and refreshes panels.
|
||||
|
||||
Errors do not kill the task — they back off and retry on the next interval.
|
||||
"""
|
||||
interval = _read_interval()
|
||||
log.info(f"Periodic sync task configured: interval={interval}s")
|
||||
|
||||
async def _task():
|
||||
# Wait one full interval before first poll —
|
||||
# the startup gate has already done the initial sync.
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
from data_sync_client import get_sync_client
|
||||
client = get_sync_client()
|
||||
|
||||
# sync_all() does blocking HTTP — run in a thread
|
||||
summary = await asyncio.to_thread(client.sync_all)
|
||||
|
||||
synced = summary.get("synced", 0)
|
||||
skipped = summary.get("skipped", 0)
|
||||
offline = summary.get("offline_mode", False)
|
||||
|
||||
log.info(
|
||||
f"Periodic sync tick: synced={synced}, "
|
||||
f"skipped={skipped}, offline={offline}"
|
||||
)
|
||||
|
||||
# Only broadcast when fresh data actually arrived.
|
||||
# Skipping silent ticks keeps frontend quiet during quiet hours.
|
||||
if synced > 0:
|
||||
try:
|
||||
await broadcast_event({
|
||||
"type": "data_sync_complete",
|
||||
"synced": synced,
|
||||
"skipped": skipped,
|
||||
"offline_mode": offline,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to broadcast data_sync_complete: {e}")
|
||||
|
||||
# Pulse Room auto-rebuild — only when fresh source data
|
||||
# arrived. Fires only if scanner ON + watchlist non-empty
|
||||
# (eligibility check is inside on_sync_complete itself);
|
||||
# daemon thread, fire-and-forget; never breaks sync pipeline.
|
||||
try:
|
||||
_pulse_on_sync_complete()
|
||||
except Exception:
|
||||
log.exception("pulse_room.sync_hook threw")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
log.info("Periodic sync task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Periodic sync task error: {e}", exc_info=True)
|
||||
# Back off on error but never longer than the configured interval
|
||||
try:
|
||||
await asyncio.sleep(min(interval, 120))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
return _task
|
||||
Reference in New Issue
Block a user