@drico2008/fincli 0.1.9 → 0.2.2

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -718
  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 +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  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 +105 -83
  14. package/fincli/app/cli/router.py +2123 -1294
  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/reports.py +151 -0
  21. package/fincli/app/modules/scanner.py +111 -93
  22. package/fincli/app/modules/transactions.py +84 -84
  23. package/fincli/app/modules/user_profile.py +84 -0
  24. package/fincli/app/plugins/loader.py +72 -0
  25. package/fincli/app/providers/ai/manager.py +60 -60
  26. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  27. package/fincli/app/providers/market/base.py +98 -77
  28. package/fincli/app/providers/market/custom_provider.py +186 -169
  29. package/fincli/app/providers/market/manager.py +84 -1
  30. package/fincli/app/providers/market/symbols.py +143 -0
  31. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  32. package/fincli/app/research/__init__.py +7 -0
  33. package/fincli/app/research/engine.py +75 -0
  34. package/fincli/app/research/formatter.py +22 -0
  35. package/fincli/app/research/models.py +18 -0
  36. package/fincli/app/research/prompt_builder.py +47 -0
  37. package/fincli/app/services/macro_data.py +50 -0
  38. package/fincli/app/services/market_data.py +203 -203
  39. package/fincli/app/services/news_aggregator.py +90 -0
  40. package/fincli/app/services/web_research.py +267 -267
  41. package/fincli/app/storage/config.py +122 -88
  42. package/fincli/app/storage/database.py +200 -101
  43. package/fincli/app/storage/secrets.py +8 -2
  44. package/fincli/app/tui/components.py +68 -50
  45. package/fincli/app/tui/layout.py +269 -258
  46. package/fincli/app/tui/market_provider_selector.py +3 -1
  47. package/fincli/app/tui/theme.py +134 -74
  48. package/fincli/app/utils/formatting.py +123 -60
  49. package/package.json +23 -23
  50. package/pyproject.toml +35 -35
