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,119 @@
1
+ """Browser push notification endpoints: the server's VAPID public key,
2
+ device (subscription) management and a test push."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+ from urllib.parse import urlsplit
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Response
10
+ from pydantic import BaseModel, Field
11
+ from sqlalchemy import or_
12
+
13
+ from marketmyna.api.deps import get_config_dep
14
+ from marketmyna.core import tenant
15
+ from marketmyna.notifiers.webpush import WebPushSender, ensure_vapid_key, vapid_public_key
16
+ from marketmyna.storage import DatabaseManager, WebPushSubscriptionRecord, get_db_url
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ class SubscriptionKeys(BaseModel):
22
+ p256dh: str = Field(min_length=10)
23
+ auth: str = Field(min_length=4)
24
+
25
+
26
+ class SubscriptionBody(BaseModel):
27
+ endpoint: str = Field(min_length=10)
28
+ keys: SubscriptionKeys
29
+ label: str = ""
30
+
31
+
32
+ class EndpointBody(BaseModel):
33
+ endpoint: str
34
+
35
+
36
+ def _db(config):
37
+ return DatabaseManager.instance(get_db_url(config=config))
38
+
39
+
40
+ def _device(record: WebPushSubscriptionRecord) -> dict:
41
+ return {
42
+ "id": record.id,
43
+ "label": record.label or "Browser",
44
+ "service": urlsplit(record.endpoint).netloc,
45
+ "created_at": record.created_at.isoformat() if record.created_at else None,
46
+ "last_success_at": record.last_success_at.isoformat() if record.last_success_at else None,
47
+ }
48
+
49
+
50
+ @router.get("/push/vapid-public-key")
51
+ def get_vapid_public_key(config=Depends(get_config_dep)) -> dict:
52
+ return {"public_key": vapid_public_key(ensure_vapid_key(config))}
53
+
54
+
55
+ @router.get("/push/subscriptions")
56
+ def list_subscriptions(config=Depends(get_config_dep)) -> list[dict]:
57
+ with _db(config).get_session() as session:
58
+ records = session.query(WebPushSubscriptionRecord).order_by(WebPushSubscriptionRecord.id).all()
59
+ return [_device(r) for r in records]
60
+
61
+
62
+ @router.post("/push/subscriptions", status_code=201)
63
+ def subscribe(body: SubscriptionBody, config=Depends(get_config_dep)) -> dict:
64
+ if not body.endpoint.startswith("https://"):
65
+ raise HTTPException(status_code=400, detail="Push endpoint must be https")
66
+ ensure_vapid_key(config)
67
+ with _db(config).get_session() as session:
68
+ if tenant.current_user_id() is not None:
69
+ # Accounts mode: a browser belongs to whoever subscribed on it last
70
+ # (endpoints are unique across users).
71
+ with tenant.system_scope():
72
+ session.query(WebPushSubscriptionRecord).filter(
73
+ WebPushSubscriptionRecord.endpoint == body.endpoint,
74
+ or_(WebPushSubscriptionRecord.user_id.is_(None),
75
+ WebPushSubscriptionRecord.user_id != tenant.current_user_id()),
76
+ ).delete(synchronize_session=False)
77
+ record = (
78
+ session.query(WebPushSubscriptionRecord)
79
+ .filter(WebPushSubscriptionRecord.endpoint == body.endpoint)
80
+ .first()
81
+ )
82
+ if record is None:
83
+ record = WebPushSubscriptionRecord(endpoint=body.endpoint)
84
+ session.add(record)
85
+ record.p256dh = body.keys.p256dh
86
+ record.auth = body.keys.auth
87
+ record.label = body.label[:80]
88
+ session.flush()
89
+ return _device(record)
90
+
91
+
92
+ @router.post("/push/unsubscribe", status_code=204)
93
+ def unsubscribe(body: EndpointBody, config=Depends(get_config_dep)) -> Response:
94
+ with _db(config).get_session() as session:
95
+ session.query(WebPushSubscriptionRecord).filter(
96
+ WebPushSubscriptionRecord.endpoint == body.endpoint
97
+ ).delete(synchronize_session=False)
98
+ return Response(status_code=204)
99
+
100
+
101
+ @router.delete("/push/subscriptions/{sub_id}", status_code=204)
102
+ def delete_subscription(sub_id: int, config=Depends(get_config_dep)) -> Response:
103
+ with _db(config).get_session() as session:
104
+ deleted = session.query(WebPushSubscriptionRecord).filter(WebPushSubscriptionRecord.id == sub_id).delete(
105
+ synchronize_session=False
106
+ )
107
+ if not deleted:
108
+ raise HTTPException(status_code=404, detail="Device not found")
109
+ return Response(status_code=204)
110
+
111
+
112
+ @router.post("/push/test")
113
+ def test_push(config=Depends(get_config_dep), url: Optional[str] = None) -> dict:
114
+ sender = WebPushSender(config)
115
+ if not sender.is_configured():
116
+ return {"sent": False, "message": "No device has enabled push notifications yet."}
117
+ sent = sender.send("Push notifications are working. Reports and alerts will arrive here.",
118
+ title="MarketMyna test", url=url or "/")
119
+ return {"sent": sent, "message": "Sent" if sent else "Every device rejected the push - see server logs."}
@@ -0,0 +1,49 @@
1
+ """Saved smart reports: the full table behind a smart message's link.
2
+
3
+ A report is addressed only by its random token (unguessable, like a share
4
+ link), so the /r/<token> page works for anyone the link was sent to."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+
10
+ from marketmyna.api.deps import get_config_dep
11
+ from marketmyna.storage import DatabaseManager, ReportRunRecord, get_db_url
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ def _summary(rec: ReportRunRecord) -> dict:
17
+ return {
18
+ "token": rec.token,
19
+ "title": rec.title,
20
+ "created_at": rec.created_at.isoformat() if rec.created_at else None,
21
+ "stock_count": rec.stock_count,
22
+ }
23
+
24
+
25
+ @router.get("/reports")
26
+ def list_reports(limit: int = 20, config=Depends(get_config_dep)) -> list[dict]:
27
+ db = DatabaseManager.instance(get_db_url(config=config))
28
+ with db.get_session() as session:
29
+ recs = (
30
+ session.query(ReportRunRecord)
31
+ .order_by(ReportRunRecord.created_at.desc(), ReportRunRecord.id.desc())
32
+ .limit(max(1, min(100, limit)))
33
+ .all()
34
+ )
35
+ return [_summary(r) for r in recs]
36
+
37
+
38
+ @router.get("/reports/{token}")
39
+ def get_report(token: str, config=Depends(get_config_dep)) -> dict:
40
+ db = DatabaseManager.instance(get_db_url(config=config))
41
+ with db.get_session() as session:
42
+ rec = session.query(ReportRunRecord).filter_by(token=token).first()
43
+ if rec is None:
44
+ raise HTTPException(status_code=404, detail="Report not found")
45
+ from marketmyna.services.smart_report import without_calls
46
+
47
+ # Reports saved before calls were removed still hold them: strip on the way out.
48
+ rows = [r if "reading" in r or "error" in r else without_calls(r) for r in rec.rows or []]
49
+ return {**_summary(rec), "rows": rows, "meta": {**(rec.meta or {}), "compliance": "analytics"}}
@@ -0,0 +1,219 @@
1
+ """Runtime scheduler endpoints: persisted schedule config, status, and a
2
+ manual "run now" that shares the exact same run path
3
+ (RuntimeSchedulerService.run_now()) the background timer loop uses -
4
+ there is only one "do a run" code path in the whole service."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Request
11
+ from pydantic import BaseModel
12
+
13
+ from marketmyna.api.deps import get_config_dep, get_scheduler_service
14
+ from marketmyna.core.compliance import mode as compliance_mode
15
+ from marketmyna.llm.channels import llm_configured
16
+ from marketmyna.notifiers.dispatcher import NotificationDispatcher
17
+ from marketmyna.services import schedule_time
18
+ from marketmyna.services.report import DEFAULT_SECTIONS, SECTION_AREAS, SECTIONS, clean_sections
19
+ from marketmyna.services.report_i18n import BUILTIN_LANGUAGES, LANGUAGES
20
+ from marketmyna.services.schedule_config import (
21
+ SMART_FILE_CHANNELS,
22
+ SMART_FILES,
23
+ SOURCES,
24
+ ScheduleConfig,
25
+ ScheduleConfigStore,
26
+ resolve_targets,
27
+ source_catalog,
28
+ )
29
+
30
+ router = APIRouter()
31
+
32
+
33
+ class ScheduleConfigUpdate(BaseModel):
34
+ enabled: Optional[bool] = None
35
+ interval_minutes: Optional[int] = None
36
+ time_of_day: Optional[str] = None
37
+ symbols: Optional[List[str]] = None
38
+ market: Optional[str] = None
39
+ mode: Optional[str] = None
40
+ report_language: Optional[str] = None
41
+ report_sections: Optional[List[str]] = None
42
+ stock_sections: Optional[Dict[str, List[str]]] = None
43
+ channel_languages: Optional[Dict[str, str]] = None
44
+ mf_codes: Optional[List[str]] = None
45
+ smart_enabled: Optional[bool] = None
46
+ smart_top_n: Optional[int] = None
47
+ smart_ai_top: Optional[int] = None
48
+ smart_email_full: Optional[bool] = None
49
+ smart_file: Optional[str] = None
50
+ smart_file_channels: Optional[List[str]] = None
51
+ report_base_url: Optional[str] = None
52
+ sources: Optional[List[str]] = None
53
+ watchlist_ids: Optional[List[int]] = None
54
+ portfolio_names: Optional[List[str]] = None
55
+
56
+
57
+ def _to_dict(cfg: ScheduleConfig, config=None) -> dict[str, Any]:
58
+ return {
59
+ "enabled": cfg.enabled,
60
+ "interval_minutes": cfg.interval_minutes,
61
+ "time_of_day": cfg.time_of_day,
62
+ "symbols": cfg.symbols,
63
+ "market": cfg.market,
64
+ "mode": cfg.mode,
65
+ "last_run_at": cfg.last_run_at.isoformat() if cfg.last_run_at else None,
66
+ "last_run_status": cfg.last_run_status,
67
+ "last_run_summary": cfg.last_run_summary,
68
+ "report_language": cfg.report_language or "en",
69
+ "report_sections": cfg.report_sections if cfg.report_sections is not None else list(DEFAULT_SECTIONS),
70
+ "stock_sections": cfg.stock_sections or {},
71
+ "channel_languages": cfg.channel_languages or {},
72
+ "mf_codes": cfg.mf_codes or [],
73
+ "smart_enabled": bool(cfg.smart_enabled),
74
+ "smart_top_n": cfg.smart_top_n or 5,
75
+ "smart_ai_top": cfg.smart_ai_top if cfg.smart_ai_top is not None else 10,
76
+ "smart_email_full": cfg.smart_email_full if cfg.smart_email_full is not None else True,
77
+ "smart_file": cfg.smart_file if cfg.smart_file in SMART_FILES else "pdf",
78
+ "smart_file_channels": list(cfg.smart_file_channels if cfg.smart_file_channels is not None
79
+ else SMART_FILE_CHANNELS),
80
+ "report_base_url": cfg.report_base_url or "",
81
+ "sources": cfg.run_sources(),
82
+ "watchlist_ids": cfg.watchlist_ids or [],
83
+ "portfolio_names": cfg.portfolio_names or [],
84
+ "stock_count": cfg.stock_count,
85
+ "area": cfg.area,
86
+ }
87
+
88
+
89
+ def _stock_count(cfg: ScheduleConfig, config) -> int:
90
+ """Stocks the run will cover."""
91
+ try:
92
+ return len(resolve_targets(cfg, config))
93
+ except Exception:
94
+ return cfg.stock_count or 0
95
+
96
+
97
+ @router.get("/scheduler/config")
98
+ def get_schedule_config(config=Depends(get_config_dep)) -> dict:
99
+ return _to_dict(ScheduleConfigStore(config=config).load(), config)
100
+
101
+
102
+ def _session_area(request: Request, config) -> Optional[str]:
103
+ """The area ("trader" / "investor") the owner is working in, or None."""
104
+ if not getattr(config, "accounts_enabled", False):
105
+ return None
106
+ from marketmyna.api.middlewares.auth import accounts_db, current_user_from_request
107
+
108
+ with accounts_db(config).get_session() as session:
109
+ user = current_user_from_request(request, session)
110
+ return user.last_area if user is not None else None
111
+
112
+
113
+ @router.put("/scheduler/config")
114
+ def update_schedule_config(body: ScheduleConfigUpdate, request: Request, config=Depends(get_config_dep)) -> dict:
115
+ overrides = body.model_dump(exclude_unset=True)
116
+ # The report follows the area it was set up in (its sections only).
117
+ area = _session_area(request, config)
118
+ if area:
119
+ overrides["area"] = area
120
+ if overrides.get("time_of_day"):
121
+ try:
122
+ hour, minute = schedule_time.parse_time(overrides["time_of_day"])
123
+ except ValueError:
124
+ raise HTTPException(status_code=400, detail="Use a 24-hour time such as 08:45 or 18:30.")
125
+ overrides["time_of_day"] = f"{hour:02d}:{minute:02d}"
126
+ if overrides.get("interval_minutes") is not None and overrides["interval_minutes"] < 1:
127
+ raise HTTPException(status_code=400, detail="Repeat every 1 minute or more.")
128
+ if "report_language" in overrides and overrides["report_language"] not in LANGUAGES:
129
+ raise HTTPException(status_code=400, detail=f"Unknown language '{overrides['report_language']}'")
130
+ if overrides.get("report_sections") is not None:
131
+ overrides["report_sections"] = clean_sections(overrides["report_sections"])
132
+ if overrides.get("stock_sections") is not None:
133
+ overrides["stock_sections"] = {
134
+ sym.strip().upper(): clean_sections(secs) for sym, secs in overrides["stock_sections"].items() if sym.strip()
135
+ }
136
+ if overrides.get("channel_languages") is not None:
137
+ bad = [lang for lang in overrides["channel_languages"].values() if lang and lang not in LANGUAGES]
138
+ if bad:
139
+ raise HTTPException(status_code=400, detail=f"Unknown language '{bad[0]}'")
140
+ overrides["channel_languages"] = {ch: lang for ch, lang in overrides["channel_languages"].items() if lang}
141
+ if overrides.get("mf_codes") is not None:
142
+ overrides["mf_codes"] = [c.strip() for c in overrides["mf_codes"] if c.strip().isdigit()]
143
+ if overrides.get("smart_top_n") is not None:
144
+ overrides["smart_top_n"] = max(1, min(20, overrides["smart_top_n"]))
145
+ if overrides.get("smart_ai_top") is not None:
146
+ overrides["smart_ai_top"] = max(0, min(50, overrides["smart_ai_top"]))
147
+ if overrides.get("smart_file") is not None and overrides["smart_file"] not in SMART_FILES:
148
+ raise HTTPException(status_code=400, detail="Report file must be pdf, xlsx or none.")
149
+ if overrides.get("smart_file_channels") is not None:
150
+ overrides["smart_file_channels"] = [c for c in SMART_FILE_CHANNELS if c in overrides["smart_file_channels"]]
151
+ if "report_base_url" in overrides:
152
+ url = (overrides["report_base_url"] or "").strip().rstrip("/")
153
+ if url and not url.startswith(("http://", "https://")):
154
+ raise HTTPException(status_code=400, detail="Public address must start with http:// or https://")
155
+ overrides["report_base_url"] = url or None
156
+ if overrides.get("sources") is not None:
157
+ overrides["sources"] = [s for s in SOURCES if s in overrides["sources"]]
158
+ store = ScheduleConfigStore(config=config)
159
+ after = store.load()
160
+ for key, value in overrides.items():
161
+ setattr(after, key, value)
162
+ after.stock_count = _stock_count(after, config)
163
+ store.save(after)
164
+ return _to_dict(after, config)
165
+
166
+
167
+ @router.get("/scheduler/status")
168
+ def get_scheduler_status(scheduler=Depends(get_scheduler_service), config=Depends(get_config_dep)) -> dict:
169
+ cfg = ScheduleConfigStore(config=config).load()
170
+ return {"running": scheduler.is_running(), "schedule": _to_dict(cfg, config)}
171
+
172
+
173
+ @router.post("/scheduler/run-now")
174
+ def run_now(request: Request, scheduler=Depends(get_scheduler_service), config=Depends(get_config_dep)) -> dict:
175
+ """Starts an immediate out-of-band run in the background, independent of
176
+ the schedule/timer - same watchdog-guarded child-process path and
177
+ notification push as a normal scheduled run. GET /scheduler/run-now
178
+ tells when it's done."""
179
+ area = _session_area(request, config)
180
+ store = ScheduleConfigStore(config=config)
181
+ if area and store.load().area != area:
182
+ store.update(area=area) # "Run now" from the investor area gives the investor report
183
+ if not scheduler.start_run_now():
184
+ raise HTTPException(status_code=409, detail="Your report is already running.")
185
+ return {"started": True}
186
+
187
+
188
+ @router.get("/scheduler/run-now")
189
+ def run_now_state(scheduler=Depends(get_scheduler_service)) -> dict:
190
+ """{"running": bool, "result": the last Run now's outcome or None}."""
191
+ return scheduler.run_now_state()
192
+
193
+
194
+ @router.get("/scheduler/sources")
195
+ def run_sources(config=Depends(get_config_dep)) -> dict:
196
+ """Watchlists and portfolios a run can cover, with their symbols."""
197
+ return source_catalog(config)
198
+
199
+
200
+ @router.get("/scheduler/report-options")
201
+ def report_options(config=Depends(get_config_dep)) -> dict:
202
+ """Everything the Scheduler page needs to build the report settings UI."""
203
+ dispatcher = NotificationDispatcher(config)
204
+ return {
205
+ "languages": [
206
+ {"code": code, "native": native, "english": english, "builtin": code in BUILTIN_LANGUAGES}
207
+ for code, (native, english) in LANGUAGES.items()
208
+ ],
209
+ "sections": [
210
+ {"id": sid, "label": label, "description": desc, "default": default,
211
+ # "trader" / "investor": shown only in that area of the app (None = both)
212
+ "area": SECTION_AREAS.get(sid)}
213
+ for sid, label, desc, default in SECTIONS
214
+ ],
215
+ "channels": dispatcher.configured_channels("report"),
216
+ "llm_configured": llm_configured(config),
217
+ # Always "analytics": no calls; Smart messaging shows readings
218
+ "compliance": compliance_mode(),
219
+ }