feat: FXStreet calendar — free upcoming events with forecasts (no API key)

FXStreet calendar.fxstreet.com/eventdate/ returns ~300-400 events over
6 weeks including consensus forecasts, no authentication required.
FF HTML scraper is blocked by Cloudflare even on residential IPs.
FMP free plan returns 403 on /economic_calendar (requires Starter plan).

- Add backend/services/fxstreet_calendar.py: single GET request returning
  all major currencies; maps Volatility 0/1/2 → low/medium/high
- Add POST /api/eco/fxs-sync + GET /api/eco/fxs-sync/status endpoints
- Add FXStreet to daily background sync in main.py (runs every 24h)
- Add "Sync Upcoming (FXStreet)" button in ImportPanel (no key needed)
- Fix FMP 403 error message to say endpoint requires Starter plan
- Keep FMP panel for users who upgrade to FMP Starter ($14.99/month)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 17:54:15 +02:00
parent 5c86d6e715
commit bdbb87962d
5 changed files with 241 additions and 14 deletions

View File

@@ -133,11 +133,13 @@ function SurpriseChip({ ev }: { ev: FFEvent }) {
// ── Import Panel ──────────────────────────────────────────────────────────────
function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) {
const [syncResult, setSyncResult] = useState<string | null>(null)
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 pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fxsPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Poll import status on mount (auto-import may be running in background)
useEffect(() => {
const poll = async () => {
const r: ImportStatus = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()).catch(() => ({ running: false, last_result: null }))
@@ -151,7 +153,10 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
}
}
poll()
return () => { if (pollRef.current) clearInterval(pollRef.current) }
return () => {
if (pollRef.current) clearInterval(pollRef.current)
if (fxsPollRef.current) clearInterval(fxsPollRef.current)
}
}, [onImported])
const startSync = async () => {
@@ -161,12 +166,29 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
await new Promise(res => setTimeout(res, 2500))
const s = await fetch(`${API}/api/eco/ff-sync/status`).then(x => x.json())
const res = s.last_result || {}
const tw = res.thisweek?.events ?? '?'
setSyncResult(`✓ live: ${tw} events`)
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) {
clearInterval(fxsPollRef.current!)
setFxsSyncing(false)
const r = s.last_result || {}
if (r.error) { setFxsMsg(`Error: ${r.error}`); return }
setFxsMsg(`✓ FXStreet: ${r.total_upserted} events (${r.date_from}${r.date_to})`)
onImported()
}
}, 2000)
} catch { setFxsSyncing(false); setFxsMsg('FXStreet error') }
}
const hasData = stats.total > 0
const res = importStatus.last_result
@@ -185,14 +207,25 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
</div>
</div>
{/* Live sync — this week from faireconomy.media JSON */}
{/* FF live sync — this week actuals */}
<button
onClick={startSync}
className="px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 flex items-center gap-1.5 text-white"
title="Update this week actuals from faireconomy.media (Forex Factory JSON)"
>
<RefreshCw size={13} />
Sync Live
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"
title="Fetch upcoming 6 weeks with consensus forecasts from FXStreet (no API key required)"
>
<RefreshCw size={13} className={clsx(fxsSyncing && 'animate-spin')} />
Sync Upcoming (FXStreet)
</button>
{res && !importStatus.running && (
@@ -201,6 +234,11 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
: <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>}
{fxsMsg && (
<span className={clsx('text-xs', fxsMsg.startsWith('✓') ? 'text-emerald-400' : fxsMsg.startsWith('Error') ? 'text-red-400' : 'text-yellow-400')}>
{fxsMsg}
</span>
)}
</div>
)
}