@@ -0,0 +1,5 @@
1
+ """Connector catalog package."""
2
+
3
+ from fincli.app.connectors.catalog import Connector, ConnectorCatalog
4
+
5
+ __all__ = ["Connector", "ConnectorCatalog"]
@@ -0,0 +1,148 @@
1
+ """Connector catalog for FinCLI provider roadmap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Connector:
10
+ name: str
11
+ category: str
12
+ access: str
13
+ coverage: str
14
+
15
+
16
+ class ConnectorCatalog:
17
+ def __init__(self, connectors: tuple[Connector, ...] | None = None) -> None:
18
+ self._connectors = connectors or CONNECTORS
19
+
20
+ def all(self) -> tuple[Connector, ...]:
21
+ return self._connectors
22
+
23
+ def by_category(self, category: str) -> list[Connector]:
24
+ normalized = category.strip().lower()
25
+ return [item for item in self._connectors if item.category == normalized]
26
+
27
+ def find(self, query: str) -> list[Connector]:
28
+ normalized = query.strip().lower()
29
+ if not normalized:
30
+ return list(self._connectors)
31
+ return [
32
+ item
33
+ for item in self._connectors
34
+ if normalized in item.name.lower()
35
+ or normalized in item.category.lower()
36
+ or normalized in item.coverage.lower()
37
+ ]
38
+
39
+
40
+ def _c(name: str, category: str, access: str = "api-key/plan-dependent", coverage: str = "") -> Connector:
41
+ return Connector(name=name, category=category, access=access, coverage=coverage or category)
42
+
43
+
44
+ CONNECTORS: tuple[Connector, ...] = (
45
+ _c("Benzinga News API", "news"),
46
+ _c("Alpha Vantage News & Sentiment", "news", "api-key/free-tier", "market news and sentiment"),
47
+ _c("Marketaux", "news", "api-key/free-tier"),
48
+ _c("APITube", "news"),
49
+ _c("Adanos Market Sentiment", "news"),
50
+ _c("Bloomberg Enterprise News", "news", "enterprise"),
51
+ _c("Reuters News API", "news", "enterprise"),
52
+ _c("Dow Jones Newswires", "news", "enterprise"),
53
+ _c("MT Newswires", "news", "enterprise"),
54
+ _c("Aiera", "news", "enterprise", "earnings call transcripts"),
55
+ _c("Stocktwits API", "news"),
56
+ _c("Reddit API", "news", "oauth/api-key", "community sentiment"),
57
+ _c("X Twitter v2 API", "news"),
58
+ _c("Seeking Alpha API", "news"),
59
+ _c("Financial Times API", "news", "enterprise"),
60
+ _c("CNBC API", "news"),
61
+ _c("MarketWatch API", "news"),
62
+ _c("Polymarket API", "news", "public/api-key", "event markets"),
63
+ _c("NewsAPI.org", "news", "api-key/free-tier"),
64
+ _c("GNews API", "news", "api-key/free-tier"),
65
+ _c("Webhose.io Financial Feed", "news"),
66
+ _c("CityFALCON", "news"),
67
+ _c("TipRanks", "research"),
68
+ _c("Alternative Data Connectors", "alternative"),
69
+ _c("Yahoo Finance News Feed", "news", "fallback/free", "Yahoo Finance news"),
70
+ _c("AnaChart", "research"),
71
+ _c("Daloopa", "research"),
72
+ _c("Morningstar API", "research"),
73
+ _c("S&P Global Capital IQ", "research", "enterprise"),
74
+ _c("FactSet Connect", "research", "enterprise"),
75
+ _c("Moody's Analytics", "research", "enterprise"),
76
+ _c("LSEG Refinitiv Workspace", "research", "enterprise"),
77
+ _c("Zacks Investment Research", "research"),
78
+ _c("Estimize", "research"),
79
+ _c("Briefing.com", "research"),
80
+ _c("Fitch Solutions", "research"),
81
+ _c("TradingView Webhooks", "research", "webhook"),
82
+ _c("Finnhub Stock API", "market", "api-key/free-tier"),
83
+ _c("EDGAR SEC API", "research", "free/public", "SEC filings"),
84
+ _c("OpenCorporates", "research"),
85
+ _c("GuruFocus", "research"),
86
+ _c("InsiderArbitrage", "research"),
87
+ _c("WhaleWisdom", "research", "api-key", "13F tracker"),
88
+ _c("SmartInsider", "research"),
89
+ _c("OpenInsider", "research", "free/public"),
90
+ _c("Polygon.io", "market"),
91
+ _c("Yahoo Finance", "market", "fallback/free", "stocks forex crypto indices commodities ETFs"),
92
+ _c("Alpha Vantage Core APIs", "market", "api-key/free-tier"),
93
+ _c("Barchart OnDemand", "market"),
94
+ _c("IEX Cloud", "market"),
95
+ _c("Algoseek", "market"),
96
+ _c("Twelve Data", "market", "api-key/free-tier"),
97
+ _c("Intrinio", "market"),
98
+ _c("EOD Historical Data", "market"),
99
+ _c("Tradier API", "market"),
100
+ _c("Tiingo", "market"),
101
+ _c("Alpaca Market Data", "market"),
102
+ _c("Interactive Brokers API", "market"),
103
+ _c("Financial Modeling Prep", "market", "api-key/free-tier"),
104
+ _c("MarketStack", "market"),
105
+ _c("Xignite", "market"),
106
+ _c("Nasdaq Data Link", "market"),
107
+ _c("OPRA", "market", "licensed", "options"),
108
+ _c("Livevol Cboe", "market"),
109
+ _c("BarChart Commodities", "market"),
110
+ _c("FRED", "macro", "free/public", "US macro"),
111
+ _c("DBnomics", "macro", "free/public", "global macro"),
112
+ _c("IMF", "macro", "free/public", "global macro"),
113
+ _c("World Bank Open Data", "macro", "free/public", "global macro"),
114
+ _c("OECD Data API", "macro", "free/public"),
115
+ _c("Eurostat API", "macro", "free/public"),
116
+ _c("BEA", "macro", "free/public"),
117
+ _c("BLS", "macro", "free/public"),
118
+ _c("Bank of England API", "macro", "free/public"),
119
+ _c("European Central Bank API", "macro", "free/public"),
120
+ _c("AkShare", "macro", "free/open-source", "China market and macro"),
121
+ _c("BPS API Indonesia", "macro", "public/plan-dependent"),
122
+ _c("Bank Indonesia API SEKI", "macro", "public/plan-dependent"),
123
+ _c("Trading Economics API", "macro"),
124
+ _c("Oanda Forex Labs", "macro"),
125
+ _c("CoinGecko API", "crypto", "free/public"),
126
+ _c("CoinMarketCap API", "crypto"),
127
+ _c("Kraken WebSocket API", "crypto", "free/public"),
128
+ _c("Glassnode API", "crypto"),
129
+ _c("CryptoQuant", "crypto"),
130
+ _c("Messari API", "crypto"),
131
+ _c("DefiLlama API", "crypto", "free/public"),
132
+ _c("The Graph Subgraphs", "crypto"),
133
+ _c("Dune Analytics API", "crypto"),
134
+ _c("Binance API", "crypto", "free/public"),
135
+ _c("CoinAPI", "crypto"),
136
+ _c("Kaiko", "crypto"),
137
+ _c("Nansen API", "crypto"),
138
+ _c("Token Terminal", "crypto"),
139
+ _c("Santiment API", "crypto"),
140
+ _c("MarineTraffic API", "alternative"),
141
+ _c("FlightAware Firehose", "alternative"),
142
+ _c("Google Trends API", "alternative"),
143
+ _c("OpenWeatherMap API", "alternative", "api-key/free-tier"),
144
+ _c("LinkUp API", "alternative"),
145
+ _c("Placer.ai API", "alternative"),
146
+ _c("Ursa Space Satellite Data", "alternative"),
147
+ _c("PatentSight API", "alternative"),
148
+ )
@@ -0,0 +1,412 @@
1
+ """News connector catalog and lightweight fetch adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from email.utils import parsedate_to_datetime
8
+ from html import unescape
9
+ import os
10
+ import re
11
+ from typing import Any
12
+ from urllib.parse import quote_plus
13
+ import xml.etree.ElementTree as ET
14
+
15
+ import httpx
16
+
17
+ from fincli.app.providers.market.base import NewsItem
18
+ from fincli.app.utils.errors import ProviderError
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class NewsConnectorSpec:
23
+ slug: str
24
+ name: str
25
+ access: str
26
+ category: str
27
+ env_key: str = ""
28
+ url_template: str = ""
29
+ notes: str = ""
30
+
31
+
32
+ API_KEY_NEWS_CONNECTORS: tuple[NewsConnectorSpec, ...] = (
33
+ NewsConnectorSpec("marketaux", "Marketaux", "free-tier", "market-news", "MARKETAUX_API_KEY"),
34
+ NewsConnectorSpec("newsapi", "NewsAPI.org", "free-tier", "general-news", "NEWSAPI_API_KEY"),
35
+ NewsConnectorSpec("gnews", "GNews", "free-tier", "general-news", "GNEWS_API_KEY"),
36
+ NewsConnectorSpec("alphavantage_news", "Alpha Vantage News Sentiment", "free-tier", "market-news", "ALPHA_VANTAGE_API_KEY"),
37
+ NewsConnectorSpec("finnhub_news", "Finnhub Company News", "free-tier", "market-news", "FINNHUB_API_KEY"),
38
+ NewsConnectorSpec("stocknewsapi", "StockNewsAPI", "free-tier", "market-news", "STOCKNEWSAPI_API_KEY"),
39
+ NewsConnectorSpec("apitube", "APITube", "free-tier", "market-news", "APITUBE_API_KEY"),
40
+ NewsConnectorSpec("benzinga", "Benzinga News", "api-key", "market-news", "BENZINGA_API_KEY"),
41
+ NewsConnectorSpec("polygon_benzinga", "Polygon Benzinga News", "api-key", "market-news", "POLYGON_API_KEY"),
42
+ NewsConnectorSpec("tiingo_news", "Tiingo News", "free-tier", "market-news", "TIINGO_API_KEY"),
43
+ NewsConnectorSpec("fmp_news", "Financial Modeling Prep News", "free-tier", "market-news", "FMP_API_KEY"),
44
+ NewsConnectorSpec("eodhd_news", "EOD Historical Data News", "free-tier", "market-news", "EODHD_API_KEY"),
45
+ NewsConnectorSpec("iex_news", "IEX Cloud News", "api-key", "market-news", "IEX_CLOUD_API_KEY"),
46
+ NewsConnectorSpec("intrinio_news", "Intrinio News", "api-key", "market-news", "INTRINIO_API_KEY"),
47
+ NewsConnectorSpec("twelvedata_news", "Twelve Data News", "api-key", "market-news", "TWELVE_DATA_API_KEY"),
48
+ NewsConnectorSpec("custom_news", "Custom News API", "custom", "custom", "CUSTOM_NEWS_API_KEY"),
49
+ )
50
+
51
+
52
+ RSS_SOURCES: tuple[tuple[str, str, str, str], ...] = (
53
+ ("google_news_rss", "Google News RSS", "market-news", "https://news.google.com/rss/search?q={query}&hl=en-US&gl=US&ceid=US:en"),
54
+ ("yahoo_finance_rss", "Yahoo Finance RSS", "market-news", "https://feeds.finance.yahoo.com/rss/2.0/headline?s={symbol}&region=US&lang=en-US"),
55
+ ("marketwatch_rss", "MarketWatch RSS", "market-news", "https://feeds.content.dowjones.io/public/rss/mw_topstories"),
56
+ ("cnbc_business_rss", "CNBC Business RSS", "business", "https://www.cnbc.com/id/10001147/device/rss/rss.html"),
57
+ ("cnbc_markets_rss", "CNBC Markets RSS", "market-news", "https://www.cnbc.com/id/100003114/device/rss/rss.html"),
58
+ ("ap_business_rss", "AP Business RSS", "business", "https://apnews.com/hub/business?output=rss"),
59
+ ("guardian_business_rss", "Guardian Business RSS", "business", "https://www.theguardian.com/uk/business/rss"),
60
+ ("bbc_business_rss", "BBC Business RSS", "business", "https://feeds.bbci.co.uk/news/business/rss.xml"),
61
+ ("npr_business_rss", "NPR Business RSS", "business", "https://feeds.npr.org/1006/rss.xml"),
62
+ ("abc_business_rss", "ABC Business RSS", "business", "https://abcnews.go.com/abcnews/businessheadlines"),
63
+ ("investing_com_news_rss", "Investing.com News RSS", "market-news", "https://www.investing.com/rss/news.rss"),
64
+ ("investing_com_stock_rss", "Investing.com Stock Market RSS", "equity", "https://www.investing.com/rss/stock.rss"),
65
+ ("investing_com_forex_rss", "Investing.com Forex RSS", "forex", "https://www.investing.com/rss/forex.rss"),
66
+ ("investing_com_commodities_rss", "Investing.com Commodities RSS", "commodities", "https://www.investing.com/rss/commodities.rss"),
67
+ ("investing_com_economy_rss", "Investing.com Economy RSS", "macro", "https://www.investing.com/rss/economic.rss"),
68
+ ("nasdaq_news_rss", "Nasdaq News RSS", "equity", "https://www.nasdaq.com/feed/rssoutbound?category=Stocks"),
69
+ ("seeking_alpha_market_news_rss", "Seeking Alpha Market News RSS", "market-news", "https://seekingalpha.com/market_currents.xml"),
70
+ ("sec_press_rss", "SEC Press Releases RSS", "regulatory", "https://www.sec.gov/news/pressreleases.rss"),
71
+ ("sec_litigation_rss", "SEC Litigation RSS", "regulatory", "https://www.sec.gov/litigation/litreleases.rss"),
72
+ ("fed_press_rss", "Federal Reserve Press RSS", "macro", "https://www.federalreserve.gov/feeds/press_all.xml"),
73
+ ("fed_speeches_rss", "Federal Reserve Speeches RSS", "macro", "https://www.federalreserve.gov/feeds/speeches.xml"),
74
+ ("ecb_press_rss", "ECB Press RSS", "macro", "https://www.ecb.europa.eu/rss/press.html"),
75
+ ("imf_news_rss", "IMF News RSS", "macro", "https://www.imf.org/en/News/RSS"),
76
+ ("world_bank_news_rss", "World Bank News RSS", "macro", "https://www.worldbank.org/en/news/all?format=rss"),
77
+ ("oecd_news_rss", "OECD News RSS", "macro", "https://www.oecd.org/newsroom/publicationsdocuments/rss.xml"),
78
+ ("bis_news_rss", "BIS News RSS", "macro", "https://www.bis.org/list/press_releases/index.rss"),
79
+ ("boe_news_rss", "Bank of England News RSS", "macro", "https://www.bankofengland.co.uk/rss/news"),
80
+ ("rba_media_rss", "Reserve Bank of Australia RSS", "macro", "https://www.rba.gov.au/rss/rss-cb-media-releases.xml"),
81
+ ("boc_press_rss", "Bank of Canada Press RSS", "macro", "https://www.bankofcanada.ca/press/feed/"),
82
+ ("boj_news_rss", "Bank of Japan News RSS", "macro", "https://www.boj.or.jp/rss/whatsnew_en.xml"),
83
+ ("bis_speeches_rss", "BIS Speeches RSS", "macro", "https://www.bis.org/list/speeches/index.rss"),
84
+ ("treasury_press_rss", "US Treasury Press RSS", "macro", "https://home.treasury.gov/news/press-releases/rss"),
85
+ ("bea_news_rss", "BEA News RSS", "macro", "https://www.bea.gov/news/rss.xml"),
86
+ ("bls_news_rss", "BLS News RSS", "macro", "https://www.bls.gov/feed/news_release.rss"),
87
+ ("census_news_rss", "US Census News RSS", "macro", "https://www.census.gov/newsroom/press-releases.xml"),
88
+ ("eia_news_rss", "EIA News RSS", "energy", "https://www.eia.gov/rss/todayinenergy.xml"),
89
+ ("ft_markets_rss", "Financial Times Markets RSS", "market-news", "https://www.ft.com/markets?format=rss"),
90
+ ("ft_companies_rss", "Financial Times Companies RSS", "equity", "https://www.ft.com/companies?format=rss"),
91
+ ("ft_global_economy_rss", "Financial Times Global Economy RSS", "macro", "https://www.ft.com/global-economy?format=rss"),
92
+ ("fortune_finance_rss", "Fortune Finance RSS", "business", "https://fortune.com/section/finance/feed/"),
93
+ ("fortune_crypto_rss", "Fortune Crypto RSS", "crypto", "https://fortune.com/crypto/feed/"),
94
+ ("forbes_business_rss", "Forbes Business RSS", "business", "https://www.forbes.com/business/feed/"),
95
+ ("forbes_markets_rss", "Forbes Markets RSS", "market-news", "https://www.forbes.com/markets/feed/"),
96
+ ("business_insider_markets_rss", "Business Insider Markets RSS", "market-news", "https://markets.businessinsider.com/rss/news"),
97
+ ("morningstar_news_rss", "Morningstar News RSS", "equity", "https://www.morningstar.com/rss"),
98
+ ("zacks_rss", "Zacks RSS", "equity", "https://www.zacks.com/rss.xml"),
99
+ ("fool_rss", "Motley Fool RSS", "equity", "https://www.fool.com/feeds/index.aspx"),
100
+ ("finviz_news_rss", "Finviz News RSS", "equity", "https://finviz.com/news.ashx"),
101
+ ("benzinga_fintech_rss", "Benzinga Fintech RSS", "equity", "https://www.benzinga.com/feed"),
102
+ ("stocktwits_trending_rss", "Stocktwits Trending RSS", "sentiment", "https://stocktwits.com/symbol/{symbol}.rss"),
103
+ ("cryptopanic_rss", "CryptoPanic RSS", "crypto", "https://cryptopanic.com/news/rss/"),
104
+ ("coindesk_rss", "CoinDesk RSS", "crypto", "https://www.coindesk.com/arc/outboundfeeds/rss/"),
105
+ ("cointelegraph_rss", "Cointelegraph RSS", "crypto", "https://cointelegraph.com/rss"),
106
+ ("decrypt_rss", "Decrypt RSS", "crypto", "https://decrypt.co/feed"),
107
+ ("theblock_rss", "The Block RSS", "crypto", "https://www.theblock.co/rss.xml"),
108
+ ("bitcoin_magazine_rss", "Bitcoin Magazine RSS", "crypto", "https://bitcoinmagazine.com/.rss/full/"),
109
+ ("fxstreet_news_rss", "FXStreet News RSS", "forex", "https://www.fxstreet.com/rss/news"),
110
+ ("forexlive_rss", "Forexlive RSS", "forex", "https://www.forexlive.com/feed/news"),
111
+ ("dailyfx_rss", "DailyFX RSS", "forex", "https://www.dailyfx.com/feeds/all"),
112
+ ("kitco_news_rss", "Kitco News RSS", "commodities", "https://www.kitco.com/rss/news"),
113
+ ("oilprice_rss", "OilPrice RSS", "commodities", "https://oilprice.com/rss/main"),
114
+ ("mining_com_rss", "Mining.com RSS", "commodities", "https://www.mining.com/feed/"),
115
+ ("agweb_rss", "AgWeb RSS", "commodities", "https://www.agweb.com/rss.xml"),
116
+ ("spglobal_commodity_rss", "S&P Global Commodity Insights RSS", "commodities", "https://www.spglobal.com/commodityinsights/en/rss-feed"),
117
+ ("nikkei_asia_rss", "Nikkei Asia RSS", "asia", "https://asia.nikkei.com/rss/feed/nar"),
118
+ ("scmp_business_rss", "SCMP Business RSS", "asia", "https://www.scmp.com/rss/92/feed"),
119
+ ("japantimes_business_rss", "Japan Times Business RSS", "asia", "https://www.japantimes.co.jp/business/feed/"),
120
+ ("straits_times_business_rss", "Straits Times Business RSS", "asia", "https://www.straitstimes.com/news/business/rss.xml"),
121
+ ("the_edge_markets_rss", "The Edge Markets RSS", "asia", "https://theedgemalaysia.com/rss"),
122
+ ("jakarta_post_business_rss", "Jakarta Post Business RSS", "indonesia", "https://www.thejakartapost.com/feeds/business.xml"),
123
+ ("cna_business_rss", "CNA Business RSS", "asia", "https://www.channelnewsasia.com/api/v1/rss-outbound-feed?_format=xml&category=6936"),
124
+ ("korea_herald_business_rss", "Korea Herald Business RSS", "asia", "https://www.koreaherald.com/rss/020000000000.xml"),
125
+ ("taipei_times_business_rss", "Taipei Times Business RSS", "asia", "https://www.taipeitimes.com/xml/index.rss"),
126
+ ("hindustan_business_rss", "Hindustan Times Business RSS", "india", "https://www.hindustantimes.com/feeds/rss/business/rssfeed.xml"),
127
+ ("economic_times_markets_rss", "Economic Times Markets RSS", "india", "https://economictimes.indiatimes.com/markets/rssfeeds/1977021501.cms"),
128
+ ("moneycontrol_rss", "Moneycontrol RSS", "india", "https://www.moneycontrol.com/rss/latestnews.xml"),
129
+ ("livemint_markets_rss", "Livemint Markets RSS", "india", "https://www.livemint.com/rss/markets"),
130
+ ("euronews_business_rss", "Euronews Business RSS", "europe", "https://www.euronews.com/rss?level=theme&name=business"),
131
+ ("dw_business_rss", "DW Business RSS", "europe", "https://rss.dw.com/xml/rss-en-bus"),
132
+ ("le_monde_economy_rss", "Le Monde Economy RSS", "europe", "https://www.lemonde.fr/en/economy/rss_full.xml"),
133
+ ("el_pais_economy_rss", "El Pais Economy RSS", "europe", "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/economia/portada"),
134
+ ("reuters_business_rss", "Reuters Business RSS", "business", "https://www.reutersagency.com/feed/?best-topics=business-finance&post_type=best"),
135
+ ("reuters_markets_rss", "Reuters Markets RSS", "market-news", "https://www.reutersagency.com/feed/?best-topics=markets&post_type=best"),
136
+ ("aljazeera_economy_rss", "Al Jazeera Economy RSS", "global", "https://www.aljazeera.com/xml/rss/all.xml"),
137
+ ("africa_business_rss", "Africa Business RSS", "global", "https://www.africabusiness.com/feed/"),
138
+ ("bloomberg_market_rss", "Bloomberg Markets RSS", "market-news", "https://feeds.bloomberg.com/markets/news.rss"),
139
+ ("bloomberg_economics_rss", "Bloomberg Economics RSS", "macro", "https://feeds.bloomberg.com/economics/news.rss"),
140
+ ("bloomberg_technology_rss", "Bloomberg Technology RSS", "tech", "https://feeds.bloomberg.com/technology/news.rss"),
141
+ ("wsj_markets_rss", "Wall Street Journal Markets RSS", "market-news", "https://feeds.a.dj.com/rss/RSSMarketsMain.xml"),
142
+ ("wsj_world_news_rss", "Wall Street Journal World RSS", "global", "https://feeds.a.dj.com/rss/RSSWorldNews.xml"),
143
+ ("wsj_business_rss", "Wall Street Journal Business RSS", "business", "https://feeds.a.dj.com/rss/WSJcomUSBusiness.xml"),
144
+ ("barrons_rss", "Barron's RSS", "market-news", "https://www.barrons.com/xml/rss/3_7510.xml"),
145
+ ("thestreet_rss", "TheStreet RSS", "market-news", "https://www.thestreet.com/.rss/full/"),
146
+ ("marketbeat_rss", "MarketBeat RSS", "equity", "https://www.marketbeat.com/feed/"),
147
+ ("prnewswire_financial_rss", "PR Newswire Financial RSS", "corporate", "https://www.prnewswire.com/rss/news-releases-list.rss"),
148
+ ("globenewswire_rss", "GlobeNewswire RSS", "corporate", "https://www.globenewswire.com/RssFeed/subjectcode/27-Financial%20Services/feedTitle/GlobeNewswire%20-%20Financial%20Services"),
149
+ ("businesswire_financial_rss", "Business Wire RSS", "corporate", "https://www.businesswire.com/portal/site/home/template.PAGE/news/rss/?javax.portlet.tpst=08c2aa13f2fe3d4dc1b6751ae1de75dd&javax.portlet.rst_08c2aa13f2fe3d4dc1b6751ae1de75dd_feedName=Financial"),
150
+ ("accesswire_financial_rss", "ACCESSWIRE RSS", "corporate", "https://www.accesswire.com/rss/financial-services"),
151
+ ("openpr_finance_rss", "OpenPR Finance RSS", "corporate", "https://www.openpr.com/rss/finance-banking-insurance.xml"),
152
+ ("etf_trends_rss", "ETF Trends RSS", "etf", "https://www.etftrends.com/feed/"),
153
+ ("etfdb_rss", "ETF Database RSS", "etf", "https://etfdb.com/feed/"),
154
+ ("spglobal_market_intel_rss", "S&P Global Market Intelligence RSS", "market-news", "https://www.spglobal.com/marketintelligence/en/rss-feed"),
155
+ )
156
+
157
+
158
+ NEWS_CONNECTORS: tuple[NewsConnectorSpec, ...] = (
159
+ *API_KEY_NEWS_CONNECTORS,
160
+ *(
161
+ NewsConnectorSpec(slug, name, "public-rss", category, url_template=url)
162
+ for slug, name, category, url in RSS_SOURCES
163
+ ),
164
+ )
165
+
166
+
167
+ NEWS_CONNECTOR_SECRET_KEYS = {spec.slug: spec.env_key for spec in API_KEY_NEWS_CONNECTORS if spec.env_key}
168
+
169
+
170
+ class NewsConnectorCatalog:
171
+ """Searchable registry of news connectors."""
172
+
173
+ def __init__(self, connectors: tuple[NewsConnectorSpec, ...] = NEWS_CONNECTORS) -> None:
174
+ self._connectors = connectors
175
+
176
+ def all(self) -> list[NewsConnectorSpec]:
177
+ return list(self._connectors)
178
+
179
+ def get(self, slug: str) -> NewsConnectorSpec | None:
180
+ normalized = slug.strip().lower()
181
+ return next((connector for connector in self._connectors if connector.slug == normalized), None)
182
+
183
+ def search(self, query: str) -> list[NewsConnectorSpec]:
184
+ normalized = query.strip().lower()
185
+ if not normalized:
186
+ return self.all()
187
+ return [
188
+ connector
189
+ for connector in self._connectors
190
+ if normalized in connector.slug or normalized in connector.name.lower() or normalized in connector.category
191
+ ]
192
+
193
+ def free_first(self) -> list[NewsConnectorSpec]:
194
+ order = {"public-rss": 0, "public-web": 1, "free": 2, "free-tier": 3, "api-key": 4, "custom": 5}
195
+ return sorted(self._connectors, key=lambda connector: (order.get(connector.access, 9), connector.slug))
196
+
197
+
198
+ class NewsConnectorManager:
199
+ """Fetch news from public RSS, free-tier APIs, or custom endpoints."""
200
+
201
+ def __init__(
202
+ self,
203
+ catalog: NewsConnectorCatalog | None = None,
204
+ client: httpx.AsyncClient | None = None,
205
+ timeout_seconds: float = 8,
206
+ ) -> None:
207
+ self.catalog = catalog or NewsConnectorCatalog()
208
+ self.client = client
209
+ self.timeout_seconds = timeout_seconds
210
+
211
+ async def fetch(self, slug: str, symbol: str, limit: int = 10) -> list[NewsItem]:
212
+ spec = self.catalog.get(slug)
213
+ if spec is None:
214
+ raise ProviderError(f"News connector tidak dikenal: {slug}")
215
+ normalized = symbol.upper()
216
+ if spec.access == "public-rss":
217
+ return await self._fetch_rss(spec, normalized, limit)
218
+ if spec.slug == "marketaux":
219
+ return await self._fetch_marketaux(normalized, limit)
220
+ if spec.slug == "newsapi":
221
+ return await self._fetch_newsapi(normalized, limit)
222
+ if spec.slug == "gnews":
223
+ return await self._fetch_gnews(normalized, limit)
224
+ if spec.slug == "alphavantage_news":
225
+ return await self._fetch_alphavantage(normalized, limit)
226
+ if spec.slug == "finnhub_news":
227
+ return await self._fetch_finnhub(normalized, limit)
228
+ if spec.slug == "custom_news":
229
+ return await self._fetch_custom(normalized, limit)
230
+ raise ProviderError(
231
+ f"Adapter aktif untuk {spec.slug} belum tersedia.",
232
+ "Gunakan /news_model list untuk melihat connector aktif atau taruh provider ini di fallback bawah.",
233
+ )
234
+
235
+ async def _fetch_rss(self, spec: NewsConnectorSpec, symbol: str, limit: int) -> list[NewsItem]:
236
+ url = spec.url_template.format(symbol=quote_plus(symbol), query=quote_plus(f"{symbol} stock market news"))
237
+ response = await self._get(url)
238
+ return _parse_rss(response.text, spec.name, limit)
239
+
240
+ async def _fetch_marketaux(self, symbol: str, limit: int) -> list[NewsItem]:
241
+ key = _required_key("marketaux")
242
+ response = await self._get(
243
+ "https://api.marketaux.com/v1/news/all",
244
+ params={"symbols": symbol, "api_token": key, "limit": limit, "language": "en"},
245
+ )
246
+ return _parse_article_list(response.json().get("data", []), "Marketaux", limit)
247
+
248
+ async def _fetch_newsapi(self, symbol: str, limit: int) -> list[NewsItem]:
249
+ key = _required_key("newsapi")
250
+ response = await self._get(
251
+ "https://newsapi.org/v2/everything",
252
+ params={"q": symbol, "apiKey": key, "pageSize": limit, "sortBy": "publishedAt", "language": "en"},
253
+ )
254
+ return _parse_article_list(response.json().get("articles", []), "NewsAPI", limit)
255
+
256
+ async def _fetch_gnews(self, symbol: str, limit: int) -> list[NewsItem]:
257
+ key = _required_key("gnews")
258
+ response = await self._get(
259
+ "https://gnews.io/api/v4/search",
260
+ params={"q": symbol, "token": key, "max": min(limit, 10), "lang": "en"},
261
+ )
262
+ return _parse_article_list(response.json().get("articles", []), "GNews", limit)
263
+
264
+ async def _fetch_alphavantage(self, symbol: str, limit: int) -> list[NewsItem]:
265
+ key = _required_key("alphavantage_news")
266
+ response = await self._get(
267
+ "https://www.alphavantage.co/query",
268
+ params={"function": "NEWS_SENTIMENT", "tickers": symbol, "apikey": key, "limit": limit},
269
+ )
270
+ return _parse_article_list(response.json().get("feed", []), "Alpha Vantage", limit)
271
+
272
+ async def _fetch_finnhub(self, symbol: str, limit: int) -> list[NewsItem]:
273
+ key = _required_key("finnhub_news")
274
+ response = await self._get(
275
+ "https://finnhub.io/api/v1/company-news",
276
+ params={"symbol": symbol, "from": "2020-01-01", "to": datetime.now(timezone.utc).date().isoformat(), "token": key},
277
+ )
278
+ return _parse_article_list(response.json(), "Finnhub", limit)
279
+
280
+ async def _fetch_custom(self, symbol: str, limit: int) -> list[NewsItem]:
281
+ base_url = os.getenv("CUSTOM_NEWS_BASE_URL") or os.getenv("NEWS_DATA_BASE_URL")
282
+ if not base_url:
283
+ raise ProviderError("CUSTOM_NEWS_BASE_URL belum diatur untuk custom_news.")
284
+ key = os.getenv("CUSTOM_NEWS_API_KEY") or os.getenv("NEWS_DATA_API_KEY")
285
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
286
+ response = await self._get(f"{base_url.rstrip('/')}/news/{quote_plus(symbol)}", params={"limit": limit}, headers=headers)
287
+ payload = response.json()
288
+ articles = payload.get("items") or payload.get("articles") or payload.get("data") if isinstance(payload, dict) else payload
289
+ return _parse_article_list(articles or [], "Custom News", limit)
290
+
291
+ async def _get(self, url: str, **kwargs: Any) -> httpx.Response:
292
+ if self.client is not None:
293
+ response = await self.client.get(url, timeout=self.timeout_seconds, **kwargs)
294
+ else:
295
+ async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True) as client:
296
+ response = await client.get(url, **kwargs)
297
+ if response.status_code >= 400:
298
+ raise ProviderError(f"News connector HTTP {response.status_code}: {url}")
299
+ return response
300
+
301
+
302
+ def news_connector_secret_key(slug: str) -> str | None:
303
+ return NEWS_CONNECTOR_SECRET_KEYS.get(slug.strip().lower())
304
+
305
+
306
+ def _required_key(slug: str) -> str:
307
+ env_key = news_connector_secret_key(slug)
308
+ if not env_key:
309
+ raise ProviderError(f"Connector {slug} tidak membutuhkan API key.")
310
+ value = os.getenv(env_key)
311
+ if not value:
312
+ raise ProviderError(
313
+ f"API key untuk news connector {slug} belum diatur.",
314
+ f"Gunakan /news_model key {slug} <api_key>.",
315
+ )
316
+ return value
317
+
318
+
319
+ def _parse_rss(xml_text: str, source: str, limit: int) -> list[NewsItem]:
320
+ try:
321
+ root = ET.fromstring(xml_text)
322
+ except ET.ParseError as exc:
323
+ raise ProviderError(f"RSS dari {source} tidak valid.") from exc
324
+ items: list[NewsItem] = []
325
+ for item in root.findall(".//item")[:limit]:
326
+ title = _clean_text(_xml_text(item, "title"))
327
+ if not title:
328
+ continue
329
+ items.append(
330
+ NewsItem(
331
+ title=title,
332
+ source=source,
333
+ url=_clean_text(_xml_text(item, "link")) or None,
334
+ published_at=_parse_datetime(_xml_text(item, "pubDate") or _xml_text(item, "published")),
335
+ summary=_clean_text(_xml_text(item, "description")),
336
+ )
337
+ )
338
+ return items
339
+
340
+
341
+ def _parse_article_list(articles: Any, source: str, limit: int) -> list[NewsItem]:
342
+ items: list[NewsItem] = []
343
+ if not isinstance(articles, list):
344
+ return items
345
+ for article in articles[:limit]:
346
+ if not isinstance(article, dict):
347
+ continue
348
+ title = _clean_text(str(article.get("title") or article.get("headline") or ""))
349
+ if not title:
350
+ continue
351
+ published = (
352
+ article.get("published_at")
353
+ or article.get("publishedAt")
354
+ or article.get("datetime")
355
+ or article.get("time_published")
356
+ or article.get("date")
357
+ )
358
+ url = article.get("url") or article.get("link")
359
+ source_name = _article_source(article, source)
360
+ items.append(
361
+ NewsItem(
362
+ title=title,
363
+ source=source_name,
364
+ url=str(url) if url else None,
365
+ published_at=_parse_datetime(str(published)) if published is not None else None,
366
+ summary=_clean_text(str(article.get("description") or article.get("summary") or article.get("content") or "")),
367
+ )
368
+ )
369
+ return items
370
+
371
+
372
+ def _article_source(article: dict[str, Any], fallback: str) -> str:
373
+ source = article.get("source")
374
+ if isinstance(source, dict):
375
+ return str(source.get("name") or fallback)
376
+ if source:
377
+ return str(source)
378
+ return fallback
379
+
380
+
381
+ def _xml_text(item: ET.Element, tag: str) -> str:
382
+ child = item.find(tag)
383
+ return child.text if child is not None and child.text else ""
384
+
385
+
386
+ def _parse_datetime(value: str) -> datetime | None:
387
+ text = value.strip()
388
+ if not text:
389
+ return None
390
+ if text.isdigit():
391
+ try:
392
+ return datetime.fromtimestamp(int(text), tz=timezone.utc)
393
+ except (OSError, ValueError):
394
+ return None
395
+ if len(text) == 8 and text.isdigit():
396
+ try:
397
+ return datetime.strptime(text, "%Y%m%d").replace(tzinfo=timezone.utc)
398
+ except ValueError:
399
+ return None
400
+ try:
401
+ return parsedate_to_datetime(text)
402
+ except (TypeError, ValueError):
403
+ pass
404
+ try:
405
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
406
+ except ValueError:
407
+ return None
408
+
409
+
410
+ def _clean_text(value: str) -> str:
411
+ text = re.sub(r"<[^>]+>", "", unescape(value or ""))
412
+ return re.sub(r"\s+", " ", text).strip()
@@ -0,0 +1,80 @@
1
+ """Local price alert management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.storage.database import FinCLIDatabase
8
+ from fincli.app.utils.errors import CommandError
9
+ from fincli.app.utils.formatting import normalize_symbol
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class AlertCheckResult:
14
+ id: int
15
+ symbol: str
16
+ condition: str
17
+ target: float
18
+ current_price: float | None
19
+ triggered: bool
20
+ note: str
21
+
22
+
23
+ class AlertService:
24
+ def __init__(self, db: FinCLIDatabase) -> None:
25
+ self.db = db
26
+
27
+ def add(self, symbol: str, condition: str, target: float, note: str = "") -> None:
28
+ normalized_condition = normalize_condition(condition)
29
+ self.db.execute(
30
+ """
31
+ INSERT INTO alerts(symbol, condition, target, note, active)
32
+ VALUES (?, ?, ?, ?, 1)
33
+ """,
34
+ (normalize_symbol(symbol), normalized_condition, target, note),
35
+ )
36
+
37
+ def remove(self, alert_id: int) -> None:
38
+ self.db.execute("DELETE FROM alerts WHERE id = ?", (alert_id,))
39
+
40
+ def list(self, active_only: bool = False) -> list[dict[str, object]]:
41
+ sql = "SELECT id, symbol, condition, target, note, active, triggered_at, created_at FROM alerts"
42
+ if active_only:
43
+ sql += " WHERE active = 1"
44
+ sql += " ORDER BY active DESC, id DESC"
45
+ return [dict(row) for row in self.db.query(sql)]
46
+
47
+ def mark_triggered(self, alert_id: int) -> None:
48
+ self.db.execute(
49
+ "UPDATE alerts SET active = 0, triggered_at = CURRENT_TIMESTAMP WHERE id = ?",
50
+ (alert_id,),
51
+ )
52
+
53
+
54
+ def normalize_condition(condition: str) -> str:
55
+ normalized = condition.strip().lower()
56
+ if normalized in {">", "above", "price_above", "gt"}:
57
+ return "above"
58
+ if normalized in {"<", "below", "price_below", "lt"}:
59
+ return "below"
60
+ raise CommandError("Alert condition must be above/> or below/<.")
61
+
62
+
63
+ def evaluate_alert(alert: dict[str, object], current_price: float | None) -> AlertCheckResult:
64
+ condition = str(alert["condition"])
65
+ target = float(alert["target"])
66
+ triggered = False
67
+ if current_price is not None:
68
+ if condition == "above":
69
+ triggered = current_price >= target
70
+ elif condition == "below":
71
+ triggered = current_price <= target
72
+ return AlertCheckResult(
73
+ id=int(alert["id"]),
74
+ symbol=str(alert["symbol"]),
75
+ condition=condition,
76
+ target=target,
77
+ current_price=current_price,
78
+ triggered=triggered,
79
+ note=str(alert.get("note") or ""),
80
+ )