feat: import Trading Economics HTML calendar — 993 events with forecasts

Adds a one-click upload+parse flow for the TE calendar page HTML.
The TE page (tradingeconomics.com/calendar) contains ~1000 events with
actuals, previous, and analyst consensus (forecast) values.

- Add backend/services/te_html_parser.py:
  - parse_html(html): extracts events from <tr data-event> rows
  - Maps 14 countries to major currencies (all Eurozone → EUR)
  - Impact inferred from data-category (high/medium/low)
  - Times converted from Europe/Zurich (CET/CEST) → UTC via zoneinfo
  - Actual=td[5], Forecast=td[7] (analyst consensus), Previous=td[6]
- Add POST /api/eco/te-html-upload (saves file to /tmp)
- Add POST /api/eco/te-html-import + GET /status (background parse)
- Add "Import TE HTML" upload button in CalendarPage ImportPanel

Tested locally: 993 events parsed, 854 with forecast, 861 with actual,
date range 2025-03-31 → 2026-06-17, timezone conversion verified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 18:15:02 +02:00
parent bdbb87962d
commit 87ded9434f
3 changed files with 377 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, KeyboardEvent } from 'react'
import { useState, useEffect, useCallback, useRef, KeyboardEvent, useId } from 'react'
import clsx from 'clsx'
import { RefreshCw, ChevronDown, AlertTriangle, Check, Key } from 'lucide-react'
import { RefreshCw, ChevronDown, AlertTriangle, Check, Key, Upload } from 'lucide-react'
const API = ''
@@ -190,6 +190,38 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
} catch { setFxsSyncing(false); setFxsMsg('FXStreet error') }
}
// 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) => {
setTeHtmlBusy(true)
setTeHtmlMsg('uploading…')
try {
const form = new FormData()
form.append('file', file)
const up = await fetch(`${API}/api/eco/te-html-upload`, { method: 'POST', body: form }).then(x => x.json())
if (up.error) { setTeHtmlMsg(`Upload error: ${up.error}`); setTeHtmlBusy(false); return }
setTeHtmlMsg(`uploaded (${up.size_mb} MB) — importing…`)
await fetch(`${API}/api/eco/te-html-import`, { method: 'POST' })
teHtmlPollRef.current = setInterval(async () => {
const s = await fetch(`${API}/api/eco/te-html-import/status`).then(x => x.json())
if (!s.running) {
clearInterval(teHtmlPollRef.current!)
setTeHtmlBusy(false)
const r = s.last_result || {}
if (r.error) { setTeHtmlMsg(`Error: ${r.error}`); return }
setTeHtmlMsg(`✓ TE HTML: ${r.total} events (${r.date_from}${r.date_to})`)
onImported()
}
}, 2000)
} catch { setTeHtmlBusy(false); setTeHtmlMsg('error') }
}
useEffect(() => () => { if (teHtmlPollRef.current) clearInterval(teHtmlPollRef.current) }, [])
const hasData = stats.total > 0
const res = importStatus.last_result
@@ -228,6 +260,29 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
Sync Upcoming (FXStreet)
</button>
{/* TE HTML import — paste calendar_data.txt from tradingeconomics.com */}
<label
htmlFor={fileInputId}
className={clsx(
'px-3 py-1.5 rounded flex items-center gap-1.5 text-white cursor-pointer',
teHtmlBusy
? 'bg-amber-800 opacity-50 cursor-not-allowed'
: 'bg-amber-700 hover:bg-amber-600'
)}
title="Import Trading Economics HTML page (calendar_data.txt) — 1000 events with forecasts"
>
<Upload size={13} />
Import TE HTML
</label>
<input
id={fileInputId}
type="file"
accept=".txt,.html"
className="hidden"
disabled={teHtmlBusy}
onChange={e => { const f = e.target.files?.[0]; if (f) uploadAndImportTeHtml(f); e.target.value = '' }}
/>
{res && !importStatus.running && (
res.error
? <span className="text-red-400 text-xs">Import error: {res.error}</span>
@@ -239,6 +294,11 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
{fxsMsg}
</span>
)}
{teHtmlMsg && (
<span className={clsx('text-xs', teHtmlMsg.startsWith('✓') ? 'text-emerald-400' : teHtmlMsg.startsWith('Error') ? 'text-red-400' : 'text-yellow-400')}>
{teHtmlMsg}
</span>
)}
</div>
)
}