@drico2008/fincli 0.1.3 → 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.
- package/LICENSE +21 -0
- package/README.md +909 -684
- 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 +23 -16
- package/fincli/app/analysis/analyzer.py +107 -100
- package/fincli/app/analysis/assistant_context.py +187 -160
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/indicators.py +1 -1
- package/fincli/app/analysis/market_structure.py +1 -1
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +105 -77
- package/fincli/app/cli/router.py +2143 -1121
- 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/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/session_history.py +113 -0
- 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/anthropic_provider.py +8 -7
- package/fincli/app/providers/ai/gemini_provider.py +8 -7
- package/fincli/app/providers/ai/groq_provider.py +8 -7
- package/fincli/app/providers/ai/huggingface_provider.py +8 -7
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/ai/openai_provider.py +8 -7
- package/fincli/app/providers/ai/openrouter_provider.py +8 -7
- package/fincli/app/providers/ai/together_provider.py +8 -7
- 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 +85 -2
- package/fincli/app/providers/market/news_provider.py +4 -4
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/market/yfinance_provider.py +1 -1
- package/fincli/app/research/__init__.py +7 -0
- package/fincli/app/research/engine.py +75 -0
- package/fincli/app/research/formatter.py +22 -0
- package/fincli/app/research/models.py +18 -0
- package/fincli/app/research/prompt_builder.py +47 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +203 -203
- package/fincli/app/services/news_aggregator.py +90 -0
- package/fincli/app/services/web_research.py +267 -0
- package/fincli/app/storage/cache.py +2 -2
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +201 -85
- package/fincli/app/storage/secrets.py +12 -3
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +270 -258
- package/fincli/app/tui/market_provider_selector.py +6 -1
- package/fincli/app/tui/model_selector.py +11 -3
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +125 -12
- package/npm/bin/fincli.js +9 -2
- package/package.json +23 -23
- package/pyproject.toml +35 -35
|
@@ -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}®ion=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
|
+
)
|