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.
Files changed (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. 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.")