Calendar synchro
This commit is contained in:
@@ -106,41 +106,28 @@ def startup():
|
|||||||
from services.institutional_scheduler import start_institutional_scheduler
|
from services.institutional_scheduler import start_institutional_scheduler
|
||||||
start_institutional_scheduler()
|
start_institutional_scheduler()
|
||||||
|
|
||||||
# Daily calendars sync — FF live (this week) + TE upcoming (if key configured)
|
# Calendar sync loop — FXStreet primary, FF fallback; interval from calendar_refresh_h config (default 6h)
|
||||||
import threading, time as _time
|
import threading, time as _time
|
||||||
def _daily_calendar_sync():
|
def _calendar_sync_loop():
|
||||||
_time.sleep(60) # let server fully boot first
|
_time.sleep(60) # let server fully boot
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# FF live JSON — this week actuals
|
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||||||
from services.ff_calendar import sync_live
|
interval_h = float(get_config("calendar_refresh_h") or 6)
|
||||||
r = sync_live()
|
from services.calendar_sync import calendar_sync, set_next_run
|
||||||
print(f"[Daily] FF live sync: {r}", flush=True)
|
set_next_run((_dt.now(_tz.utc) + _td(hours=interval_h)).isoformat())
|
||||||
|
r = calendar_sync()
|
||||||
|
src = r.get("source") or ("ff_fallback" if r.get("_fallback") else "fxstreet")
|
||||||
|
print(f"[Calendar] Sync done — {src}, upserted={r.get('total_upserted', '?')}", flush=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Daily] FF live sync error: {e}", flush=True)
|
print(f"[Calendar] Sync loop error: {e}", flush=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# FXStreet — upcoming forecasts (no API key needed)
|
interval_h = float(get_config("calendar_refresh_h") or 6)
|
||||||
from services.fxstreet_calendar import fetch_upcoming as fxs_fetch
|
except Exception:
|
||||||
r = fxs_fetch(weeks_ahead=6)
|
interval_h = 6
|
||||||
print(f"[Daily] FXStreet sync: {r.get('total_upserted', 0)} upserted", flush=True)
|
_time.sleep(int(interval_h * 3600))
|
||||||
except Exception as e:
|
|
||||||
print(f"[Daily] FXStreet sync error: {e}", flush=True)
|
|
||||||
|
|
||||||
try:
|
threading.Thread(target=_calendar_sync_loop, daemon=True).start()
|
||||||
# FMP — upcoming forecasts (only if key is set + Starter plan)
|
|
||||||
from services.database import get_config
|
|
||||||
fmp_key = get_config("fmp_api_key") or ""
|
|
||||||
if fmp_key:
|
|
||||||
from services.fmp_calendar import fetch_upcoming
|
|
||||||
r = fetch_upcoming(weeks_ahead=6)
|
|
||||||
print(f"[Daily] FMP sync: {r.get('total_upserted', 0)} upserted", flush=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Daily] FMP sync error: {e}", flush=True)
|
|
||||||
|
|
||||||
_time.sleep(86400) # repeat every 24h
|
|
||||||
|
|
||||||
threading.Thread(target=_daily_calendar_sync, daemon=True).start()
|
|
||||||
|
|
||||||
# Auto-import Forex Factory CSV if file is present (force, then delete CSV)
|
# Auto-import Forex Factory CSV if file is present (force, then delete CSV)
|
||||||
import threading
|
import threading
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ApiKeysRequest(BaseModel):
|
|||||||
newsapi_key: Optional[str] = None
|
newsapi_key: Optional[str] = None
|
||||||
eia_api_key: Optional[str] = None
|
eia_api_key: Optional[str] = None
|
||||||
fred_api_key: Optional[str] = None
|
fred_api_key: Optional[str] = None
|
||||||
|
calendar_refresh_h: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SourcesRequest(BaseModel):
|
class SourcesRequest(BaseModel):
|
||||||
@@ -61,6 +62,9 @@ def update_api_keys(req: ApiKeysRequest):
|
|||||||
if req.fred_api_key is not None:
|
if req.fred_api_key is not None:
|
||||||
set_config("fred_api_key", req.fred_api_key)
|
set_config("fred_api_key", req.fred_api_key)
|
||||||
updated.append("fred")
|
updated.append("fred")
|
||||||
|
if req.calendar_refresh_h is not None:
|
||||||
|
set_config("calendar_refresh_h", req.calendar_refresh_h)
|
||||||
|
updated.append("calendar_refresh_h")
|
||||||
return {"status": "ok", "updated": updated}
|
return {"status": "ok", "updated": updated}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -570,6 +570,36 @@ def fxs_sync_status_ep() -> Dict[str, Any]:
|
|||||||
return _fxs_sync_status
|
return _fxs_sync_status
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unified calendar sync ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _run_calendar_sync(weeks_ahead: int):
|
||||||
|
from services.calendar_sync import calendar_sync
|
||||||
|
try:
|
||||||
|
calendar_sync(weeks_ahead=weeks_ahead)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[eco/calendar-sync] Failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calendar-sync")
|
||||||
|
def calendar_sync_ep(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
weeks: int = Query(8, ge=1, le=12),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Trigger unified calendar sync (FXStreet primary → FF fallback) in background."""
|
||||||
|
from services.calendar_sync import get_sync_status
|
||||||
|
if get_sync_status()["running"]:
|
||||||
|
raise HTTPException(409, "Calendar sync already running")
|
||||||
|
background_tasks.add_task(_run_calendar_sync, weeks)
|
||||||
|
return {"status": "started", "weeks_ahead": weeks}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendar-sync/status")
|
||||||
|
def calendar_sync_status_ep() -> Dict[str, Any]:
|
||||||
|
"""Get unified calendar sync status (last_run, source, last_result, next_run)."""
|
||||||
|
from services.calendar_sync import get_sync_status
|
||||||
|
return get_sync_status()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calendar")
|
@router.get("/calendar")
|
||||||
def ff_calendar(
|
def ff_calendar(
|
||||||
period: str = Query("recent", description="recent|today|tomorrow|yesterday|this_week|next_week|previous_week|this_month|next_month|previous_month|custom"),
|
period: str = Query("recent", description="recent|today|tomorrow|yesterday|this_week|next_week|previous_week|this_month|next_month|previous_month|custom"),
|
||||||
|
|||||||
61
backend/services/calendar_sync.py
Normal file
61
backend/services/calendar_sync.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Unified calendar sync — FXStreet (primary) with FF live fallback.
|
||||||
|
Called by the background loop in main.py and manually via POST /api/eco/calendar-sync.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_sync_status: Dict[str, Any] = {
|
||||||
|
"running": False,
|
||||||
|
"last_run": None,
|
||||||
|
"last_result": None,
|
||||||
|
"source": None,
|
||||||
|
"next_run": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_sync(weeks_ahead: int = 8) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync upcoming economic events:
|
||||||
|
1. Try FXStreet (no API key, ~300-400 events over weeks_ahead weeks)
|
||||||
|
2. Fall back to FF live JSON if FXStreet returns 403 or fails
|
||||||
|
"""
|
||||||
|
global _sync_status
|
||||||
|
_sync_status["running"] = True
|
||||||
|
_sync_status["last_run"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
source = "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from services.fxstreet_calendar import fetch_upcoming as fxs_fetch
|
||||||
|
r = fxs_fetch(weeks_ahead=weeks_ahead)
|
||||||
|
if r.get("error"):
|
||||||
|
logger.warning(f"[calendar_sync] FXStreet error: {r['error']} — falling back to FF live")
|
||||||
|
from services.ff_calendar import sync_live
|
||||||
|
r_ff = sync_live()
|
||||||
|
result = {**r_ff, "_fallback": True, "_fxs_error": r["error"]}
|
||||||
|
source = "ff_fallback"
|
||||||
|
else:
|
||||||
|
result = r
|
||||||
|
source = "fxstreet"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[calendar_sync] Exception: {e}")
|
||||||
|
result = {"error": str(e)}
|
||||||
|
source = "error"
|
||||||
|
|
||||||
|
_sync_status["running"] = False
|
||||||
|
_sync_status["last_result"] = result
|
||||||
|
_sync_status["source"] = source
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_status() -> Dict[str, Any]:
|
||||||
|
return dict(_sync_status)
|
||||||
|
|
||||||
|
|
||||||
|
def set_next_run(ts: str):
|
||||||
|
_sync_status["next_run"] = ts
|
||||||
@@ -131,16 +131,50 @@ function SurpriseChip({ ev }: { ev: FFEvent }) {
|
|||||||
|
|
||||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CalSyncStatus {
|
||||||
|
running: boolean
|
||||||
|
last_run: string | null
|
||||||
|
last_result: Record<string, any> | null
|
||||||
|
source: string | null
|
||||||
|
next_run: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgo(iso: string | null): string | null {
|
||||||
|
if (!iso) return null
|
||||||
|
const ms = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.floor(ms / 60000)
|
||||||
|
if (m < 1) return 'just now'
|
||||||
|
if (m < 60) return `${m}m ago`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${h}h ${m % 60}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNext(iso: string | null): string | null {
|
||||||
|
if (!iso) return null
|
||||||
|
const ms = new Date(iso).getTime() - Date.now()
|
||||||
|
if (ms <= 0) return 'imminent'
|
||||||
|
const m = Math.floor(ms / 60000)
|
||||||
|
if (m < 60) return `in ${m}m`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `in ${h}h ${m % 60}m`
|
||||||
|
}
|
||||||
|
|
||||||
function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) {
|
function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) {
|
||||||
const [syncResult, setSyncResult] = useState<string | null>(null)
|
|
||||||
const [fxsMsg, setFxsMsg] = useState<string | null>(null)
|
|
||||||
const [fxsSyncing, setFxsSyncing] = useState(false)
|
|
||||||
const [importStatus, setImportStatus] = useState<ImportStatus>({ running: false, last_result: null })
|
const [importStatus, setImportStatus] = useState<ImportStatus>({ running: false, last_result: null })
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const fxsPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
|
const [calStatus, setCalStatus] = useState<CalSyncStatus>({ running: false, last_run: null, last_result: null, source: null, next_run: null })
|
||||||
|
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||||
|
const calPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const [teHtmlMsg, setTeHtmlMsg] = useState<string | null>(null)
|
||||||
|
const [teHtmlBusy, setTeHtmlBusy] = useState(false)
|
||||||
|
const teHtmlPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const fileInputId = useId()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const poll = async () => {
|
// Check startup auto-import status
|
||||||
|
const pollImport = async () => {
|
||||||
const r: ImportStatus = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()).catch(() => ({ running: false, last_result: null }))
|
const r: ImportStatus = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()).catch(() => ({ running: false, last_result: null }))
|
||||||
setImportStatus(r)
|
setImportStatus(r)
|
||||||
if (r.running) {
|
if (r.running) {
|
||||||
@@ -151,50 +185,44 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
|||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
poll()
|
pollImport()
|
||||||
|
|
||||||
|
// Load calendar sync status
|
||||||
|
fetch(`${API}/api/eco/calendar-sync/status`).then(x => x.json()).then(setCalStatus).catch(() => {})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
if (fxsPollRef.current) clearInterval(fxsPollRef.current)
|
if (calPollRef.current) clearInterval(calPollRef.current)
|
||||||
|
if (teHtmlPollRef.current) clearInterval(teHtmlPollRef.current)
|
||||||
}
|
}
|
||||||
}, [onImported])
|
}, [onImported])
|
||||||
|
|
||||||
const startSync = async () => {
|
const startCalendarSync = async () => {
|
||||||
setSyncResult('syncing…')
|
setSyncMsg('syncing…')
|
||||||
|
setCalStatus(s => ({ ...s, running: true }))
|
||||||
try {
|
try {
|
||||||
await fetch(`${API}/api/eco/ff-sync`, { method: 'POST' })
|
await fetch(`${API}/api/eco/calendar-sync?weeks=8`, { method: 'POST' })
|
||||||
await new Promise(res => setTimeout(res, 2500))
|
calPollRef.current = setInterval(async () => {
|
||||||
const s = await fetch(`${API}/api/eco/ff-sync/status`).then(x => x.json())
|
const s: CalSyncStatus = await fetch(`${API}/api/eco/calendar-sync/status`).then(x => x.json())
|
||||||
const res = s.last_result || {}
|
setCalStatus(s)
|
||||||
setSyncResult(`✓ live: ${res.thisweek?.events ?? '?'} events`)
|
|
||||||
onImported()
|
|
||||||
} catch { setSyncResult('sync error') }
|
|
||||||
}
|
|
||||||
|
|
||||||
const startFxsSync = async () => {
|
|
||||||
setFxsSyncing(true)
|
|
||||||
setFxsMsg('syncing FXStreet…')
|
|
||||||
try {
|
|
||||||
await fetch(`${API}/api/eco/fxs-sync?weeks=6`, { method: 'POST' })
|
|
||||||
fxsPollRef.current = setInterval(async () => {
|
|
||||||
const s = await fetch(`${API}/api/eco/fxs-sync/status`).then(x => x.json())
|
|
||||||
if (!s.running) {
|
if (!s.running) {
|
||||||
clearInterval(fxsPollRef.current!)
|
clearInterval(calPollRef.current!)
|
||||||
setFxsSyncing(false)
|
|
||||||
const r = s.last_result || {}
|
const r = s.last_result || {}
|
||||||
if (r.error) { setFxsMsg(`Error: ${r.error}`); return }
|
if (r.error) {
|
||||||
setFxsMsg(`✓ FXStreet: ${r.total_upserted} events (${r.date_from} → ${r.date_to})`)
|
setSyncMsg(`Erreur: ${r.error}`)
|
||||||
|
} else if (s.source === 'ff_fallback') {
|
||||||
|
const tw = r.thisweek?.events ?? '?'
|
||||||
|
const nw = r.nextweek?.events ?? '?'
|
||||||
|
setSyncMsg(`✓ FF fallback: ${tw}+${nw} events`)
|
||||||
|
} else {
|
||||||
|
setSyncMsg(`✓ FXStreet: ${r.total_upserted} events`)
|
||||||
|
}
|
||||||
onImported()
|
onImported()
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch { setFxsSyncing(false); setFxsMsg('FXStreet error') }
|
} catch { setSyncMsg('sync error'); setCalStatus(s => ({ ...s, running: false })) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// TE HTML upload + import
|
|
||||||
const [teHtmlMsg, setTeHtmlMsg] = useState<string | null>(null)
|
|
||||||
const [teHtmlBusy, setTeHtmlBusy] = useState(false)
|
|
||||||
const teHtmlPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
const fileInputId = useId()
|
|
||||||
|
|
||||||
const uploadAndImportTeHtml = async (file: File) => {
|
const uploadAndImportTeHtml = async (file: File) => {
|
||||||
setTeHtmlBusy(true)
|
setTeHtmlBusy(true)
|
||||||
setTeHtmlMsg('uploading…')
|
setTeHtmlMsg('uploading…')
|
||||||
@@ -223,6 +251,7 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
|||||||
|
|
||||||
const hasData = stats.total > 0
|
const hasData = stats.total > 0
|
||||||
const res = importStatus.last_result
|
const res = importStatus.last_result
|
||||||
|
const srcLabel = calStatus.source === 'ff_fallback' ? 'FF fallback' : calStatus.source === 'fxstreet' ? 'FXStreet' : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-4 mb-4 flex flex-wrap items-center gap-4 text-sm">
|
<div className="bg-slate-800 border border-slate-700 rounded-lg p-4 mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||||
@@ -230,36 +259,32 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
|||||||
<div className="text-slate-400 text-xs mb-0.5">FF Calendar DB</div>
|
<div className="text-slate-400 text-xs mb-0.5">FF Calendar DB</div>
|
||||||
<div className="font-semibold">
|
<div className="font-semibold">
|
||||||
{importStatus.running
|
{importStatus.running
|
||||||
? <span className="text-yellow-400 animate-pulse">Importing CSV… (auto-import on startup)</span>
|
? <span className="text-yellow-400 animate-pulse">Importing CSV…</span>
|
||||||
: hasData
|
: hasData
|
||||||
? <><span className="text-emerald-400">{stats.total.toLocaleString()} events</span> <span className="text-slate-500 text-xs">({stats.earliest?.slice(0,7)} → {stats.latest?.slice(0,7)})</span></>
|
? <><span className="text-emerald-400">{stats.total.toLocaleString()} events</span> <span className="text-slate-500 text-xs">({stats.earliest?.slice(0,7)} → {stats.latest?.slice(0,7)})</span></>
|
||||||
: <span className="text-slate-400 text-xs">Auto-import will run on next server start</span>
|
: <span className="text-slate-400 text-xs">No data yet</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
{calStatus.last_run && (
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Last: {fmtAgo(calStatus.last_run)}{srcLabel ? ` via ${srcLabel}` : ''}
|
||||||
|
{calStatus.next_run && <span className="ml-2">· Next: {fmtNext(calStatus.next_run)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FF live sync — this week actuals */}
|
{/* Unified sync button */}
|
||||||
<button
|
<button
|
||||||
onClick={startSync}
|
onClick={startCalendarSync}
|
||||||
className="px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 flex items-center gap-1.5 text-white"
|
disabled={calStatus.running}
|
||||||
title="Update this week actuals from faireconomy.media (Forex Factory JSON)"
|
|
||||||
>
|
|
||||||
<RefreshCw size={13} />
|
|
||||||
Sync Live (FF)
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* FXStreet — upcoming 6 weeks + forecasts, no API key */}
|
|
||||||
<button
|
|
||||||
onClick={startFxsSync}
|
|
||||||
disabled={fxsSyncing}
|
|
||||||
className="px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 flex items-center gap-1.5 text-white"
|
className="px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 flex items-center gap-1.5 text-white"
|
||||||
title="Fetch upcoming 6 weeks with consensus forecasts from FXStreet (no API key required)"
|
title="Sync economic calendar (FXStreet primary, FF fallback if blocked)"
|
||||||
>
|
>
|
||||||
<RefreshCw size={13} className={clsx(fxsSyncing && 'animate-spin')} />
|
<RefreshCw size={13} className={clsx(calStatus.running && 'animate-spin')} />
|
||||||
Sync Upcoming (FXStreet)
|
{calStatus.running ? 'Syncing…' : 'Sync Now'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* TE HTML import — paste calendar_data.txt from tradingeconomics.com */}
|
{/* TE HTML import */}
|
||||||
<label
|
<label
|
||||||
htmlFor={fileInputId}
|
htmlFor={fileInputId}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -287,10 +312,9 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
|||||||
? <span className="text-red-400 text-xs">Import error: {res.error}</span>
|
? <span className="text-red-400 text-xs">Import error: {res.error}</span>
|
||||||
: <span className="text-emerald-400 text-xs flex items-center gap-1"><Check size={12}/> {res.inserted?.toLocaleString()} inserted</span>
|
: <span className="text-emerald-400 text-xs flex items-center gap-1"><Check size={12}/> {res.inserted?.toLocaleString()} inserted</span>
|
||||||
)}
|
)}
|
||||||
{syncResult && <span className="text-sky-400 text-xs">{syncResult}</span>}
|
{syncMsg && (
|
||||||
{fxsMsg && (
|
<span className={clsx('text-xs', syncMsg.startsWith('✓') ? 'text-emerald-400' : syncMsg.startsWith('Err') ? 'text-red-400' : 'text-yellow-400')}>
|
||||||
<span className={clsx('text-xs', fxsMsg.startsWith('✓') ? 'text-emerald-400' : fxsMsg.startsWith('Error') ? 'text-red-400' : 'text-yellow-400')}>
|
{syncMsg}
|
||||||
{fxsMsg}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{teHtmlMsg && (
|
{teHtmlMsg && (
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ export default function Config() {
|
|||||||
const [newsapiKey, setNewsapiKey] = useState('')
|
const [newsapiKey, setNewsapiKey] = useState('')
|
||||||
const [eiaKey, setEiaKey] = useState('')
|
const [eiaKey, setEiaKey] = useState('')
|
||||||
const [fredKey, setFredKey] = useState('')
|
const [fredKey, setFredKey] = useState('')
|
||||||
|
const [calendarRefreshH, setCalendarRefreshH] = useState(6)
|
||||||
const [showKeys, setShowKeys] = useState(false)
|
const [showKeys, setShowKeys] = useState(false)
|
||||||
const [savedMsg, setSavedMsg] = useState('')
|
const [savedMsg, setSavedMsg] = useState('')
|
||||||
const [configTab, setConfigTab] = useState<'ia' | 'cycle' | 'sources' | 'options' | 'profiles' | 'data'>('ia')
|
const [configTab, setConfigTab] = useState<'ia' | 'cycle' | 'sources' | 'options' | 'profiles' | 'data'>('ia')
|
||||||
@@ -472,6 +473,13 @@ export default function Config() {
|
|||||||
}
|
}
|
||||||
}, [optionsGateData])
|
}, [optionsGateData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.calendar_refresh_h) {
|
||||||
|
const h = parseFloat(config.calendar_refresh_h)
|
||||||
|
if (!isNaN(h)) setCalendarRefreshH(h)
|
||||||
|
}
|
||||||
|
}, [config])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (techIndicatorsData) {
|
if (techIndicatorsData) {
|
||||||
setTechEnabled(techIndicatorsData.tech_indicators_enabled ?? true)
|
setTechEnabled(techIndicatorsData.tech_indicators_enabled ?? true)
|
||||||
@@ -1095,6 +1103,39 @@ export default function Config() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar auto-sync frequency */}
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-3">
|
||||||
|
<RefreshCw className="w-4 h-4 text-indigo-400" /> Calendar Auto-Sync
|
||||||
|
</h2>
|
||||||
|
<div className="text-xs text-slate-400 mb-3">
|
||||||
|
Interval between automatic economic calendar syncs (FXStreet primary, Forex Factory fallback).
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs text-slate-500 whitespace-nowrap">Refresh every</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[2, 4, 6, 12, 24].map(h => (
|
||||||
|
<button key={h} onClick={() => setCalendarRefreshH(h)}
|
||||||
|
className={clsx('px-3 py-1.5 rounded text-xs transition-colors', {
|
||||||
|
'bg-indigo-700 text-white': calendarRefreshH === h,
|
||||||
|
'bg-dark-700 text-slate-400 hover:text-slate-200': calendarRefreshH !== h,
|
||||||
|
})}>
|
||||||
|
{h}h
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateApiKeys({ calendar_refresh_h: String(calendarRefreshH) }, {
|
||||||
|
onSuccess: () => { setSavedMsg('Calendar refresh saved'); setTimeout(() => setSavedMsg(''), 2000) }
|
||||||
|
})}
|
||||||
|
disabled={savingKeys}
|
||||||
|
className="flex items-center gap-1.5 bg-indigo-700 hover:bg-indigo-600 disabled:opacity-40 text-white px-3 py-1.5 rounded text-xs font-semibold">
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
{savingKeys ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user