@drico2008/fincli 0.1.9 → 0.3.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/LICENSE +21 -0
- package/README.md +124 -625
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +26 -14
- package/fincli/app/analysis/analyzer.py +107 -96
- package/fincli/app/analysis/assistant_context.py +187 -186
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +108 -81
- package/fincli/app/cli/router.py +2327 -1237
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/portfolio_risk.py +305 -0
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/transactions.py +84 -84
- package/fincli/app/modules/user_profile.py +84 -0
- package/fincli/app/plugins/loader.py +72 -0
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/market/alphavantage_provider.py +194 -0
- package/fincli/app/providers/market/base.py +98 -77
- package/fincli/app/providers/market/custom_provider.py +186 -169
- package/fincli/app/providers/market/manager.py +84 -1
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/reliability.py +86 -0
- package/fincli/app/research/__init__.py +8 -0
- package/fincli/app/research/engine.py +137 -0
- package/fincli/app/research/exporter.py +91 -0
- package/fincli/app/research/formatter.py +27 -0
- package/fincli/app/research/models.py +24 -0
- package/fincli/app/research/prompt_builder.py +54 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +274 -169
- package/fincli/app/services/market_overview.py +42 -1
- package/fincli/app/services/news_aggregator.py +95 -0
- package/fincli/app/services/web_research.py +267 -267
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +209 -99
- package/fincli/app/storage/provider_metrics.py +61 -0
- package/fincli/app/storage/secrets.py +26 -2
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +269 -258
- package/fincli/app/tui/market_provider_selector.py +3 -1
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +123 -60
- package/package.json +22 -20
- package/pyproject.toml +35 -35
|
@@ -4,7 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from datetime import date, datetime, timedelta
|
|
7
|
+
import html as html_lib
|
|
8
|
+
import re
|
|
7
9
|
from typing import Any
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
8
11
|
|
|
9
12
|
import httpx
|
|
10
13
|
|
|
@@ -70,6 +73,105 @@ class EconomicCalendarService:
|
|
|
70
73
|
return [_parse_event(item) for item in raw_events if isinstance(item, dict)]
|
|
71
74
|
|
|
72
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.3.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
|
+
|
|
73
175
|
def default_calendar_window(mode: str | None = None) -> tuple[date, date]:
|
|
74
176
|
today = date.today()
|
|
75
177
|
if mode == "today":
|
|
@@ -101,13 +203,73 @@ def filter_events(events: list[EconomicEvent], country: str | None = None, impac
|
|
|
101
203
|
filtered = events
|
|
102
204
|
if country:
|
|
103
205
|
normalized_country = country.lower()
|
|
104
|
-
filtered = [
|
|
206
|
+
filtered = [
|
|
207
|
+
event
|
|
208
|
+
for event in filtered
|
|
209
|
+
if event.country.lower() == normalized_country or event.country.lower() in {"global", "fincli"}
|
|
210
|
+
]
|
|
105
211
|
if impact:
|
|
106
212
|
normalized_impact = impact.lower()
|
|
107
213
|
filtered = [event for event in filtered if event.impact.lower() == normalized_impact]
|
|
108
214
|
return filtered
|
|
109
215
|
|
|
110
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
|
+
|
|
111
273
|
def _parse_event(item: dict[str, Any]) -> EconomicEvent:
|
|
112
274
|
return EconomicEvent(
|
|
113
275
|
event=str(item.get("event") or item.get("name") or "Untitled event"),
|
|
@@ -121,6 +283,134 @@ def _parse_event(item: dict[str, Any]) -> EconomicEvent:
|
|
|
121
283
|
)
|
|
122
284
|
|
|
123
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
|
+
|
|
124
414
|
def _parse_time(value: object) -> datetime | None:
|
|
125
415
|
if not value:
|
|
126
416
|
return None
|
|
@@ -133,6 +423,89 @@ def _parse_time(value: object) -> datetime | None:
|
|
|
133
423
|
return None
|
|
134
424
|
|
|
135
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
|
+
|
|
136
509
|
def _optional_text(value: object) -> str | None:
|
|
137
510
|
if value is None or value == "":
|
|
138
511
|
return None
|