diff --git a/backend/main.py b/backend/main.py index f65c0d8..0e8c418 100644 --- a/backend/main.py +++ b/backend/main.py @@ -180,6 +180,10 @@ def startup(): # Start Saxo OAuth token refresh + options-chain snapshot poller from services.saxo_scheduler import start_saxo_scheduler start_saxo_scheduler() + # Start Saxo-priced Watchlist + wavelet recompute refresh (own cadence, independent of + # the once-a-day auto_cycle — see services/wavelet_scheduler.py) + from services.wavelet_scheduler import start_wavelet_scheduler + start_wavelet_scheduler() # One-time cleanup: collapse snapshot rows stored before save-time dedup existed try: @@ -270,6 +274,8 @@ def shutdown(): stop_institutional_scheduler() from services.saxo_scheduler import stop_saxo_scheduler stop_saxo_scheduler() + from services.wavelet_scheduler import stop_wavelet_scheduler + stop_wavelet_scheduler() app.include_router(market_data.router) diff --git a/backend/routers/wavelet.py b/backend/routers/wavelet.py index 61e0563..ba4bff5 100644 --- a/backend/routers/wavelet.py +++ b/backend/routers/wavelet.py @@ -211,6 +211,35 @@ def wavelet_reliability_endpoint( return result +# ── Watchlist refresh scheduler — Saxo-priced quotes + wavelet recompute, own cadence ── +# ── (services/wavelet_scheduler.py), independent of the once-a-day auto_cycle ───────── + +class RefreshSettingsRequest(BaseModel): + enabled: bool + refresh_minutes: float + + +@router.get("/refresh-settings") +def get_refresh_settings(): + from services.wavelet_scheduler import get_settings + return get_settings() + + +@router.put("/refresh-settings") +def update_refresh_settings(req: RefreshSettingsRequest): + from services.wavelet_scheduler import set_settings, get_settings + set_settings(req.enabled, req.refresh_minutes) + return get_settings() + + +@router.post("/refresh-now") +def refresh_now(): + """Manual immediate refresh of the whole Watchlist (Saxo-priced quotes + wavelet + recompute) — doesn't wait for the periodic poll.""" + from services.wavelet_scheduler import run_refresh_pass + return {"signal_rows": run_refresh_pass()} + + # ── Saved simulation/optimization runs ──────────────────────────────────────── class SimulationCreate(BaseModel): diff --git a/backend/services/wavelet_scheduler.py b/backend/services/wavelet_scheduler.py new file mode 100644 index 0000000..94755f6 --- /dev/null +++ b/backend/services/wavelet_scheduler.py @@ -0,0 +1,76 @@ +""" +Periodic Saxo-priced Watchlist refresh + wavelet recompute — mirrors services/saxo_scheduler.py's +pattern (own thread, own config-driven interval, `while not stop.wait(0)` so the first pass runs +immediately on startup). Independent of services/auto_cycle.py's once-a-day cycle, which also runs +this same computation as one of its steps but only at cycle cadence — this lets the Dashboard's +Wavelets Signal card and Instrument Analysis's cached Wavelet tab (services.wavelet_signals. +scan_watchlist_wavelet_signals writes both) stay current without waiting for, or manually +triggering, a full cycle. +""" +from __future__ import annotations + +import logging +import threading +import uuid + +logger = logging.getLogger(__name__) + +_thread: threading.Thread | None = None +_stop = threading.Event() + +DEFAULT_REFRESH_MINUTES = 15 + + +def get_settings() -> dict: + from .database import get_config + enabled = (get_config("wavelet_refresh_enabled") or "true").lower() == "true" + try: + minutes = float(get_config("wavelet_refresh_minutes") or str(DEFAULT_REFRESH_MINUTES)) + except (TypeError, ValueError): + minutes = DEFAULT_REFRESH_MINUTES + return {"enabled": enabled, "refresh_minutes": minutes} + + +def set_settings(enabled: bool, refresh_minutes: float) -> None: + from .database import set_config + set_config("wavelet_refresh_enabled", "true" if enabled else "false") + set_config("wavelet_refresh_minutes", str(max(1.0, refresh_minutes))) + + +def run_refresh_pass() -> int: + """One pass over the whole Watchlist: Saxo-first price fetch + wavelet recompute (see + services.wavelet_signals.scan_watchlist_wavelet_signals) — the same work the daily cycle's + own wavelet step does, just callable on its own cadence. Shared by the periodic loop and + the manual 'refresh now' button. Returns the number of signal rows written.""" + from .wavelet_signals import compute_and_save_wavelet_signals + run_id = f"refresh-{uuid.uuid4().hex[:10]}" + try: + results = compute_and_save_wavelet_signals(run_id) + logger.info(f"[Wavelet Scheduler] Refresh pass complete: {len(results)} signal rows ({run_id})") + return len(results) + except Exception as e: + logger.warning(f"[Wavelet Scheduler] Refresh pass failed: {e}") + return 0 + + +def _loop(stop: threading.Event): + while not stop.wait(0): + settings = get_settings() + if not settings["enabled"]: + stop.wait(timeout=300) # re-check periodically in case it gets enabled without a restart + continue + run_refresh_pass() + stop.wait(timeout=settings["refresh_minutes"] * 60) + + +def start_wavelet_scheduler(): + global _thread + _stop.clear() + if not (_thread and _thread.is_alive()): + _thread = threading.Thread(target=_loop, args=(_stop,), name="wavelet-refresh", daemon=True) + _thread.start() + logger.info("[Wavelet Scheduler] Started") + + +def stop_wavelet_scheduler(): + _stop.set() diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 0f96967..02bed33 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1852,6 +1852,28 @@ export const useUpdateSaxoSettings = () => { }) } +// Wavelet Watchlist refresh — Saxo-priced quotes + wavelet recompute, own cadence +// (services/wavelet_scheduler.py), independent of the once-a-day auto_cycle. +export const useWaveletRefreshSettings = () => + useQuery<{ enabled: boolean; refresh_minutes: number }>({ + queryKey: ['wavelet-refresh-settings'], + queryFn: () => api.get('/wavelet/refresh-settings').then(r => r.data), + }) + +export const useUpdateWaveletRefreshSettings = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (settings: { enabled: boolean; refresh_minutes: number }) => + api.put('/wavelet/refresh-settings', settings).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['wavelet-refresh-settings'] }), + }) +} + +export const useWaveletRefreshNow = () => + useMutation({ + mutationFn: () => api.post('/wavelet/refresh-now').then(r => r.data as { signal_rows: number }), + }) + export type SaxoSnapshotRow = { id: string; symbol: string; snapshot_date: string; spot: number | null expiry_date: string; strike: number; option_type: 'call' | 'put' diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index f230145..377136b 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, useSetWatchlistSaxoOptionLink, useSetWatchlistSaxoQuoteLink, useRenameWatchlistInstrument, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, type CycleStepDef } from '../hooks/useApi' +import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, useSetWatchlistSaxoOptionLink, useSetWatchlistSaxoQuoteLink, useRenameWatchlistInstrument, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, useWaveletRefreshSettings, useUpdateWaveletRefreshSettings, useWaveletRefreshNow, type CycleStepDef } from '../hooks/useApi' import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera, ShieldCheck, ExternalLink } from 'lucide-react' import clsx from 'clsx' import SaxoLinkPicker from '../components/SaxoLinkPicker' @@ -492,6 +492,9 @@ function SaxoConnectionCard() { const { data: settings } = useSaxoSettings() const updateSettings = useUpdateSaxoSettings() const snapshotAllNow = useSnapshotAllSaxoNow() + const { data: waveletRefreshSettings } = useWaveletRefreshSettings() + const updateWaveletRefreshSettings = useUpdateWaveletRefreshSettings() + const waveletRefreshNow = useWaveletRefreshNow() const [input, setInput] = useState('') const [snapMsg, setSnapMsg] = useState('') const { data: catalogMatches } = useSaxoCatalog(undefined, input.length >= 2 ? input : undefined) @@ -649,6 +652,41 @@ function SaxoConnectionCard() { + +
OG:xcme) :{' '}