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,128 @@
1
+ """Provider catalogue for the Settings page: which AI providers the app
2
+ knows, where to get a key, and a live "which models can this key use"
3
+ lookup - so model names come from the provider, not from a list in this
4
+ code that goes stale.
5
+
6
+ Returned model ids are already in litellm form ("groq/openai/gpt-oss-120b"),
7
+ and the one model_catalog.recommend() would pick is marked "recommended".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Iterable, Optional
13
+
14
+ import requests
15
+
16
+ _TIMEOUT = 15
17
+
18
+ # id, label, config key field, where to get a key, free-tier note
19
+ PROVIDERS = [
20
+ {"id": "gemini", "label": "Google Gemini", "key_field": "gemini_api_key",
21
+ "key_url": "https://aistudio.google.com/app/apikey",
22
+ "note": "Free tier with per-model rate limits - a good default."},
23
+ {"id": "groq", "label": "Groq", "key_field": "groq_api_key",
24
+ "key_url": "https://console.groq.com/keys",
25
+ "note": "Free tier, very fast - good as the fast model or a fallback."},
26
+ {"id": "openrouter", "label": "OpenRouter", "key_field": "openrouter_api_key",
27
+ "key_url": "https://openrouter.ai/keys",
28
+ "note": "One key for many models; free models are marked but rate-limited."},
29
+ {"id": "openai", "label": "OpenAI", "key_field": "openai_api_key",
30
+ "key_url": "https://platform.openai.com/api-keys", "note": "Paid."},
31
+ {"id": "anthropic", "label": "Anthropic", "key_field": "anthropic_api_key",
32
+ "key_url": "https://console.anthropic.com/settings/keys", "note": "Paid."},
33
+ {"id": "deepseek", "label": "DeepSeek", "key_field": "deepseek_api_key",
34
+ "key_url": "https://platform.deepseek.com/api_keys", "note": "Paid, low cost."},
35
+ {"id": "mistral", "label": "Mistral", "key_field": "mistral_api_key",
36
+ "key_url": "https://console.mistral.ai/api-keys", "note": "Free experiment tier available."},
37
+ {"id": "cerebras", "label": "Cerebras", "key_field": "cerebras_api_key",
38
+ "key_url": "https://cloud.cerebras.ai/platform", "note": "Free tier with a large daily token allowance - a strong fallback."},
39
+ {"id": "nvidia_nim", "label": "NVIDIA NIM", "key_field": "nvidia_api_key",
40
+ "key_url": "https://build.nvidia.com/settings/api-keys", "note": "Free developer credits for hosted open models."},
41
+ {"id": "ollama", "label": "Ollama (local)", "key_field": None,
42
+ "key_url": "https://ollama.com/download", "note": "Runs on your machine: free and private."},
43
+ ]
44
+ PROVIDER_IDS = {p["id"] for p in PROVIDERS}
45
+
46
+
47
+ def list_models(provider: str, api_key: Optional[str] = None, base_url: Optional[str] = None,
48
+ include_old: bool = True, hide: Iterable[str] = ()) -> list[dict]:
49
+ """[{id, label, free, recommended}] for models this key can use to
50
+ generate text; the recommended one first.
51
+
52
+ include_old=False leaves out models a newer stable release of the same
53
+ line replaces (model_catalog.superseded), models the provider marks
54
+ inactive, and `hide` (litellm ids found retired by model_health)."""
55
+ from .model_catalog import bare, is_text_model, recommend, superseded
56
+
57
+ models = [m for m in _list_models(provider, api_key, base_url) if is_text_model(bare(m["id"]))]
58
+ if not include_old:
59
+ gone = superseded(bare(m["id"]) for m in models)
60
+ hidden = set(hide)
61
+ models = [m for m in models if m.get("active", True) and bare(m["id"]) not in gone and m["id"] not in hidden]
62
+ for m in models:
63
+ m.pop("active", None)
64
+ best = recommend(provider, [bare(m["id"]) for m in models])
65
+ for m in models:
66
+ m["recommended"] = best is not None and bare(m["id"]) == best
67
+ return sorted(models, key=lambda m: not m["recommended"])
68
+
69
+
70
+ def _list_models(provider: str, api_key: Optional[str] = None, base_url: Optional[str] = None) -> list[dict]:
71
+ if provider == "gemini":
72
+ resp = requests.get("https://generativelanguage.googleapis.com/v1beta/models",
73
+ params={"key": api_key, "pageSize": 200}, timeout=_TIMEOUT)
74
+ resp.raise_for_status()
75
+ out = []
76
+ for m in resp.json().get("models", []):
77
+ if "generateContent" not in (m.get("supportedGenerationMethods") or []):
78
+ continue
79
+ name = m["name"].split("/", 1)[-1]
80
+ out.append({"id": f"gemini/{name}", "label": m.get("displayName") or name, "free": None})
81
+ return out
82
+
83
+ if provider == "openrouter":
84
+ # Public list; pricing tells which models are free.
85
+ resp = requests.get("https://openrouter.ai/api/v1/models", timeout=_TIMEOUT)
86
+ resp.raise_for_status()
87
+ out = []
88
+ for m in resp.json().get("data", []):
89
+ pricing = m.get("pricing") or {}
90
+ free = str(pricing.get("prompt")) in ("0", "0.0") and str(pricing.get("completion")) in ("0", "0.0")
91
+ out.append({"id": f"openrouter/{m['id']}", "label": m.get("name") or m["id"], "free": free})
92
+ return sorted(out, key=lambda m: (not m["free"], m["label"].lower()))
93
+
94
+ if provider == "ollama":
95
+ base = (base_url or "http://localhost:11434").rstrip("/")
96
+ resp = requests.get(f"{base}/api/tags", timeout=_TIMEOUT)
97
+ resp.raise_for_status()
98
+ return [{"id": f"ollama/{m['name']}", "label": m["name"], "free": True} for m in resp.json().get("models", [])]
99
+
100
+ if provider == "anthropic":
101
+ resp = requests.get("https://api.anthropic.com/v1/models", timeout=_TIMEOUT,
102
+ headers={"x-api-key": api_key or "", "anthropic-version": "2023-06-01"})
103
+ resp.raise_for_status()
104
+ return [{"id": f"anthropic/{m['id']}", "label": m.get("display_name") or m["id"], "free": False}
105
+ for m in resp.json().get("data", [])]
106
+
107
+ # OpenAI-style /models lists.
108
+ urls = {
109
+ "groq": "https://api.groq.com/openai/v1/models",
110
+ "openai": "https://api.openai.com/v1/models",
111
+ "deepseek": "https://api.deepseek.com/models",
112
+ "mistral": "https://api.mistral.ai/v1/models",
113
+ "cerebras": "https://api.cerebras.ai/v1/models",
114
+ "nvidia_nim": "https://integrate.api.nvidia.com/v1/models",
115
+ }
116
+ if provider not in urls:
117
+ raise ValueError(f"Unknown provider '{provider}'")
118
+ resp = requests.get(urls[provider], headers={"Authorization": f"Bearer {api_key or ''}"}, timeout=_TIMEOUT)
119
+ resp.raise_for_status()
120
+ skip = ("whisper", "tts", "embedding", "embed", "moderation", "dall-e", "image", "audio", "transcribe", "guard")
121
+ out = []
122
+ for m in resp.json().get("data", []):
123
+ mid = m.get("id", "")
124
+ if not mid or any(word in mid.lower() for word in skip):
125
+ continue
126
+ out.append({"id": f"{provider}/{mid}", "label": mid, "free": True if provider in ("groq", "cerebras") else None,
127
+ "active": m.get("active") is not False})
128
+ return sorted(out, key=lambda m: m["label"].lower())
@@ -0,0 +1,128 @@
1
+ """Response cache and usage counters for LLM calls (sqlite, shared by the
2
+ API process and the scheduler's child processes).
3
+
4
+ The cache key covers everything that shapes the answer - model tier,
5
+ system prompt, prompt, and generation limits - so a cached reply is only
6
+ reused for a byte-identical request. Prompts embed the current price and
7
+ indicator values, so a new trading day naturally misses the cache.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import logging
14
+ from datetime import date, datetime, timedelta
15
+ from typing import Optional
16
+
17
+ from ..utils.log_redact import SECRETS as _SECRETS
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ DEFAULT_CACHE_HOURS = 6.0
22
+
23
+ # GenerationErrorCode value -> the reason shown in Settings -> AI usage.
24
+ ERROR_KINDS = {
25
+ "model_unavailable": "retired",
26
+ "rate_limited": "quota",
27
+ "auth_failed": "key",
28
+ "timeout": "timeout",
29
+ "invalid_response": "bad answer",
30
+ "unknown": "outage",
31
+ }
32
+
33
+
34
+ def clean_error(exc, limit: int = 300) -> str:
35
+ """One line of an error's text, with anything that looks like an API key
36
+ masked (provider errors can echo the request URL, ?key=... included)."""
37
+ return _SECRETS.sub("***", " ".join(str(exc).split()))[:limit]
38
+
39
+
40
+ def error_kind(exc) -> str:
41
+ code = getattr(getattr(exc, "error_code", None), "value", "")
42
+ return ERROR_KINDS.get(code, "other")
43
+
44
+
45
+ class LLMStore:
46
+ def __init__(self, config):
47
+ from ..storage import DatabaseManager, get_db_url
48
+
49
+ self._db = DatabaseManager.instance(get_db_url(config=config))
50
+ hours = getattr(config, "llm_cache_hours", None)
51
+ self.cache_hours = DEFAULT_CACHE_HOURS if hours is None else float(hours)
52
+
53
+ @staticmethod
54
+ def key(tier: str, system_prompt: Optional[str], prompt: str, max_tokens, temperature) -> str:
55
+ raw = "\x1f".join([tier, system_prompt or "", prompt, str(max_tokens), str(temperature)])
56
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
57
+
58
+ # ---- cache
59
+ def get(self, key: str) -> Optional[tuple[str, str]]:
60
+ if self.cache_hours <= 0:
61
+ return None
62
+ from ..storage import LLMCacheRecord
63
+
64
+ try:
65
+ with self._db.get_session() as session:
66
+ record = session.get(LLMCacheRecord, key)
67
+ if record and record.created_at >= datetime.now() - timedelta(hours=self.cache_hours):
68
+ return record.text, record.model or ""
69
+ except Exception as exc:
70
+ logger.debug("LLM cache read failed: %s", exc)
71
+ return None
72
+
73
+ def put(self, key: str, text: str, model: str) -> None:
74
+ if self.cache_hours <= 0 or not text:
75
+ return
76
+ from ..storage import LLMCacheRecord
77
+
78
+ try:
79
+ with self._db.get_session() as session:
80
+ session.merge(LLMCacheRecord(key=key, text=text, model=model, created_at=datetime.now()))
81
+ # Keep the table small: drop answers as soon as they are past the TTL.
82
+ cutoff = datetime.now() - timedelta(hours=self.cache_hours)
83
+ session.query(LLMCacheRecord).filter(LLMCacheRecord.created_at < cutoff).delete(
84
+ synchronize_session=False)
85
+ except Exception as exc:
86
+ logger.debug("LLM cache write failed: %s", exc)
87
+
88
+ # ---- usage
89
+ def record(self, route: str, *, ok: bool = True, cached: bool = False, usage: Optional[dict] = None,
90
+ error: Optional[Exception] = None) -> None:
91
+ from ..storage import LLMUsageRecord
92
+
93
+ usage = usage or {}
94
+ try:
95
+ with self._db.get_session() as session:
96
+ today = date.today().isoformat()
97
+ row = session.query(LLMUsageRecord).filter_by(day=today, route=route).first()
98
+ if row is None:
99
+ row = LLMUsageRecord(day=today, route=route, calls=0, failures=0, cache_hits=0,
100
+ prompt_tokens=0, completion_tokens=0)
101
+ session.add(row)
102
+ if cached:
103
+ row.cache_hits = (row.cache_hits or 0) + 1
104
+ elif ok:
105
+ row.calls = (row.calls or 0) + 1
106
+ row.prompt_tokens = (row.prompt_tokens or 0) + int(usage.get("prompt_tokens") or 0)
107
+ row.completion_tokens = (row.completion_tokens or 0) + int(usage.get("completion_tokens") or 0)
108
+ else:
109
+ row.failures = (row.failures or 0) + 1
110
+ if error is not None:
111
+ row.last_error_kind = error_kind(error)
112
+ row.last_error = clean_error(error)
113
+ except Exception as exc:
114
+ logger.debug("LLM usage write failed: %s", exc)
115
+
116
+ def usage(self, days: int = 7) -> list[dict]:
117
+ from ..storage import LLMUsageRecord
118
+
119
+ since = (date.today() - timedelta(days=days - 1)).isoformat()
120
+ with self._db.get_session() as session:
121
+ rows = session.query(LLMUsageRecord).filter(LLMUsageRecord.day >= since).all()
122
+ return [
123
+ {"day": r.day, "route": r.route, "calls": r.calls or 0, "failures": r.failures or 0,
124
+ "cache_hits": r.cache_hits or 0, "prompt_tokens": r.prompt_tokens or 0,
125
+ "completion_tokens": r.completion_tokens or 0, "last_error_kind": r.last_error_kind,
126
+ "last_error": r.last_error}
127
+ for r in sorted(rows, key=lambda r: (r.day, r.route), reverse=True)
128
+ ]
@@ -0,0 +1,35 @@
1
+ """Notification channel senders and dispatcher."""
2
+
3
+ from .astrbot import AstrbotSender
4
+ from .dingtalk import DingtalkSender
5
+ from .discord import DiscordSender
6
+ from .dispatcher import NotificationDispatcher
7
+ from .email import EmailSender
8
+ from .feishu import FeishuSender
9
+ from .gotify import GotifySender
10
+ from .ntfy import NtfySender
11
+ from .pushover import PushoverSender
12
+ from .pushplus import PushplusSender
13
+ from .serverchan3 import Serverchan3Sender
14
+ from .slack import SlackSender
15
+ from .telegram import TelegramSender
16
+ from .webhook import WebhookSender
17
+ from .wechat import WechatSender
18
+
19
+ __all__ = [
20
+ "AstrbotSender",
21
+ "DingtalkSender",
22
+ "DiscordSender",
23
+ "EmailSender",
24
+ "FeishuSender",
25
+ "GotifySender",
26
+ "NotificationDispatcher",
27
+ "NtfySender",
28
+ "PushoverSender",
29
+ "PushplusSender",
30
+ "Serverchan3Sender",
31
+ "SlackSender",
32
+ "TelegramSender",
33
+ "WebhookSender",
34
+ "WechatSender",
35
+ ]
@@ -0,0 +1,43 @@
1
+ """Apprise sender: one field that reaches 100+ services (LINE, Viber,
2
+ Mastodon, Zulip, Home Assistant, Rocket.Chat, SMS gateways, ...) through
3
+ Apprise URLs - see https://github.com/caronc/apprise/wiki.
4
+
5
+ Optional dependency: `pip install apprise`. Without it the channel is
6
+ reported as not configured (and logged) rather than failing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger
14
+
15
+
16
+ class AppriseSender:
17
+ def __init__(self, config):
18
+ self._urls = [u for u in (getattr(config, "apprise_urls", None) or []) if u]
19
+
20
+ def is_configured(self) -> bool:
21
+ if not self._urls:
22
+ return False
23
+ try:
24
+ import apprise # noqa: F401
25
+ except ImportError:
26
+ logger.warning("apprise_urls is set but the apprise package isn't installed (pip install apprise)")
27
+ return False
28
+ return True
29
+
30
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
31
+ if not self.is_configured():
32
+ return False
33
+ import apprise
34
+
35
+ client = apprise.Apprise()
36
+ for url in self._urls:
37
+ if not client.add(url):
38
+ logger.error("Apprise rejected URL (check its format): %s", url.split("://")[0] + "://…")
39
+ try:
40
+ return bool(client.notify(body=message, title=title or DEFAULT_TITLE))
41
+ except Exception as exc:
42
+ logger.error("Apprise send failed: %s", exc)
43
+ return False
@@ -0,0 +1,47 @@
1
+ """Self-hosted AstrBot webhook notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import time
9
+ from typing import Optional
10
+
11
+ import requests
12
+
13
+ from .base import DEFAULT_TIMEOUT, logger
14
+
15
+
16
+ class AstrbotSender:
17
+ def __init__(self, config):
18
+ self._url = getattr(config, "astrbot_url", None)
19
+ self._token = getattr(config, "astrbot_token", None)
20
+
21
+ def is_configured(self) -> bool:
22
+ return bool(self._url)
23
+
24
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
25
+ if not self.is_configured():
26
+ return False
27
+
28
+ payload = {"content": f"{title}\n\n{message}" if title else message}
29
+ headers = {"Content-Type": "application/json"}
30
+
31
+ if self._token:
32
+ timestamp = str(int(time.time()))
33
+ payload_json = json.dumps(payload, sort_keys=True)
34
+ sign_data = f"{timestamp}.{payload_json}".encode("utf-8")
35
+ signature = hmac.new(self._token.encode("utf-8"), sign_data, hashlib.sha256).hexdigest()
36
+ headers["X-Signature"] = signature
37
+ headers["X-Timestamp"] = timestamp
38
+
39
+ try:
40
+ resp = requests.post(self._url, json=payload, headers=headers, timeout=timeout)
41
+ if resp.status_code == 200:
42
+ return True
43
+ logger.error("AstrBot send failed: HTTP %s %s", resp.status_code, resp.text[:200])
44
+ return False
45
+ except requests.RequestException as exc:
46
+ logger.error("AstrBot send failed: %s", exc)
47
+ return False
@@ -0,0 +1,24 @@
1
+ """Bark iOS push sender (https://github.com/Finb/Bark): the device URL
2
+ shown in the Bark app, e.g. https://api.day.app/yourkey."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, http_ok, truncate
9
+
10
+ BARK_MAX_LENGTH = 3500
11
+
12
+
13
+ class BarkSender:
14
+ def __init__(self, config):
15
+ self._url = (getattr(config, "bark_url", None) or "").rstrip("/")
16
+
17
+ def is_configured(self) -> bool:
18
+ return bool(self._url)
19
+
20
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
21
+ if not self.is_configured():
22
+ return False
23
+ payload = {"title": title or DEFAULT_TITLE, "body": truncate(message, BARK_MAX_LENGTH), "group": "MarketMyna"}
24
+ return http_ok("Bark", "POST", self._url, json=payload, timeout=timeout)
@@ -0,0 +1,80 @@
1
+ """Shared helpers for notification channel senders.
2
+
3
+ Each sender follows the same small contract: ``is_configured()`` and
4
+ ``send(message, title=None, timeout=10.0) -> bool``. A channel that fails
5
+ must return False rather than raise, so one broken channel never blocks
6
+ the others (see NotificationDispatcher).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Callable
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ DEFAULT_TITLE = "MarketMyna Alert"
17
+ DEFAULT_TIMEOUT = 10.0
18
+
19
+
20
+ TRUNCATION_NOTE = "\n…\n(Shortened to fit this channel. Open MarketMyna for the full report.)"
21
+
22
+
23
+ def utf8_len(text: str) -> int:
24
+ return len(text.encode("utf-8"))
25
+
26
+
27
+ def truncate(text: str, max_length: int, measure: Callable[[str], int] = len) -> str:
28
+ """Cap message length for platforms with a hard limit, saying so when it
29
+ cuts (so a reader knows sections are missing). `measure` = utf8_len for
30
+ platforms that count bytes."""
31
+ if measure(text) <= max_length:
32
+ return text
33
+ note = TRUNCATION_NOTE if max_length >= 400 else "…"
34
+ budget = max_length - measure(note)
35
+ cut = text[:budget]
36
+ while cut and measure(cut) > budget: # multi-byte characters
37
+ cut = cut[: max(1, len(cut) - max(1, (measure(cut) - budget) // 2))]
38
+ cut = cut.rsplit("\n", 1)[0] if "\n" in cut[-200:] and note != "…" else cut # end on a whole line
39
+ return cut.rstrip() + note
40
+
41
+
42
+ def split_message(text: str, limit: int, measure: Callable[[str], int] = len) -> list[str]:
43
+ """Split on line boundaries into chunks of at most `limit` (chars, or
44
+ bytes with measure=utf8_len); a single over-long line is hard-cut."""
45
+ chunks: list[str] = []
46
+ current = ""
47
+ for line in text.split("\n"):
48
+ while measure(line) > limit:
49
+ if current:
50
+ chunks.append(current)
51
+ current = ""
52
+ piece = line[:limit]
53
+ while measure(piece) > limit:
54
+ piece = piece[:-1]
55
+ chunks.append(piece)
56
+ line = line[len(piece):]
57
+ candidate = f"{current}\n{line}" if current else line
58
+ if measure(candidate) > limit:
59
+ chunks.append(current)
60
+ current = line
61
+ else:
62
+ current = candidate
63
+ if current:
64
+ chunks.append(current)
65
+ return chunks or [""]
66
+
67
+
68
+ def http_ok(channel: str, method: str, url: str, *, timeout: float = DEFAULT_TIMEOUT, **kwargs) -> bool:
69
+ """One HTTP call for a sender: True on 2xx, False (logged) otherwise."""
70
+ import requests
71
+
72
+ try:
73
+ resp = requests.request(method, url, timeout=timeout, **kwargs)
74
+ except requests.RequestException as exc:
75
+ logger.error("%s send failed: %s", channel, exc)
76
+ return False
77
+ if 200 <= resp.status_code < 300:
78
+ return True
79
+ logger.error("%s send failed: HTTP %s %s", channel, resp.status_code, resp.text[:200])
80
+ return False
@@ -0,0 +1,54 @@
1
+ """DingTalk custom robot webhook notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import time
9
+ import urllib.parse
10
+ from typing import Optional
11
+
12
+ import requests
13
+
14
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger, truncate, utf8_len
15
+
16
+ DINGTALK_MAX_LENGTH = 19000
17
+
18
+
19
+ class DingtalkSender:
20
+ def __init__(self, config):
21
+ self._webhook_url = getattr(config, "dingtalk_webhook_url", None)
22
+ self._secret = getattr(config, "dingtalk_secret", None)
23
+
24
+ def is_configured(self) -> bool:
25
+ return bool(self._webhook_url)
26
+
27
+ def _signed_url(self) -> str:
28
+ if not self._secret:
29
+ return self._webhook_url
30
+
31
+ timestamp = str(round(time.time() * 1000))
32
+ string_to_sign = f"{timestamp}\n{self._secret}".encode("utf-8")
33
+ hmac_code = hmac.new(self._secret.encode("utf-8"), string_to_sign, digestmod=hashlib.sha256).digest()
34
+ sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
35
+ joiner = "&" if "?" in self._webhook_url else "?"
36
+ return f"{self._webhook_url}{joiner}timestamp={timestamp}&sign={sign}"
37
+
38
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
39
+ if not self.is_configured():
40
+ return False
41
+
42
+ text = truncate(f"### {title or DEFAULT_TITLE}\n\n{message}", DINGTALK_MAX_LENGTH, measure=utf8_len) # 20000-byte cap
43
+ payload = {"msgtype": "markdown", "markdown": {"title": title or DEFAULT_TITLE, "text": text}}
44
+
45
+ try:
46
+ resp = requests.post(self._signed_url(), json=payload, timeout=timeout)
47
+ result = resp.json()
48
+ if result.get("errcode") == 0:
49
+ return True
50
+ logger.error("DingTalk send failed: %s", result)
51
+ return False
52
+ except (requests.RequestException, ValueError) as exc:
53
+ logger.error("DingTalk send failed: %s", exc)
54
+ return False
@@ -0,0 +1,39 @@
1
+ """Discord Incoming Webhook notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Optional
7
+
8
+ import requests
9
+
10
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger, split_message
11
+
12
+ DISCORD_MAX_LENGTH = 2000
13
+
14
+
15
+ class DiscordSender:
16
+ def __init__(self, config):
17
+ self._webhook_url = getattr(config, "discord_webhook_url", None)
18
+
19
+ def is_configured(self) -> bool:
20
+ return bool(self._webhook_url)
21
+
22
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
23
+ if not self.is_configured():
24
+ return False
25
+
26
+ text = f"**{title or DEFAULT_TITLE}**\n{message}"
27
+ # Long reports go out as several messages instead of being cut off.
28
+ for i, chunk in enumerate(split_message(text, DISCORD_MAX_LENGTH)):
29
+ if i:
30
+ time.sleep(0.5) # webhook rate limit: 5 requests / 2 s
31
+ try:
32
+ resp = requests.post(self._webhook_url, json={"content": chunk}, timeout=timeout)
33
+ except requests.RequestException as exc:
34
+ logger.error("Discord send failed: %s", exc)
35
+ return False
36
+ if not 200 <= resp.status_code < 300:
37
+ logger.error("Discord send failed: HTTP %s %s", resp.status_code, resp.text[:200])
38
+ return False
39
+ return True