@drico2008/fincli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +217 -217
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +29 -27
- package/fincli/app/analysis/analyzer.py +34 -34
- package/fincli/app/analysis/assistant_context.py +3 -3
- package/fincli/app/cli/commands.py +33 -27
- package/fincli/app/cli/router.py +1633 -1105
- package/fincli/app/diagnostics/__init__.py +2 -0
- package/fincli/app/diagnostics/capabilities.py +44 -0
- package/fincli/app/diagnostics/runtime.py +106 -0
- package/fincli/app/main.py +6 -1
- package/fincli/app/modules/economic_calendar.py +512 -512
- package/fincli/app/modules/portfolio_risk.py +305 -305
- package/fincli/app/modules/trading.py +142 -0
- package/fincli/app/plugins/loader.py +72 -72
- package/fincli/app/providers/market/finnhub_provider.py +51 -2
- package/fincli/app/providers/market/symbols.py +95 -2
- package/fincli/app/providers/reliability.py +82 -65
- package/fincli/app/research/__init__.py +8 -8
- package/fincli/app/research/engine.py +119 -112
- package/fincli/app/research/exporter.py +91 -91
- package/fincli/app/research/formatter.py +25 -24
- package/fincli/app/research/models.py +22 -21
- package/fincli/app/research/prompt_builder.py +53 -51
- package/fincli/app/services/data_quality.py +27 -0
- package/fincli/app/services/data_trust.py +117 -0
- package/fincli/app/services/macro_data.py +158 -50
- package/fincli/app/services/market_data.py +183 -79
- package/fincli/app/services/market_overview.py +131 -142
- package/fincli/app/services/news_aggregator.py +95 -95
- package/fincli/app/storage/config.py +6 -3
- package/fincli/app/storage/database.py +130 -117
- package/fincli/app/storage/provider_metrics.py +61 -61
- package/fincli/app/storage/secrets.py +128 -128
- package/npm/bin/fincli.js +65 -42
- package/package.json +7 -7
- package/pyproject.toml +1 -1
|
@@ -1,512 +1,512 @@
|
|
|
1
|
-
"""Economic calendar fetching and fallback formatting data."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from datetime import date, datetime, timedelta
|
|
7
|
-
import html as html_lib
|
|
8
|
-
import re
|
|
9
|
-
from typing import Any
|
|
10
|
-
import xml.etree.ElementTree as ET
|
|
11
|
-
|
|
12
|
-
import httpx
|
|
13
|
-
|
|
14
|
-
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass(frozen=True, slots=True)
|
|
18
|
-
class EconomicEvent:
|
|
19
|
-
event: str
|
|
20
|
-
country: str
|
|
21
|
-
impact: str
|
|
22
|
-
time: datetime | None
|
|
23
|
-
actual: str | None = None
|
|
24
|
-
estimate: str | None = None
|
|
25
|
-
previous: str | None = None
|
|
26
|
-
unit: str | None = None
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class EconomicCalendarService:
|
|
30
|
-
"""Fetch economic calendar events with Finnhub support."""
|
|
31
|
-
|
|
32
|
-
def __init__(
|
|
33
|
-
self,
|
|
34
|
-
api_key: str | None,
|
|
35
|
-
base_url: str = "https://finnhub.io/api/v1",
|
|
36
|
-
client: httpx.AsyncClient | None = None,
|
|
37
|
-
) -> None:
|
|
38
|
-
self.api_key = api_key or ""
|
|
39
|
-
self.base_url = base_url.rstrip("/")
|
|
40
|
-
self._client = client
|
|
41
|
-
|
|
42
|
-
async def events(self, start: date, end: date) -> list[EconomicEvent]:
|
|
43
|
-
if not self.api_key:
|
|
44
|
-
raise ProviderError(
|
|
45
|
-
"Economic calendar provider belum dikonfigurasi.",
|
|
46
|
-
"Isi FINNHUB_API_KEY di .env untuk mengambil economic calendar aktual.",
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
close_client = self._client is None
|
|
50
|
-
client = self._client or httpx.AsyncClient(timeout=30)
|
|
51
|
-
try:
|
|
52
|
-
response = await client.get(
|
|
53
|
-
f"{self.base_url}/calendar/economic",
|
|
54
|
-
params={"from": start.isoformat(), "to": end.isoformat(), "token": self.api_key},
|
|
55
|
-
)
|
|
56
|
-
if response.status_code == 429:
|
|
57
|
-
raise RateLimitError("Finnhub economic calendar terkena rate limit.")
|
|
58
|
-
response.raise_for_status()
|
|
59
|
-
payload = response.json()
|
|
60
|
-
except httpx.TimeoutException as exc:
|
|
61
|
-
raise ProviderError("Finnhub economic calendar timeout.") from exc
|
|
62
|
-
except httpx.HTTPStatusError as exc:
|
|
63
|
-
raise ProviderError(f"Finnhub economic calendar gagal: HTTP {exc.response.status_code}.") from exc
|
|
64
|
-
except ValueError as exc:
|
|
65
|
-
raise ProviderError("Response economic calendar bukan JSON valid.") from exc
|
|
66
|
-
finally:
|
|
67
|
-
if close_client:
|
|
68
|
-
await client.aclose()
|
|
69
|
-
|
|
70
|
-
raw_events = payload.get("economicCalendar") if isinstance(payload, dict) else None
|
|
71
|
-
if not isinstance(raw_events, list):
|
|
72
|
-
raise ProviderError("Response Finnhub economic calendar tidak valid.")
|
|
73
|
-
return [_parse_event(item) for item in raw_events if isinstance(item, dict)]
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
class PublicEconomicCalendarService:
|
|
77
|
-
"""Fetch no-key public economic calendar data before static fallback."""
|
|
78
|
-
|
|
79
|
-
def __init__(
|
|
80
|
-
self,
|
|
81
|
-
base_url: str = "https://nfs.faireconomy.media",
|
|
82
|
-
client: httpx.AsyncClient | None = None,
|
|
83
|
-
) -> None:
|
|
84
|
-
self.base_url = base_url.rstrip("/")
|
|
85
|
-
self._client = client
|
|
86
|
-
|
|
87
|
-
async def events(self, start: date, end: date) -> list[EconomicEvent]:
|
|
88
|
-
close_client = self._client is None
|
|
89
|
-
client = self._client or httpx.AsyncClient(
|
|
90
|
-
timeout=20,
|
|
91
|
-
follow_redirects=True,
|
|
92
|
-
headers={"User-Agent": "FinCLI/0.
|
|
93
|
-
)
|
|
94
|
-
errors: list[str] = []
|
|
95
|
-
try:
|
|
96
|
-
for source_name, fetcher in (
|
|
97
|
-
("forexfactory_json", self._fetch_forex_factory_json),
|
|
98
|
-
("forexfactory_xml", self._fetch_forex_factory_xml),
|
|
99
|
-
("fred_release_calendar", self._fetch_fred_release_calendar),
|
|
100
|
-
("tradingeconomics_guest", self._fetch_trading_economics_guest),
|
|
101
|
-
):
|
|
102
|
-
try:
|
|
103
|
-
events = await fetcher(client, start, end)
|
|
104
|
-
if events:
|
|
105
|
-
return events
|
|
106
|
-
errors.append(f"{source_name}: empty")
|
|
107
|
-
except ProviderError as exc:
|
|
108
|
-
errors.append(f"{source_name}: {exc}")
|
|
109
|
-
finally:
|
|
110
|
-
if close_client:
|
|
111
|
-
await client.aclose()
|
|
112
|
-
raise ProviderError("Semua public economic calendar fallback gagal: " + "; ".join(errors))
|
|
113
|
-
|
|
114
|
-
async def _fetch_forex_factory_json(self, client: httpx.AsyncClient, start: date, end: date) -> list[EconomicEvent]:
|
|
115
|
-
response = await _calendar_get(client, f"{self.base_url}/ff_calendar_thisweek.json", "ForexFactory public calendar")
|
|
116
|
-
payload = _calendar_json(response, "ForexFactory public calendar")
|
|
117
|
-
if not isinstance(payload, list):
|
|
118
|
-
raise ProviderError("Response ForexFactory calendar tidak valid.")
|
|
119
|
-
events = [_parse_public_event(item) for item in payload if isinstance(item, dict)]
|
|
120
|
-
return [event for event in events if event.time is None or start <= event.time.date() <= end]
|
|
121
|
-
|
|
122
|
-
async def _fetch_forex_factory_xml(self, client: httpx.AsyncClient, start: date, end: date) -> list[EconomicEvent]:
|
|
123
|
-
response = await _calendar_get(client, f"{self.base_url}/ff_calendar_thisweek.xml", "ForexFactory XML calendar")
|
|
124
|
-
events = _parse_forex_factory_xml(response.text)
|
|
125
|
-
return [event for event in events if event.time is None or start <= event.time.date() <= end]
|
|
126
|
-
|
|
127
|
-
async def _fetch_fred_release_calendar(
|
|
128
|
-
self, client: httpx.AsyncClient, start: date, end: date
|
|
129
|
-
) -> list[EconomicEvent]:
|
|
130
|
-
response = await _calendar_get(
|
|
131
|
-
client,
|
|
132
|
-
"https://fred.stlouisfed.org/releases/calendar",
|
|
133
|
-
"FRED release calendar",
|
|
134
|
-
params={"ob": "rd", "od": "asc", "vs": start.isoformat(), "ve": end.isoformat()},
|
|
135
|
-
)
|
|
136
|
-
events = _parse_fred_calendar_html(response.text)
|
|
137
|
-
return [event for event in events if event.time is None or start <= event.time.date() <= end]
|
|
138
|
-
|
|
139
|
-
async def _fetch_trading_economics_guest(
|
|
140
|
-
self, client: httpx.AsyncClient, start: date, end: date
|
|
141
|
-
) -> list[EconomicEvent]:
|
|
142
|
-
response = await _calendar_get(
|
|
143
|
-
client,
|
|
144
|
-
"https://api.tradingeconomics.com/calendar",
|
|
145
|
-
"Trading Economics guest calendar",
|
|
146
|
-
params={"c": "guest:guest", "f": "json"},
|
|
147
|
-
)
|
|
148
|
-
payload = _calendar_json(response, "Trading Economics guest calendar")
|
|
149
|
-
if not isinstance(payload, list):
|
|
150
|
-
raise ProviderError("Response Trading Economics calendar tidak valid.")
|
|
151
|
-
events = [_parse_trading_economics_event(item) for item in payload if isinstance(item, dict)]
|
|
152
|
-
return [event for event in events if event.time is None or start <= event.time.date() <= end]
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
async def _calendar_get(
|
|
156
|
-
client: httpx.AsyncClient, url: str, label: str, params: dict[str, str] | None = None
|
|
157
|
-
) -> httpx.Response:
|
|
158
|
-
try:
|
|
159
|
-
response = await client.get(url, params=params)
|
|
160
|
-
response.raise_for_status()
|
|
161
|
-
return response
|
|
162
|
-
except httpx.TimeoutException as exc:
|
|
163
|
-
raise ProviderError(f"{label} timeout.") from exc
|
|
164
|
-
except httpx.HTTPStatusError as exc:
|
|
165
|
-
raise ProviderError(f"{label} gagal: HTTP {exc.response.status_code}.") from exc
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def _calendar_json(response: httpx.Response, label: str) -> Any:
|
|
169
|
-
try:
|
|
170
|
-
return response.json()
|
|
171
|
-
except ValueError as exc:
|
|
172
|
-
raise ProviderError(f"Response {label} bukan JSON valid.") from exc
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def default_calendar_window(mode: str | None = None) -> tuple[date, date]:
|
|
176
|
-
today = date.today()
|
|
177
|
-
if mode == "today":
|
|
178
|
-
return today, today
|
|
179
|
-
if mode == "week":
|
|
180
|
-
return today, today + timedelta(days=7)
|
|
181
|
-
return today, today + timedelta(days=7)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def fallback_events(start: date, end: date) -> list[EconomicEvent]:
|
|
185
|
-
"""Return non-date-specific event categories when no provider is configured."""
|
|
186
|
-
|
|
187
|
-
return [
|
|
188
|
-
EconomicEvent("Central bank rate decisions", "Global", "high", None, unit="event group"),
|
|
189
|
-
EconomicEvent("Inflation releases: CPI/PCE", "Global", "high", None, unit="event group"),
|
|
190
|
-
EconomicEvent("Labor market data: payrolls/unemployment", "Global", "high", None, unit="event group"),
|
|
191
|
-
EconomicEvent("GDP, PMI, retail sales, consumer sentiment", "Global", "medium", None, unit="event group"),
|
|
192
|
-
EconomicEvent(
|
|
193
|
-
f"Provider window requested: {start.isoformat()} to {end.isoformat()}",
|
|
194
|
-
"FinCLI",
|
|
195
|
-
"info",
|
|
196
|
-
None,
|
|
197
|
-
unit="fallback",
|
|
198
|
-
),
|
|
199
|
-
]
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def filter_events(events: list[EconomicEvent], country: str | None = None, impact: str | None = None) -> list[EconomicEvent]:
|
|
203
|
-
filtered = events
|
|
204
|
-
if country:
|
|
205
|
-
normalized_country = country.lower()
|
|
206
|
-
filtered = [
|
|
207
|
-
event
|
|
208
|
-
for event in filtered
|
|
209
|
-
if event.country.lower() == normalized_country or event.country.lower() in {"global", "fincli"}
|
|
210
|
-
]
|
|
211
|
-
if impact:
|
|
212
|
-
normalized_impact = impact.lower()
|
|
213
|
-
filtered = [event for event in filtered if event.impact.lower() == normalized_impact]
|
|
214
|
-
return filtered
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def calendar_summary(events: list[EconomicEvent]) -> dict[str, int]:
|
|
218
|
-
summary = {"total": len(events), "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
219
|
-
for event in events:
|
|
220
|
-
key = event.impact.lower()
|
|
221
|
-
summary[key] = summary.get(key, 0) + 1
|
|
222
|
-
return summary
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def economic_event_rows(events: list[EconomicEvent]) -> list[dict[str, object]]:
|
|
226
|
-
return [
|
|
227
|
-
{
|
|
228
|
-
"time": event.time.isoformat() if event.time else "",
|
|
229
|
-
"country": event.country,
|
|
230
|
-
"impact": event.impact,
|
|
231
|
-
"event": event.event,
|
|
232
|
-
"actual": calendar_actual_value(event),
|
|
233
|
-
"estimate": calendar_estimate_value(event),
|
|
234
|
-
"previous": calendar_previous_value(event),
|
|
235
|
-
"unit": event.unit or "",
|
|
236
|
-
}
|
|
237
|
-
for event in events
|
|
238
|
-
]
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def calendar_actual_value(event: EconomicEvent) -> str:
|
|
242
|
-
if event.actual:
|
|
243
|
-
return event.actual
|
|
244
|
-
if event.unit == "event group":
|
|
245
|
-
return "category"
|
|
246
|
-
if event.unit == "fallback":
|
|
247
|
-
return "window"
|
|
248
|
-
if event.time and event.time > datetime.now(event.time.tzinfo):
|
|
249
|
-
return "pending"
|
|
250
|
-
return "not supplied"
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def calendar_estimate_value(event: EconomicEvent) -> str:
|
|
254
|
-
if event.estimate:
|
|
255
|
-
return event.estimate
|
|
256
|
-
if event.unit == "event group":
|
|
257
|
-
return "monitor"
|
|
258
|
-
if event.unit == "fallback":
|
|
259
|
-
return "provider unavailable"
|
|
260
|
-
return "not supplied by source"
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def calendar_previous_value(event: EconomicEvent) -> str:
|
|
264
|
-
if event.previous:
|
|
265
|
-
return event.previous
|
|
266
|
-
if event.unit == "event group":
|
|
267
|
-
return "verify"
|
|
268
|
-
if event.unit == "fallback":
|
|
269
|
-
return "check provider"
|
|
270
|
-
return event.unit or "not supplied by source"
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def _parse_event(item: dict[str, Any]) -> EconomicEvent:
|
|
274
|
-
return EconomicEvent(
|
|
275
|
-
event=str(item.get("event") or item.get("name") or "Untitled event"),
|
|
276
|
-
country=str(item.get("country") or "N/A"),
|
|
277
|
-
impact=str(item.get("impact") or "N/A").lower(),
|
|
278
|
-
time=_parse_time(item.get("time")),
|
|
279
|
-
actual=_optional_text(item.get("actual")),
|
|
280
|
-
estimate=_optional_text(item.get("estimate")),
|
|
281
|
-
previous=_optional_text(item.get("prev") if "prev" in item else item.get("previous")),
|
|
282
|
-
unit=_optional_text(item.get("unit")),
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _parse_public_event(item: dict[str, Any]) -> EconomicEvent:
|
|
287
|
-
return EconomicEvent(
|
|
288
|
-
event=str(item.get("title") or item.get("event") or item.get("name") or "Untitled event"),
|
|
289
|
-
country=str(item.get("country") or "N/A"),
|
|
290
|
-
impact=_normalize_impact(item.get("impact")),
|
|
291
|
-
time=_parse_time(item.get("date") or item.get("time")),
|
|
292
|
-
actual=_optional_text(item.get("actual")),
|
|
293
|
-
estimate=_optional_text(item.get("forecast") if "forecast" in item else item.get("estimate")),
|
|
294
|
-
previous=_optional_text(item.get("previous") if "previous" in item else item.get("prev")),
|
|
295
|
-
unit="public calendar",
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def _parse_forex_factory_xml(payload: str) -> list[EconomicEvent]:
|
|
300
|
-
try:
|
|
301
|
-
root = ET.fromstring(payload)
|
|
302
|
-
except ET.ParseError as exc:
|
|
303
|
-
raise ProviderError("Response ForexFactory XML calendar tidak valid.") from exc
|
|
304
|
-
|
|
305
|
-
events: list[EconomicEvent] = []
|
|
306
|
-
for item in root.findall(".//event"):
|
|
307
|
-
event_time = _parse_forex_factory_time(_child_text(item, "date"), _child_text(item, "time"))
|
|
308
|
-
events.append(
|
|
309
|
-
EconomicEvent(
|
|
310
|
-
event=_child_text(item, "title") or "Untitled event",
|
|
311
|
-
country=_child_text(item, "country") or "N/A",
|
|
312
|
-
impact=_normalize_impact(_child_text(item, "impact")),
|
|
313
|
-
time=event_time,
|
|
314
|
-
actual=_optional_text(_child_text(item, "actual")),
|
|
315
|
-
estimate=_optional_text(_child_text(item, "forecast")),
|
|
316
|
-
previous=_optional_text(_child_text(item, "previous")),
|
|
317
|
-
unit="public calendar",
|
|
318
|
-
)
|
|
319
|
-
)
|
|
320
|
-
return events
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def _parse_fred_calendar_html(payload: str) -> list[EconomicEvent]:
|
|
324
|
-
tbody_match = re.search(r"<tbody>(?P<body>.*?)</tbody>", payload, re.IGNORECASE | re.DOTALL)
|
|
325
|
-
if not tbody_match:
|
|
326
|
-
raise ProviderError("Response FRED release calendar tidak valid.")
|
|
327
|
-
|
|
328
|
-
rows = re.findall(r"<tr[^>]*>(.*?)</tr>", tbody_match.group("body"), flags=re.IGNORECASE | re.DOTALL)
|
|
329
|
-
current_date: datetime | None = None
|
|
330
|
-
events: list[EconomicEvent] = []
|
|
331
|
-
for row in rows:
|
|
332
|
-
date_match = re.search(
|
|
333
|
-
r"<span[^>]*font-weight:\s*bold[^>]*>(?P<date>[^<]+)</span>",
|
|
334
|
-
row,
|
|
335
|
-
flags=re.IGNORECASE | re.DOTALL,
|
|
336
|
-
)
|
|
337
|
-
if date_match:
|
|
338
|
-
current_date = _parse_fred_date(_clean_html(date_match.group("date")))
|
|
339
|
-
continue
|
|
340
|
-
|
|
341
|
-
cells = re.findall(r"<td[^>]*>(.*?)</td>", row, flags=re.IGNORECASE | re.DOTALL)
|
|
342
|
-
if current_date is None or len(cells) < 2:
|
|
343
|
-
continue
|
|
344
|
-
release_name = _clean_html(cells[1])
|
|
345
|
-
if not release_name:
|
|
346
|
-
continue
|
|
347
|
-
event_time = _parse_fred_time(current_date, _clean_html(cells[0]))
|
|
348
|
-
events.append(
|
|
349
|
-
EconomicEvent(
|
|
350
|
-
event=release_name,
|
|
351
|
-
country="US",
|
|
352
|
-
impact=_fred_release_impact(release_name),
|
|
353
|
-
time=event_time,
|
|
354
|
-
unit="fred release calendar",
|
|
355
|
-
)
|
|
356
|
-
)
|
|
357
|
-
return events
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def _parse_trading_economics_event(item: dict[str, Any]) -> EconomicEvent:
|
|
361
|
-
return EconomicEvent(
|
|
362
|
-
event=str(item.get("Event") or item.get("event") or item.get("Name") or "Untitled event"),
|
|
363
|
-
country=_normalize_country(item.get("Country") or item.get("country")),
|
|
364
|
-
impact=_normalize_importance(item.get("Importance") if "Importance" in item else item.get("importance")),
|
|
365
|
-
time=_parse_time(item.get("Date") or item.get("date") or item.get("Time") or item.get("time")),
|
|
366
|
-
actual=_optional_text(item.get("Actual") if "Actual" in item else item.get("actual")),
|
|
367
|
-
estimate=_optional_text(item.get("Forecast") if "Forecast" in item else item.get("forecast")),
|
|
368
|
-
previous=_optional_text(item.get("Previous") if "Previous" in item else item.get("previous")),
|
|
369
|
-
unit="public calendar",
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def _normalize_country(value: object) -> str:
|
|
374
|
-
text = str(value or "N/A").strip()
|
|
375
|
-
mapping = {
|
|
376
|
-
"united states": "US",
|
|
377
|
-
"united kingdom": "GB",
|
|
378
|
-
"euro area": "EU",
|
|
379
|
-
"eurozone": "EU",
|
|
380
|
-
"japan": "JP",
|
|
381
|
-
"china": "CN",
|
|
382
|
-
"canada": "CA",
|
|
383
|
-
"australia": "AU",
|
|
384
|
-
"new zealand": "NZ",
|
|
385
|
-
"germany": "DE",
|
|
386
|
-
"france": "FR",
|
|
387
|
-
"switzerland": "CH",
|
|
388
|
-
}
|
|
389
|
-
return mapping.get(text.lower(), text)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
def _normalize_importance(value: object) -> str:
|
|
393
|
-
text = str(value or "").strip().lower()
|
|
394
|
-
if text in {"3", "high", "red"}:
|
|
395
|
-
return "high"
|
|
396
|
-
if text in {"2", "medium", "orange"}:
|
|
397
|
-
return "medium"
|
|
398
|
-
if text in {"1", "low", "yellow", "gray", "grey"}:
|
|
399
|
-
return "low"
|
|
400
|
-
return text or "N/A"
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def _normalize_impact(value: object) -> str:
|
|
404
|
-
normalized = str(value or "N/A").strip().lower()
|
|
405
|
-
return {
|
|
406
|
-
"red": "high",
|
|
407
|
-
"orange": "medium",
|
|
408
|
-
"yellow": "low",
|
|
409
|
-
"gray": "low",
|
|
410
|
-
"grey": "low",
|
|
411
|
-
}.get(normalized, normalized)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def _parse_time(value: object) -> datetime | None:
|
|
415
|
-
if not value:
|
|
416
|
-
return None
|
|
417
|
-
if isinstance(value, (int, float)):
|
|
418
|
-
return datetime.fromtimestamp(value)
|
|
419
|
-
text = str(value).replace("Z", "+00:00")
|
|
420
|
-
try:
|
|
421
|
-
return datetime.fromisoformat(text)
|
|
422
|
-
except ValueError:
|
|
423
|
-
return None
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
def _parse_forex_factory_time(date_text: str | None, time_text: str | None) -> datetime | None:
|
|
427
|
-
if not date_text:
|
|
428
|
-
return None
|
|
429
|
-
try:
|
|
430
|
-
parsed_date = datetime.strptime(date_text.strip(), "%m-%d-%Y")
|
|
431
|
-
except ValueError:
|
|
432
|
-
return None
|
|
433
|
-
|
|
434
|
-
normalized_time = (time_text or "").strip().lower().replace(" ", "")
|
|
435
|
-
if not normalized_time or normalized_time in {"allday", "tentative"}:
|
|
436
|
-
return parsed_date
|
|
437
|
-
|
|
438
|
-
try:
|
|
439
|
-
parsed_time = datetime.strptime(normalized_time, "%I:%M%p")
|
|
440
|
-
except ValueError:
|
|
441
|
-
return parsed_date
|
|
442
|
-
return parsed_date.replace(hour=parsed_time.hour, minute=parsed_time.minute)
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
def _parse_fred_date(value: str) -> datetime | None:
|
|
446
|
-
try:
|
|
447
|
-
return datetime.strptime(value.strip(), "%A %B %d, %Y")
|
|
448
|
-
except ValueError:
|
|
449
|
-
return None
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
def _parse_fred_time(base_date: datetime, value: str) -> datetime:
|
|
453
|
-
normalized = value.strip().lower()
|
|
454
|
-
if not normalized or normalized == "n/a":
|
|
455
|
-
return base_date
|
|
456
|
-
try:
|
|
457
|
-
parsed = datetime.strptime(normalized.replace(" ", ""), "%I:%M%p")
|
|
458
|
-
except ValueError:
|
|
459
|
-
return base_date
|
|
460
|
-
return base_date.replace(hour=parsed.hour, minute=parsed.minute)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def _fred_release_impact(name: str) -> str:
|
|
464
|
-
text = name.lower()
|
|
465
|
-
high_keywords = (
|
|
466
|
-
"fomc",
|
|
467
|
-
"consumer price index",
|
|
468
|
-
"producer price index",
|
|
469
|
-
"employment situation",
|
|
470
|
-
"unemployment",
|
|
471
|
-
"payroll",
|
|
472
|
-
"gross domestic product",
|
|
473
|
-
"gdp",
|
|
474
|
-
"personal income and outlays",
|
|
475
|
-
"retail sales",
|
|
476
|
-
"industrial production",
|
|
477
|
-
"federal funds",
|
|
478
|
-
)
|
|
479
|
-
medium_keywords = (
|
|
480
|
-
"housing",
|
|
481
|
-
"manufacturing",
|
|
482
|
-
"trade",
|
|
483
|
-
"consumer sentiment",
|
|
484
|
-
"job openings",
|
|
485
|
-
"claims",
|
|
486
|
-
"treasury",
|
|
487
|
-
)
|
|
488
|
-
if any(keyword in text for keyword in high_keywords):
|
|
489
|
-
return "high"
|
|
490
|
-
if any(keyword in text for keyword in medium_keywords):
|
|
491
|
-
return "medium"
|
|
492
|
-
return "low"
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def _child_text(item: ET.Element, tag: str) -> str | None:
|
|
496
|
-
child = item.find(tag)
|
|
497
|
-
if child is None or child.text is None:
|
|
498
|
-
return None
|
|
499
|
-
text = child.text.strip()
|
|
500
|
-
return text or None
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
def _clean_html(value: str) -> str:
|
|
504
|
-
no_tags = re.sub(r"<[^>]+>", " ", value)
|
|
505
|
-
collapsed = re.sub(r"\s+", " ", html_lib.unescape(no_tags))
|
|
506
|
-
return collapsed.strip()
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
def _optional_text(value: object) -> str | None:
|
|
510
|
-
if value is None or value == "":
|
|
511
|
-
return None
|
|
512
|
-
return str(value)
|
|
1
|
+
"""Economic calendar fetching and fallback formatting data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date, datetime, timedelta
|
|
7
|
+
import html as html_lib
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class EconomicEvent:
|
|
19
|
+
event: str
|
|
20
|
+
country: str
|
|
21
|
+
impact: str
|
|
22
|
+
time: datetime | None
|
|
23
|
+
actual: str | None = None
|
|
24
|
+
estimate: str | None = None
|
|
25
|
+
previous: str | None = None
|
|
26
|
+
unit: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EconomicCalendarService:
|
|
30
|
+
"""Fetch economic calendar events with Finnhub support."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_key: str | None,
|
|
35
|
+
base_url: str = "https://finnhub.io/api/v1",
|
|
36
|
+
client: httpx.AsyncClient | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.api_key = api_key or ""
|
|
39
|
+
self.base_url = base_url.rstrip("/")
|
|
40
|
+
self._client = client
|
|
41
|
+
|
|
42
|
+
async def events(self, start: date, end: date) -> list[EconomicEvent]:
|
|
43
|
+
if not self.api_key:
|
|
44
|
+
raise ProviderError(
|
|
45
|
+
"Economic calendar provider belum dikonfigurasi.",
|
|
46
|
+
"Isi FINNHUB_API_KEY di .env untuk mengambil economic calendar aktual.",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
close_client = self._client is None
|
|
50
|
+
client = self._client or httpx.AsyncClient(timeout=30)
|
|
51
|
+
try:
|
|
52
|
+
response = await client.get(
|
|
53
|
+
f"{self.base_url}/calendar/economic",
|
|
54
|
+
params={"from": start.isoformat(), "to": end.isoformat(), "token": self.api_key},
|
|
55
|
+
)
|
|
56
|
+
if response.status_code == 429:
|
|
57
|
+
raise RateLimitError("Finnhub economic calendar terkena rate limit.")
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
payload = response.json()
|
|
60
|
+
except httpx.TimeoutException as exc:
|
|
61
|
+
raise ProviderError("Finnhub economic calendar timeout.") from exc
|
|
62
|
+
except httpx.HTTPStatusError as exc:
|
|
63
|
+
raise ProviderError(f"Finnhub economic calendar gagal: HTTP {exc.response.status_code}.") from exc
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise ProviderError("Response economic calendar bukan JSON valid.") from exc
|
|
66
|
+
finally:
|
|
67
|
+
if close_client:
|
|
68
|
+
await client.aclose()
|
|
69
|
+
|
|
70
|
+
raw_events = payload.get("economicCalendar") if isinstance(payload, dict) else None
|
|
71
|
+
if not isinstance(raw_events, list):
|
|
72
|
+
raise ProviderError("Response Finnhub economic calendar tidak valid.")
|
|
73
|
+
return [_parse_event(item) for item in raw_events if isinstance(item, dict)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PublicEconomicCalendarService:
|
|
77
|
+
"""Fetch no-key public economic calendar data before static fallback."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
base_url: str = "https://nfs.faireconomy.media",
|
|
82
|
+
client: httpx.AsyncClient | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.base_url = base_url.rstrip("/")
|
|
85
|
+
self._client = client
|
|
86
|
+
|
|
87
|
+
async def events(self, start: date, end: date) -> list[EconomicEvent]:
|
|
88
|
+
close_client = self._client is None
|
|
89
|
+
client = self._client or httpx.AsyncClient(
|
|
90
|
+
timeout=20,
|
|
91
|
+
follow_redirects=True,
|
|
92
|
+
headers={"User-Agent": "FinCLI/0.4.0 economic-calendar"},
|
|
93
|
+
)
|
|
94
|
+
errors: list[str] = []
|
|
95
|
+
try:
|
|
96
|
+
for source_name, fetcher in (
|
|
97
|
+
("forexfactory_json", self._fetch_forex_factory_json),
|
|
98
|
+
("forexfactory_xml", self._fetch_forex_factory_xml),
|
|
99
|
+
("fred_release_calendar", self._fetch_fred_release_calendar),
|
|
100
|
+
("tradingeconomics_guest", self._fetch_trading_economics_guest),
|
|
101
|
+
):
|
|
102
|
+
try:
|
|
103
|
+
events = await fetcher(client, start, end)
|
|
104
|
+
if events:
|
|
105
|
+
return events
|
|
106
|
+
errors.append(f"{source_name}: empty")
|
|
107
|
+
except ProviderError as exc:
|
|
108
|
+
errors.append(f"{source_name}: {exc}")
|
|
109
|
+
finally:
|
|
110
|
+
if close_client:
|
|
111
|
+
await client.aclose()
|
|
112
|
+
raise ProviderError("Semua public economic calendar fallback gagal: " + "; ".join(errors))
|
|
113
|
+
|
|
114
|
+
async def _fetch_forex_factory_json(self, client: httpx.AsyncClient, start: date, end: date) -> list[EconomicEvent]:
|
|
115
|
+
response = await _calendar_get(client, f"{self.base_url}/ff_calendar_thisweek.json", "ForexFactory public calendar")
|
|
116
|
+
payload = _calendar_json(response, "ForexFactory public calendar")
|
|
117
|
+
if not isinstance(payload, list):
|
|
118
|
+
raise ProviderError("Response ForexFactory calendar tidak valid.")
|
|
119
|
+
events = [_parse_public_event(item) for item in payload if isinstance(item, dict)]
|
|
120
|
+
return [event for event in events if event.time is None or start <= event.time.date() <= end]
|
|
121
|
+
|
|
122
|
+
async def _fetch_forex_factory_xml(self, client: httpx.AsyncClient, start: date, end: date) -> list[EconomicEvent]:
|
|
123
|
+
response = await _calendar_get(client, f"{self.base_url}/ff_calendar_thisweek.xml", "ForexFactory XML calendar")
|
|
124
|
+
events = _parse_forex_factory_xml(response.text)
|
|
125
|
+
return [event for event in events if event.time is None or start <= event.time.date() <= end]
|
|
126
|
+
|
|
127
|
+
async def _fetch_fred_release_calendar(
|
|
128
|
+
self, client: httpx.AsyncClient, start: date, end: date
|
|
129
|
+
) -> list[EconomicEvent]:
|
|
130
|
+
response = await _calendar_get(
|
|
131
|
+
client,
|
|
132
|
+
"https://fred.stlouisfed.org/releases/calendar",
|
|
133
|
+
"FRED release calendar",
|
|
134
|
+
params={"ob": "rd", "od": "asc", "vs": start.isoformat(), "ve": end.isoformat()},
|
|
135
|
+
)
|
|
136
|
+
events = _parse_fred_calendar_html(response.text)
|
|
137
|
+
return [event for event in events if event.time is None or start <= event.time.date() <= end]
|
|
138
|
+
|
|
139
|
+
async def _fetch_trading_economics_guest(
|
|
140
|
+
self, client: httpx.AsyncClient, start: date, end: date
|
|
141
|
+
) -> list[EconomicEvent]:
|
|
142
|
+
response = await _calendar_get(
|
|
143
|
+
client,
|
|
144
|
+
"https://api.tradingeconomics.com/calendar",
|
|
145
|
+
"Trading Economics guest calendar",
|
|
146
|
+
params={"c": "guest:guest", "f": "json"},
|
|
147
|
+
)
|
|
148
|
+
payload = _calendar_json(response, "Trading Economics guest calendar")
|
|
149
|
+
if not isinstance(payload, list):
|
|
150
|
+
raise ProviderError("Response Trading Economics calendar tidak valid.")
|
|
151
|
+
events = [_parse_trading_economics_event(item) for item in payload if isinstance(item, dict)]
|
|
152
|
+
return [event for event in events if event.time is None or start <= event.time.date() <= end]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def _calendar_get(
|
|
156
|
+
client: httpx.AsyncClient, url: str, label: str, params: dict[str, str] | None = None
|
|
157
|
+
) -> httpx.Response:
|
|
158
|
+
try:
|
|
159
|
+
response = await client.get(url, params=params)
|
|
160
|
+
response.raise_for_status()
|
|
161
|
+
return response
|
|
162
|
+
except httpx.TimeoutException as exc:
|
|
163
|
+
raise ProviderError(f"{label} timeout.") from exc
|
|
164
|
+
except httpx.HTTPStatusError as exc:
|
|
165
|
+
raise ProviderError(f"{label} gagal: HTTP {exc.response.status_code}.") from exc
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _calendar_json(response: httpx.Response, label: str) -> Any:
|
|
169
|
+
try:
|
|
170
|
+
return response.json()
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
raise ProviderError(f"Response {label} bukan JSON valid.") from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def default_calendar_window(mode: str | None = None) -> tuple[date, date]:
|
|
176
|
+
today = date.today()
|
|
177
|
+
if mode == "today":
|
|
178
|
+
return today, today
|
|
179
|
+
if mode == "week":
|
|
180
|
+
return today, today + timedelta(days=7)
|
|
181
|
+
return today, today + timedelta(days=7)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def fallback_events(start: date, end: date) -> list[EconomicEvent]:
|
|
185
|
+
"""Return non-date-specific event categories when no provider is configured."""
|
|
186
|
+
|
|
187
|
+
return [
|
|
188
|
+
EconomicEvent("Central bank rate decisions", "Global", "high", None, unit="event group"),
|
|
189
|
+
EconomicEvent("Inflation releases: CPI/PCE", "Global", "high", None, unit="event group"),
|
|
190
|
+
EconomicEvent("Labor market data: payrolls/unemployment", "Global", "high", None, unit="event group"),
|
|
191
|
+
EconomicEvent("GDP, PMI, retail sales, consumer sentiment", "Global", "medium", None, unit="event group"),
|
|
192
|
+
EconomicEvent(
|
|
193
|
+
f"Provider window requested: {start.isoformat()} to {end.isoformat()}",
|
|
194
|
+
"FinCLI",
|
|
195
|
+
"info",
|
|
196
|
+
None,
|
|
197
|
+
unit="fallback",
|
|
198
|
+
),
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def filter_events(events: list[EconomicEvent], country: str | None = None, impact: str | None = None) -> list[EconomicEvent]:
|
|
203
|
+
filtered = events
|
|
204
|
+
if country:
|
|
205
|
+
normalized_country = country.lower()
|
|
206
|
+
filtered = [
|
|
207
|
+
event
|
|
208
|
+
for event in filtered
|
|
209
|
+
if event.country.lower() == normalized_country or event.country.lower() in {"global", "fincli"}
|
|
210
|
+
]
|
|
211
|
+
if impact:
|
|
212
|
+
normalized_impact = impact.lower()
|
|
213
|
+
filtered = [event for event in filtered if event.impact.lower() == normalized_impact]
|
|
214
|
+
return filtered
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def calendar_summary(events: list[EconomicEvent]) -> dict[str, int]:
|
|
218
|
+
summary = {"total": len(events), "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
219
|
+
for event in events:
|
|
220
|
+
key = event.impact.lower()
|
|
221
|
+
summary[key] = summary.get(key, 0) + 1
|
|
222
|
+
return summary
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def economic_event_rows(events: list[EconomicEvent]) -> list[dict[str, object]]:
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
"time": event.time.isoformat() if event.time else "",
|
|
229
|
+
"country": event.country,
|
|
230
|
+
"impact": event.impact,
|
|
231
|
+
"event": event.event,
|
|
232
|
+
"actual": calendar_actual_value(event),
|
|
233
|
+
"estimate": calendar_estimate_value(event),
|
|
234
|
+
"previous": calendar_previous_value(event),
|
|
235
|
+
"unit": event.unit or "",
|
|
236
|
+
}
|
|
237
|
+
for event in events
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def calendar_actual_value(event: EconomicEvent) -> str:
|
|
242
|
+
if event.actual:
|
|
243
|
+
return event.actual
|
|
244
|
+
if event.unit == "event group":
|
|
245
|
+
return "category"
|
|
246
|
+
if event.unit == "fallback":
|
|
247
|
+
return "window"
|
|
248
|
+
if event.time and event.time > datetime.now(event.time.tzinfo):
|
|
249
|
+
return "pending"
|
|
250
|
+
return "not supplied"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def calendar_estimate_value(event: EconomicEvent) -> str:
|
|
254
|
+
if event.estimate:
|
|
255
|
+
return event.estimate
|
|
256
|
+
if event.unit == "event group":
|
|
257
|
+
return "monitor"
|
|
258
|
+
if event.unit == "fallback":
|
|
259
|
+
return "provider unavailable"
|
|
260
|
+
return "not supplied by source"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def calendar_previous_value(event: EconomicEvent) -> str:
|
|
264
|
+
if event.previous:
|
|
265
|
+
return event.previous
|
|
266
|
+
if event.unit == "event group":
|
|
267
|
+
return "verify"
|
|
268
|
+
if event.unit == "fallback":
|
|
269
|
+
return "check provider"
|
|
270
|
+
return event.unit or "not supplied by source"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_event(item: dict[str, Any]) -> EconomicEvent:
|
|
274
|
+
return EconomicEvent(
|
|
275
|
+
event=str(item.get("event") or item.get("name") or "Untitled event"),
|
|
276
|
+
country=str(item.get("country") or "N/A"),
|
|
277
|
+
impact=str(item.get("impact") or "N/A").lower(),
|
|
278
|
+
time=_parse_time(item.get("time")),
|
|
279
|
+
actual=_optional_text(item.get("actual")),
|
|
280
|
+
estimate=_optional_text(item.get("forecast") if "forecast" in item else item.get("estimate")),
|
|
281
|
+
previous=_optional_text(item.get("prev") if "prev" in item else item.get("previous")),
|
|
282
|
+
unit=_optional_text(item.get("unit")),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_public_event(item: dict[str, Any]) -> EconomicEvent:
|
|
287
|
+
return EconomicEvent(
|
|
288
|
+
event=str(item.get("title") or item.get("event") or item.get("name") or "Untitled event"),
|
|
289
|
+
country=str(item.get("country") or "N/A"),
|
|
290
|
+
impact=_normalize_impact(item.get("impact")),
|
|
291
|
+
time=_parse_time(item.get("date") or item.get("time")),
|
|
292
|
+
actual=_optional_text(item.get("actual")),
|
|
293
|
+
estimate=_optional_text(item.get("forecast") if "forecast" in item else item.get("estimate")),
|
|
294
|
+
previous=_optional_text(item.get("previous") if "previous" in item else item.get("prev")),
|
|
295
|
+
unit="public calendar",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _parse_forex_factory_xml(payload: str) -> list[EconomicEvent]:
|
|
300
|
+
try:
|
|
301
|
+
root = ET.fromstring(payload)
|
|
302
|
+
except ET.ParseError as exc:
|
|
303
|
+
raise ProviderError("Response ForexFactory XML calendar tidak valid.") from exc
|
|
304
|
+
|
|
305
|
+
events: list[EconomicEvent] = []
|
|
306
|
+
for item in root.findall(".//event"):
|
|
307
|
+
event_time = _parse_forex_factory_time(_child_text(item, "date"), _child_text(item, "time"))
|
|
308
|
+
events.append(
|
|
309
|
+
EconomicEvent(
|
|
310
|
+
event=_child_text(item, "title") or "Untitled event",
|
|
311
|
+
country=_child_text(item, "country") or "N/A",
|
|
312
|
+
impact=_normalize_impact(_child_text(item, "impact")),
|
|
313
|
+
time=event_time,
|
|
314
|
+
actual=_optional_text(_child_text(item, "actual")),
|
|
315
|
+
estimate=_optional_text(_child_text(item, "forecast")),
|
|
316
|
+
previous=_optional_text(_child_text(item, "previous")),
|
|
317
|
+
unit="public calendar",
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
return events
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _parse_fred_calendar_html(payload: str) -> list[EconomicEvent]:
|
|
324
|
+
tbody_match = re.search(r"<tbody>(?P<body>.*?)</tbody>", payload, re.IGNORECASE | re.DOTALL)
|
|
325
|
+
if not tbody_match:
|
|
326
|
+
raise ProviderError("Response FRED release calendar tidak valid.")
|
|
327
|
+
|
|
328
|
+
rows = re.findall(r"<tr[^>]*>(.*?)</tr>", tbody_match.group("body"), flags=re.IGNORECASE | re.DOTALL)
|
|
329
|
+
current_date: datetime | None = None
|
|
330
|
+
events: list[EconomicEvent] = []
|
|
331
|
+
for row in rows:
|
|
332
|
+
date_match = re.search(
|
|
333
|
+
r"<span[^>]*font-weight:\s*bold[^>]*>(?P<date>[^<]+)</span>",
|
|
334
|
+
row,
|
|
335
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
336
|
+
)
|
|
337
|
+
if date_match:
|
|
338
|
+
current_date = _parse_fred_date(_clean_html(date_match.group("date")))
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
cells = re.findall(r"<td[^>]*>(.*?)</td>", row, flags=re.IGNORECASE | re.DOTALL)
|
|
342
|
+
if current_date is None or len(cells) < 2:
|
|
343
|
+
continue
|
|
344
|
+
release_name = _clean_html(cells[1])
|
|
345
|
+
if not release_name:
|
|
346
|
+
continue
|
|
347
|
+
event_time = _parse_fred_time(current_date, _clean_html(cells[0]))
|
|
348
|
+
events.append(
|
|
349
|
+
EconomicEvent(
|
|
350
|
+
event=release_name,
|
|
351
|
+
country="US",
|
|
352
|
+
impact=_fred_release_impact(release_name),
|
|
353
|
+
time=event_time,
|
|
354
|
+
unit="fred release calendar",
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
return events
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _parse_trading_economics_event(item: dict[str, Any]) -> EconomicEvent:
|
|
361
|
+
return EconomicEvent(
|
|
362
|
+
event=str(item.get("Event") or item.get("event") or item.get("Name") or "Untitled event"),
|
|
363
|
+
country=_normalize_country(item.get("Country") or item.get("country")),
|
|
364
|
+
impact=_normalize_importance(item.get("Importance") if "Importance" in item else item.get("importance")),
|
|
365
|
+
time=_parse_time(item.get("Date") or item.get("date") or item.get("Time") or item.get("time")),
|
|
366
|
+
actual=_optional_text(item.get("Actual") if "Actual" in item else item.get("actual")),
|
|
367
|
+
estimate=_optional_text(item.get("Forecast") if "Forecast" in item else item.get("forecast")),
|
|
368
|
+
previous=_optional_text(item.get("Previous") if "Previous" in item else item.get("previous")),
|
|
369
|
+
unit="public calendar",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _normalize_country(value: object) -> str:
|
|
374
|
+
text = str(value or "N/A").strip()
|
|
375
|
+
mapping = {
|
|
376
|
+
"united states": "US",
|
|
377
|
+
"united kingdom": "GB",
|
|
378
|
+
"euro area": "EU",
|
|
379
|
+
"eurozone": "EU",
|
|
380
|
+
"japan": "JP",
|
|
381
|
+
"china": "CN",
|
|
382
|
+
"canada": "CA",
|
|
383
|
+
"australia": "AU",
|
|
384
|
+
"new zealand": "NZ",
|
|
385
|
+
"germany": "DE",
|
|
386
|
+
"france": "FR",
|
|
387
|
+
"switzerland": "CH",
|
|
388
|
+
}
|
|
389
|
+
return mapping.get(text.lower(), text)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _normalize_importance(value: object) -> str:
|
|
393
|
+
text = str(value or "").strip().lower()
|
|
394
|
+
if text in {"3", "high", "red"}:
|
|
395
|
+
return "high"
|
|
396
|
+
if text in {"2", "medium", "orange"}:
|
|
397
|
+
return "medium"
|
|
398
|
+
if text in {"1", "low", "yellow", "gray", "grey"}:
|
|
399
|
+
return "low"
|
|
400
|
+
return text or "N/A"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _normalize_impact(value: object) -> str:
|
|
404
|
+
normalized = str(value or "N/A").strip().lower()
|
|
405
|
+
return {
|
|
406
|
+
"red": "high",
|
|
407
|
+
"orange": "medium",
|
|
408
|
+
"yellow": "low",
|
|
409
|
+
"gray": "low",
|
|
410
|
+
"grey": "low",
|
|
411
|
+
}.get(normalized, normalized)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _parse_time(value: object) -> datetime | None:
|
|
415
|
+
if not value:
|
|
416
|
+
return None
|
|
417
|
+
if isinstance(value, (int, float)):
|
|
418
|
+
return datetime.fromtimestamp(value)
|
|
419
|
+
text = str(value).replace("Z", "+00:00")
|
|
420
|
+
try:
|
|
421
|
+
return datetime.fromisoformat(text)
|
|
422
|
+
except ValueError:
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _parse_forex_factory_time(date_text: str | None, time_text: str | None) -> datetime | None:
|
|
427
|
+
if not date_text:
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
parsed_date = datetime.strptime(date_text.strip(), "%m-%d-%Y")
|
|
431
|
+
except ValueError:
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
normalized_time = (time_text or "").strip().lower().replace(" ", "")
|
|
435
|
+
if not normalized_time or normalized_time in {"allday", "tentative"}:
|
|
436
|
+
return parsed_date
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
parsed_time = datetime.strptime(normalized_time, "%I:%M%p")
|
|
440
|
+
except ValueError:
|
|
441
|
+
return parsed_date
|
|
442
|
+
return parsed_date.replace(hour=parsed_time.hour, minute=parsed_time.minute)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _parse_fred_date(value: str) -> datetime | None:
|
|
446
|
+
try:
|
|
447
|
+
return datetime.strptime(value.strip(), "%A %B %d, %Y")
|
|
448
|
+
except ValueError:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _parse_fred_time(base_date: datetime, value: str) -> datetime:
|
|
453
|
+
normalized = value.strip().lower()
|
|
454
|
+
if not normalized or normalized == "n/a":
|
|
455
|
+
return base_date
|
|
456
|
+
try:
|
|
457
|
+
parsed = datetime.strptime(normalized.replace(" ", ""), "%I:%M%p")
|
|
458
|
+
except ValueError:
|
|
459
|
+
return base_date
|
|
460
|
+
return base_date.replace(hour=parsed.hour, minute=parsed.minute)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _fred_release_impact(name: str) -> str:
|
|
464
|
+
text = name.lower()
|
|
465
|
+
high_keywords = (
|
|
466
|
+
"fomc",
|
|
467
|
+
"consumer price index",
|
|
468
|
+
"producer price index",
|
|
469
|
+
"employment situation",
|
|
470
|
+
"unemployment",
|
|
471
|
+
"payroll",
|
|
472
|
+
"gross domestic product",
|
|
473
|
+
"gdp",
|
|
474
|
+
"personal income and outlays",
|
|
475
|
+
"retail sales",
|
|
476
|
+
"industrial production",
|
|
477
|
+
"federal funds",
|
|
478
|
+
)
|
|
479
|
+
medium_keywords = (
|
|
480
|
+
"housing",
|
|
481
|
+
"manufacturing",
|
|
482
|
+
"trade",
|
|
483
|
+
"consumer sentiment",
|
|
484
|
+
"job openings",
|
|
485
|
+
"claims",
|
|
486
|
+
"treasury",
|
|
487
|
+
)
|
|
488
|
+
if any(keyword in text for keyword in high_keywords):
|
|
489
|
+
return "high"
|
|
490
|
+
if any(keyword in text for keyword in medium_keywords):
|
|
491
|
+
return "medium"
|
|
492
|
+
return "low"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _child_text(item: ET.Element, tag: str) -> str | None:
|
|
496
|
+
child = item.find(tag)
|
|
497
|
+
if child is None or child.text is None:
|
|
498
|
+
return None
|
|
499
|
+
text = child.text.strip()
|
|
500
|
+
return text or None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _clean_html(value: str) -> str:
|
|
504
|
+
no_tags = re.sub(r"<[^>]+>", " ", value)
|
|
505
|
+
collapsed = re.sub(r"\s+", " ", html_lib.unescape(no_tags))
|
|
506
|
+
return collapsed.strip()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _optional_text(value: object) -> str | None:
|
|
510
|
+
if value is None or value == "":
|
|
511
|
+
return None
|
|
512
|
+
return str(value)
|