@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -625
  3. package/fincli/__init__.py +3 -3
  4. package/fincli/app/agents/__init__.py +5 -0
  5. package/fincli/app/agents/registry.py +76 -0
  6. package/fincli/app/analysis/ai_prompts.py +26 -14
  7. package/fincli/app/analysis/analyzer.py +107 -96
  8. package/fincli/app/analysis/assistant_context.py +187 -186
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/multi_timeframe.py +180 -0
  12. package/fincli/app/analysis/trading_methods.py +144 -0
  13. package/fincli/app/cli/commands.py +108 -81
  14. package/fincli/app/cli/router.py +2327 -1237
  15. package/fincli/app/connectors/__init__.py +5 -0
  16. package/fincli/app/connectors/catalog.py +148 -0
  17. package/fincli/app/connectors/news_connectors.py +412 -0
  18. package/fincli/app/modules/alerts.py +80 -0
  19. package/fincli/app/modules/economic_calendar.py +374 -1
  20. package/fincli/app/modules/portfolio_risk.py +305 -0
  21. package/fincli/app/modules/reports.py +151 -0
  22. package/fincli/app/modules/scanner.py +111 -93
  23. package/fincli/app/modules/transactions.py +84 -84
  24. package/fincli/app/modules/user_profile.py +84 -0
  25. package/fincli/app/plugins/loader.py +72 -0
  26. package/fincli/app/providers/ai/manager.py +60 -60
  27. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  28. package/fincli/app/providers/market/base.py +98 -77
  29. package/fincli/app/providers/market/custom_provider.py +186 -169
  30. package/fincli/app/providers/market/manager.py +84 -1
  31. package/fincli/app/providers/market/symbols.py +143 -0
  32. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  33. package/fincli/app/providers/reliability.py +86 -0
  34. package/fincli/app/research/__init__.py +8 -0
  35. package/fincli/app/research/engine.py +137 -0
  36. package/fincli/app/research/exporter.py +91 -0
  37. package/fincli/app/research/formatter.py +27 -0
  38. package/fincli/app/research/models.py +24 -0
  39. package/fincli/app/research/prompt_builder.py +54 -0
  40. package/fincli/app/services/macro_data.py +50 -0
  41. package/fincli/app/services/market_data.py +274 -169
  42. package/fincli/app/services/market_overview.py +42 -1
  43. package/fincli/app/services/news_aggregator.py +95 -0
  44. package/fincli/app/services/web_research.py +267 -267
  45. package/fincli/app/storage/config.py +122 -88
  46. package/fincli/app/storage/database.py +209 -99
  47. package/fincli/app/storage/provider_metrics.py +61 -0
  48. package/fincli/app/storage/secrets.py +26 -2
  49. package/fincli/app/tui/components.py +68 -50
  50. package/fincli/app/tui/layout.py +269 -258
  51. package/fincli/app/tui/market_provider_selector.py +3 -1
  52. package/fincli/app/tui/theme.py +134 -74
  53. package/fincli/app/utils/formatting.py +123 -60
  54. package/package.json +22 -20
  55. 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 = [event for event in filtered if event.country.lower() == normalized_country]
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