marketmyna 0.2.0__py3-none-any.whl
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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- marketmyna-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Finnhub: real-time US stock prices for the Markets / Watchlist tables.
|
|
2
|
+
|
|
3
|
+
Free key at finnhub.io (Dashboard -> API key). Uses:
|
|
4
|
+
- the trades WebSocket (wss://ws.finnhub.io) for every US symbol on
|
|
5
|
+
screen: each trade updates the price immediately;
|
|
6
|
+
- the REST quote (/api/v1/quote, free tier 60 calls/min) once per symbol per
|
|
7
|
+
day for the previous close (to compute % change) and a starting price.
|
|
8
|
+
|
|
9
|
+
One connection is shared by all users. The free plan streams a limited
|
|
10
|
+
number of symbols at once (about 50), so the most recently viewed ones are
|
|
11
|
+
subscribed first.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from collections import deque
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Optional
|
|
24
|
+
from zoneinfo import ZoneInfo
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
WS_URL = "wss://ws.finnhub.io"
|
|
29
|
+
REST = "https://finnhub.io/api/v1/quote"
|
|
30
|
+
MAX_SUBSCRIPTIONS = int(os.environ.get("FINNHUB_MAX_SYMBOLS", 50))
|
|
31
|
+
REST_GAP = 1.1 # seconds between REST calls: stays under 60/min
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def configured(config) -> bool:
|
|
35
|
+
return bool(getattr(config, "finnhub_api_key", None))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _us_date(epoch: float):
|
|
39
|
+
return datetime.fromtimestamp(epoch, ZoneInfo("America/New_York")).date()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FinnhubStream:
|
|
43
|
+
def __init__(self, key: str):
|
|
44
|
+
self.key = key
|
|
45
|
+
self.trades: dict[str, dict] = {} # symbol -> {price, time}
|
|
46
|
+
self.base: dict[str, dict] = {} # symbol -> {prev_close, price, time, day} from REST
|
|
47
|
+
self.wanted: list[str] = [] # most recent first
|
|
48
|
+
self.subscribed: set[str] = set()
|
|
49
|
+
self.error: Optional[str] = None
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self._ws = None
|
|
52
|
+
self._stop = threading.Event()
|
|
53
|
+
self._rest_queue: deque[str] = deque()
|
|
54
|
+
self._threads: list[threading.Thread] = []
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------ lifecycle
|
|
57
|
+
def start(self) -> None:
|
|
58
|
+
for target, name in ((self._run_ws, "finnhub-ws"), (self._run_rest, "finnhub-rest")):
|
|
59
|
+
t = threading.Thread(target=target, name=name, daemon=True)
|
|
60
|
+
t.start()
|
|
61
|
+
self._threads.append(t)
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
self._stop.set()
|
|
65
|
+
ws = self._ws
|
|
66
|
+
if ws is not None:
|
|
67
|
+
try:
|
|
68
|
+
ws.close()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------ demand
|
|
73
|
+
def want(self, symbols: list[str]) -> None:
|
|
74
|
+
"""Symbols currently on screen; newest demand first, capped."""
|
|
75
|
+
with self._lock:
|
|
76
|
+
fresh = list(dict.fromkeys(s.upper() for s in symbols))
|
|
77
|
+
self.wanted = (fresh + [s for s in self.wanted if s not in set(fresh)])[:MAX_SUBSCRIPTIONS]
|
|
78
|
+
today = _us_date(time.time())
|
|
79
|
+
for s in self.wanted:
|
|
80
|
+
b = self.base.get(s)
|
|
81
|
+
if (b is None or b.get("day") != today) and s not in self._rest_queue:
|
|
82
|
+
self._rest_queue.append(s)
|
|
83
|
+
self._sync()
|
|
84
|
+
|
|
85
|
+
def _sync(self) -> None:
|
|
86
|
+
ws = self._ws
|
|
87
|
+
if ws is None:
|
|
88
|
+
return
|
|
89
|
+
with self._lock:
|
|
90
|
+
target = set(self.wanted)
|
|
91
|
+
add, drop = target - self.subscribed, self.subscribed - target
|
|
92
|
+
try:
|
|
93
|
+
for s in drop:
|
|
94
|
+
ws.send(json.dumps({"type": "unsubscribe", "symbol": s}))
|
|
95
|
+
for s in add:
|
|
96
|
+
ws.send(json.dumps({"type": "subscribe", "symbol": s}))
|
|
97
|
+
with self._lock:
|
|
98
|
+
self.subscribed = (self.subscribed - drop) | add
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
logger.debug("Finnhub subscribe failed: %s", exc)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------ snapshot
|
|
103
|
+
def quotes(self, symbols: list[str]) -> dict[str, dict]:
|
|
104
|
+
out = {}
|
|
105
|
+
with self._lock:
|
|
106
|
+
for s in (x.upper() for x in symbols):
|
|
107
|
+
b, t = self.base.get(s), self.trades.get(s)
|
|
108
|
+
price = t["price"] if t else (b or {}).get("price")
|
|
109
|
+
if not price:
|
|
110
|
+
continue
|
|
111
|
+
prev = (b or {}).get("prev_close")
|
|
112
|
+
when = t["time"] if t else (b or {}).get("time") or time.time()
|
|
113
|
+
out[s] = {
|
|
114
|
+
"price": price,
|
|
115
|
+
"change": round(price - prev, 4) if prev else None,
|
|
116
|
+
"change_percent": round((price - prev) / prev * 100, 2) if prev else None,
|
|
117
|
+
"volume": None, # Finnhub trades carry per-trade size, not the day's volume
|
|
118
|
+
"time": when,
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------ workers
|
|
123
|
+
def _run_ws(self) -> None:
|
|
124
|
+
from websockets.sync.client import connect
|
|
125
|
+
|
|
126
|
+
backoff = 2.0
|
|
127
|
+
while not self._stop.is_set():
|
|
128
|
+
try:
|
|
129
|
+
with connect(f"{WS_URL}?token={self.key}", open_timeout=15, close_timeout=2) as ws:
|
|
130
|
+
self._ws, self.error, backoff = ws, None, 2.0
|
|
131
|
+
with self._lock:
|
|
132
|
+
self.subscribed = set()
|
|
133
|
+
self._sync()
|
|
134
|
+
while not self._stop.is_set():
|
|
135
|
+
try:
|
|
136
|
+
raw = ws.recv(timeout=5)
|
|
137
|
+
except TimeoutError:
|
|
138
|
+
self._sync()
|
|
139
|
+
continue
|
|
140
|
+
self._on_message(raw)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
self.error = f"Finnhub stream: {exc}"
|
|
143
|
+
logger.warning("Finnhub WebSocket dropped: %s", exc)
|
|
144
|
+
finally:
|
|
145
|
+
self._ws = None
|
|
146
|
+
self._stop.wait(backoff)
|
|
147
|
+
backoff = min(backoff * 2, 60)
|
|
148
|
+
|
|
149
|
+
def _on_message(self, raw) -> None:
|
|
150
|
+
try:
|
|
151
|
+
msg = json.loads(raw)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return
|
|
154
|
+
if msg.get("type") == "error":
|
|
155
|
+
self.error = f"Finnhub: {msg.get('msg')}"
|
|
156
|
+
return
|
|
157
|
+
if msg.get("type") != "trade":
|
|
158
|
+
return # "ping" keep-alives
|
|
159
|
+
with self._lock:
|
|
160
|
+
for t in msg.get("data") or []:
|
|
161
|
+
sym, price, ms = t.get("s"), t.get("p"), t.get("t")
|
|
162
|
+
if not sym or not price:
|
|
163
|
+
continue
|
|
164
|
+
cur = self.trades.get(sym)
|
|
165
|
+
when = (ms or 0) / 1000 or time.time()
|
|
166
|
+
if cur is None or when >= cur["time"]:
|
|
167
|
+
self.trades[sym] = {"price": float(price), "time": when}
|
|
168
|
+
|
|
169
|
+
def _run_rest(self) -> None:
|
|
170
|
+
import requests
|
|
171
|
+
|
|
172
|
+
while not self._stop.is_set():
|
|
173
|
+
with self._lock:
|
|
174
|
+
sym = self._rest_queue.popleft() if self._rest_queue else None
|
|
175
|
+
if sym is None:
|
|
176
|
+
self._stop.wait(0.5)
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
r = requests.get(REST, params={"symbol": sym, "token": self.key}, timeout=10)
|
|
180
|
+
if r.status_code == 429:
|
|
181
|
+
with self._lock:
|
|
182
|
+
self._rest_queue.appendleft(sym)
|
|
183
|
+
self._stop.wait(10)
|
|
184
|
+
continue
|
|
185
|
+
q = r.json() if r.ok else {}
|
|
186
|
+
if q.get("c"):
|
|
187
|
+
with self._lock:
|
|
188
|
+
self.base[sym] = {"prev_close": q.get("pc"), "price": q.get("c"),
|
|
189
|
+
"time": q.get("t") or time.time(), "day": _us_date(time.time())}
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
logger.debug("Finnhub quote %s failed: %s", sym, exc)
|
|
192
|
+
self._stop.wait(REST_GAP)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_stream: Optional[FinnhubStream] = None
|
|
196
|
+
_stream_lock = threading.Lock()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def stream(config) -> FinnhubStream:
|
|
200
|
+
"""The shared stream; restarted when the key changes."""
|
|
201
|
+
global _stream
|
|
202
|
+
key = config.finnhub_api_key
|
|
203
|
+
with _stream_lock:
|
|
204
|
+
if _stream is None or _stream.key != key:
|
|
205
|
+
if _stream is not None:
|
|
206
|
+
_stream.stop()
|
|
207
|
+
_stream = FinnhubStream(key)
|
|
208
|
+
_stream.start()
|
|
209
|
+
return _stream
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Works out which AI provider a pasted key belongs to, checks the provider
|
|
2
|
+
accepts it, and picks the model to use with it (a free one where the key
|
|
3
|
+
only has free access).
|
|
4
|
+
|
|
5
|
+
Supported: Anthropic, Google Gemini, Groq, Mistral and OpenRouter. The key's
|
|
6
|
+
prefix settles the provider (AIza = Gemini, gsk_ = Groq, sk-or- = OpenRouter,
|
|
7
|
+
sk-ant- = Anthropic, no prefix = Mistral); the provider is then asked to list
|
|
8
|
+
its models with the key, which checks it works. The key is only ever sent to
|
|
9
|
+
the provider it is being tested against.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from ..llm.model_catalog import recommend
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
TIMEOUT = 8
|
|
25
|
+
|
|
26
|
+
PROVIDER_LABELS = {
|
|
27
|
+
"gemini": "Google Gemini",
|
|
28
|
+
"groq": "Groq",
|
|
29
|
+
"openrouter": "OpenRouter",
|
|
30
|
+
"openai": "OpenAI",
|
|
31
|
+
"anthropic": "Anthropic",
|
|
32
|
+
"deepseek": "DeepSeek",
|
|
33
|
+
"mistral": "Mistral",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Providers a pasted key is detected for (OpenAI / DeepSeek keys saved earlier still work).
|
|
37
|
+
SUPPORTED_TEXT = "Anthropic, Google Gemini, Groq, Mistral or OpenRouter"
|
|
38
|
+
|
|
39
|
+
# Used only when the key's model list can't be read (provider unreachable);
|
|
40
|
+
# otherwise model_catalog.recommend() picks from the live list, so a new
|
|
41
|
+
# release is used and a retired one isn't. litellm prefix added later.
|
|
42
|
+
PREFERRED_MODELS = {
|
|
43
|
+
"gemini": ["gemini-flash-latest"],
|
|
44
|
+
"groq": ["openai/gpt-oss-120b"],
|
|
45
|
+
"openrouter": ["openai/gpt-5-mini"],
|
|
46
|
+
"openai": ["gpt-5-mini"],
|
|
47
|
+
"anthropic": ["claude-haiku-4-5"],
|
|
48
|
+
"deepseek": ["deepseek-chat"],
|
|
49
|
+
"mistral": ["mistral-small-latest"],
|
|
50
|
+
}
|
|
51
|
+
OPENROUTER_FREE_MODELS = [
|
|
52
|
+
"meta-llama/llama-3.3-70b-instruct:free",
|
|
53
|
+
"deepseek/deepseek-chat-v3-0324:free",
|
|
54
|
+
"google/gemini-2.0-flash-exp:free",
|
|
55
|
+
"mistralai/mistral-small-3.2-24b-instruct:free",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Providers whose keys come with a free tier, and how to describe it.
|
|
59
|
+
FREE_TIER_NOTES = {
|
|
60
|
+
"gemini": "Free tier (rate-limited) unless billing is enabled on the Google project.",
|
|
61
|
+
"groq": "Free tier (rate-limited).",
|
|
62
|
+
"mistral": "Free Experiment plan (rate-limited) unless the workspace is on a paid plan.",
|
|
63
|
+
}
|
|
64
|
+
PAID_NOTE = "Paid per use: calls are billed to your {label} account."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class KeyDetectError(ValueError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Detected:
|
|
73
|
+
provider: str
|
|
74
|
+
model: str # litellm id, e.g. gemini/gemini-flash-latest
|
|
75
|
+
free: Optional[bool] # True: free access; False: billed; None: can't tell
|
|
76
|
+
note: str
|
|
77
|
+
verified: bool = True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def candidates(key: str) -> list[str]:
|
|
81
|
+
"""Providers a key could belong to, most likely first."""
|
|
82
|
+
if key.startswith("AIza"):
|
|
83
|
+
return ["gemini"]
|
|
84
|
+
if key.startswith("gsk_"):
|
|
85
|
+
return ["groq"]
|
|
86
|
+
if key.startswith("sk-or-"):
|
|
87
|
+
return ["openrouter"]
|
|
88
|
+
if key.startswith("sk-ant-"):
|
|
89
|
+
return ["anthropic"]
|
|
90
|
+
if key.startswith("sk-"):
|
|
91
|
+
return [] # OpenAI / DeepSeek style: not supported
|
|
92
|
+
return ["mistral"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get(url: str, headers: dict) -> requests.Response:
|
|
96
|
+
return requests.get(url, headers=headers, timeout=TIMEOUT)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _list_models(provider: str, key: str) -> Optional[list[str]]:
|
|
100
|
+
"""Model ids the key can see; None if the provider is unreachable.
|
|
101
|
+
Raises PermissionError when the provider rejects the key."""
|
|
102
|
+
bearer = {"Authorization": f"Bearer {key}"}
|
|
103
|
+
if provider == "gemini":
|
|
104
|
+
url, headers = "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000", {"x-goog-api-key": key}
|
|
105
|
+
elif provider == "anthropic":
|
|
106
|
+
url, headers = "https://api.anthropic.com/v1/models?limit=1000", {"x-api-key": key, "anthropic-version": "2023-06-01"}
|
|
107
|
+
else:
|
|
108
|
+
url = {
|
|
109
|
+
"groq": "https://api.groq.com/openai/v1/models",
|
|
110
|
+
"openrouter": "https://openrouter.ai/api/v1/key",
|
|
111
|
+
"openai": "https://api.openai.com/v1/models",
|
|
112
|
+
"deepseek": "https://api.deepseek.com/models",
|
|
113
|
+
"mistral": "https://api.mistral.ai/v1/models",
|
|
114
|
+
}[provider]
|
|
115
|
+
headers = bearer
|
|
116
|
+
try:
|
|
117
|
+
resp = _get(url, headers)
|
|
118
|
+
except requests.RequestException as exc:
|
|
119
|
+
logger.info("Key check against %s failed: %s", provider, exc)
|
|
120
|
+
return None
|
|
121
|
+
if resp.status_code in (400, 401, 403):
|
|
122
|
+
raise PermissionError(provider)
|
|
123
|
+
if resp.status_code >= 300:
|
|
124
|
+
logger.info("Key check against %s: HTTP %s", provider, resp.status_code)
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
body = resp.json()
|
|
128
|
+
except ValueError:
|
|
129
|
+
return None
|
|
130
|
+
if provider == "openrouter":
|
|
131
|
+
return ["__free_tier__"] if (body.get("data") or {}).get("is_free_tier") else []
|
|
132
|
+
if provider == "gemini":
|
|
133
|
+
return [m.get("name", "").removeprefix("models/") for m in body.get("models") or []]
|
|
134
|
+
return [m.get("id", "") for m in body.get("data") or []]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _openrouter_free_model() -> str:
|
|
138
|
+
try:
|
|
139
|
+
models = _get("https://openrouter.ai/api/v1/models", {}).json().get("data") or []
|
|
140
|
+
except (requests.RequestException, ValueError):
|
|
141
|
+
return OPENROUTER_FREE_MODELS[0]
|
|
142
|
+
free = {
|
|
143
|
+
m.get("id") for m in models
|
|
144
|
+
if str((m.get("pricing") or {}).get("prompt")) == "0" and str((m.get("pricing") or {}).get("completion")) == "0"
|
|
145
|
+
}
|
|
146
|
+
for model in OPENROUTER_FREE_MODELS:
|
|
147
|
+
if model in free:
|
|
148
|
+
return model
|
|
149
|
+
return sorted(m for m in free if m and m.endswith(":free"))[0] if free else OPENROUTER_FREE_MODELS[0]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _pick(provider: str, seen: Optional[list[str]]) -> Detected:
|
|
153
|
+
label = PROVIDER_LABELS[provider]
|
|
154
|
+
if provider == "openrouter":
|
|
155
|
+
if seen and "__free_tier__" in seen:
|
|
156
|
+
return Detected(provider, f"openrouter/{_openrouter_free_model()}", True,
|
|
157
|
+
"Free account: using a free model (rate-limited).")
|
|
158
|
+
if seen is None:
|
|
159
|
+
return Detected(provider, f"openrouter/{PREFERRED_MODELS[provider][0]}", None,
|
|
160
|
+
"Couldn't reach OpenRouter to check the key; it will be tried on first use.", False)
|
|
161
|
+
return Detected(provider, f"openrouter/{PREFERRED_MODELS[provider][0]}", False, PAID_NOTE.format(label=label))
|
|
162
|
+
model = (recommend(provider, seen) if seen else None) or PREFERRED_MODELS[provider][0]
|
|
163
|
+
if seen is None:
|
|
164
|
+
return Detected(provider, f"{provider}/{model}", provider in FREE_TIER_NOTES or None,
|
|
165
|
+
f"Couldn't reach {label} to check the key; it will be tried on first use.", False)
|
|
166
|
+
if provider in FREE_TIER_NOTES:
|
|
167
|
+
return Detected(provider, f"{provider}/{model}", True, FREE_TIER_NOTES[provider])
|
|
168
|
+
return Detected(provider, f"{provider}/{model}", False, PAID_NOTE.format(label=label))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def detect(key: str) -> Detected:
|
|
172
|
+
"""Provider and model for `key`. Raises KeyDetectError when no supported
|
|
173
|
+
provider accepts it."""
|
|
174
|
+
key = (key or "").strip()
|
|
175
|
+
options = candidates(key)
|
|
176
|
+
if not options:
|
|
177
|
+
raise KeyDetectError(f"This looks like an OpenAI or DeepSeek key. Use a key from {SUPPORTED_TEXT}.")
|
|
178
|
+
unreachable: list[str] = []
|
|
179
|
+
for provider in options:
|
|
180
|
+
try:
|
|
181
|
+
seen = _list_models(provider, key)
|
|
182
|
+
except PermissionError:
|
|
183
|
+
continue
|
|
184
|
+
if seen is None:
|
|
185
|
+
unreachable.append(provider)
|
|
186
|
+
continue
|
|
187
|
+
return _pick(provider, seen)
|
|
188
|
+
if unreachable and len(options) == 1:
|
|
189
|
+
return _pick(unreachable[0], None)
|
|
190
|
+
if unreachable:
|
|
191
|
+
raise KeyDetectError(
|
|
192
|
+
f"Couldn't reach {', '.join(PROVIDER_LABELS[p] for p in unreachable)} to check this key. Try again in a minute."
|
|
193
|
+
)
|
|
194
|
+
if options == ["mistral"]:
|
|
195
|
+
raise KeyDetectError(f"This key wasn't accepted. Use a key from {SUPPORTED_TEXT}.")
|
|
196
|
+
raise KeyDetectError(f"{' / '.join(PROVIDER_LABELS[p] for p in options)} rejected this key. Check it was copied in full.")
|