feat: Trading Economics API for upcoming events with forecasts
- New services/te_calendar.py: fetch_upcoming(weeks_ahead) calls TE API for 9 countries (USD/EUR/GBP/JPY/AUD/CAD/NZD/CHF/CNY), converts to ff_calendar format, upserts with source='te_api' - New endpoints: GET/POST /api/eco/te-key, POST /api/eco/te-sync, GET /api/eco/te-sync/status - Daily scheduler in main.py: FF live sync + TE sync (if key configured) run 60s after startup then every 24h - CalendarPage: TEPanel with key input (password field, Enter to save, "Get free key" link to tradingeconomics.com/api/login), "Sync upcoming (6 weeks)" button with polling FF HTML scraper kept as fallback but TE API is the primary source for upcoming forecasts (no Cloudflare blocking on server IPs). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { RefreshCw, ChevronDown, AlertTriangle, Check } from 'lucide-react'
|
||||
import { RefreshCw, ChevronDown, AlertTriangle, Check, Key } from 'lucide-react'
|
||||
|
||||
const API = ''
|
||||
|
||||
@@ -38,6 +38,8 @@ interface ImportStatus {
|
||||
last_result: { inserted?: number; skipped?: number; total?: number; error?: string } | null
|
||||
}
|
||||
|
||||
interface TEKeyStatus { configured: boolean; preview: string }
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PERIODS = [
|
||||
@@ -303,6 +305,124 @@ function ImpactFilter({ selected, onChange }: { selected: string[]; onChange: (v
|
||||
)
|
||||
}
|
||||
|
||||
// ── Trading Economics Panel ───────────────────────────────────────────────────
|
||||
|
||||
function TEPanel({ onSynced }: { onSynced: () => void }) {
|
||||
const [keyStatus, setKeyStatus] = useState<TEKeyStatus | null>(null)
|
||||
const [keyInput, setKeyInput] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API}/api/eco/te-key`).then(r => r.json()).then(setKeyStatus).catch(() => {})
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}, [])
|
||||
|
||||
const saveKey = async () => {
|
||||
if (!keyInput.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const r = await fetch(`${API}/api/eco/te-key?key=${encodeURIComponent(keyInput.trim())}`, { method: 'POST' }).then(x => x.json())
|
||||
setKeyStatus({ configured: true, preview: r.preview })
|
||||
setKeyInput('')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const startSync = async () => {
|
||||
setSyncing(true)
|
||||
setSyncMsg('syncing…')
|
||||
try {
|
||||
await fetch(`${API}/api/eco/te-sync?weeks=6`, { method: 'POST' })
|
||||
pollRef.current = setInterval(async () => {
|
||||
const s = await fetch(`${API}/api/eco/te-sync/status`).then(x => x.json())
|
||||
if (!s.running) {
|
||||
clearInterval(pollRef.current!)
|
||||
setSyncing(false)
|
||||
const r = s.last_result || {}
|
||||
if (r.error) { setSyncMsg(`Error: ${r.error}`); return }
|
||||
setSyncMsg(`✓ ${r.total_upserted} events (${r.date_from} → ${r.date_to})`)
|
||||
onSynced()
|
||||
}
|
||||
}, 2000)
|
||||
} catch { setSyncing(false); setSyncMsg('sync error') }
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') saveKey() }
|
||||
|
||||
const configured = keyStatus?.configured
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 border border-indigo-800/50 rounded-lg p-4 mb-4 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key size={14} className="text-indigo-400" />
|
||||
<span className="text-slate-300 font-medium">Trading Economics</span>
|
||||
<span className="text-xs text-slate-500">upcoming forecasts — free API</span>
|
||||
</div>
|
||||
|
||||
{configured ? (
|
||||
<span className="text-emerald-400 text-xs flex items-center gap-1">
|
||||
<Check size={11} /> key: {keyStatus?.preview}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={keyInput}
|
||||
onChange={e => setKeyInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Paste TE API key…"
|
||||
className="bg-slate-700 border border-slate-600 rounded px-2 py-1 text-xs text-white w-52 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={saveKey}
|
||||
disabled={saving || !keyInput.trim()}
|
||||
className="px-2.5 py-1 rounded bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-xs text-white"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<a
|
||||
href="https://tradingeconomics.com/api/login"
|
||||
target="_blank" rel="noreferrer"
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 underline"
|
||||
>
|
||||
Get free key ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configured && (
|
||||
<button
|
||||
onClick={startSync}
|
||||
disabled={syncing}
|
||||
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"
|
||||
>
|
||||
<RefreshCw size={13} className={clsx(syncing && 'animate-spin')} />
|
||||
Sync upcoming (6 weeks)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{configured && (
|
||||
<button
|
||||
onClick={() => { setKeyStatus({ configured: false, preview: '' }); setKeyInput('') }}
|
||||
className="text-xs text-slate-500 hover:text-slate-400 underline"
|
||||
>
|
||||
Change key
|
||||
</button>
|
||||
)}
|
||||
|
||||
{syncMsg && (
|
||||
<span className={clsx('text-xs', syncMsg.startsWith('✓') ? 'text-emerald-400' : syncMsg.startsWith('Error') ? 'text-red-400' : 'text-yellow-400')}>
|
||||
{syncMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CalendarPage() {
|
||||
@@ -400,6 +520,9 @@ export default function CalendarPage() {
|
||||
{/* Import Panel */}
|
||||
<ImportPanel stats={stats} onImported={() => { fetchStats(); fetchEvents() }} />
|
||||
|
||||
{/* Trading Economics Panel — upcoming forecasts */}
|
||||
<TEPanel onSynced={() => { fetchStats(); fetchEvents() }} />
|
||||
|
||||
{/* Period Tabs */}
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{PERIODS.map(p => (
|
||||
|
||||
Reference in New Issue
Block a user