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,116 @@
1
+ """Single-user build: there is no login. Every request runs as the one
2
+ local owner, an admin account made on first use (accounts.ensure_local_owner),
3
+ so the per-user data scoping underneath works unchanged. Run it only where
4
+ you alone can reach it (localhost, a VPN, or behind your own proxy auth).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import threading
12
+
13
+ import anyio
14
+ from fastapi import APIRouter, Request
15
+ from starlette.types import ASGIApp, Receive, Scope, Send
16
+
17
+ from marketmyna.core import tenant
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ OPEN_PATHS = ("/health", "/api/health", "/api/auth/")
22
+
23
+
24
+ def accounts_config():
25
+ """The app Config the owner lookup uses. Tests patch this."""
26
+ from marketmyna.api.deps import get_config_dep
27
+
28
+ return get_config_dep()
29
+
30
+
31
+ def accounts_db(config):
32
+ from marketmyna.storage import DatabaseManager, get_db_url
33
+
34
+ return DatabaseManager.instance(get_db_url(config=config))
35
+
36
+
37
+ _owner_ids: dict[str, int] = {}
38
+ _owner_lock = threading.Lock()
39
+
40
+
41
+ def local_owner_id(config) -> int:
42
+ """Id of the local owner account, made on first use (cached per database)."""
43
+ from marketmyna.storage import get_db_url
44
+
45
+ url = get_db_url(config=config)
46
+ with _owner_lock:
47
+ if url not in _owner_ids:
48
+ from marketmyna.services.accounts import ensure_local_owner
49
+
50
+ with accounts_db(config).get_session() as session, tenant.system_scope():
51
+ _owner_ids[url] = ensure_local_owner(session).id
52
+ return _owner_ids[url]
53
+
54
+
55
+ def _session_user(config, raw: str | None):
56
+ """(user_id, role): always the local owner."""
57
+ return local_owner_id(config), "admin"
58
+
59
+
60
+ def current_user_from_request(request: Request, session):
61
+ """The local owner's UserRecord (in the caller's DB session)."""
62
+ from marketmyna.storage import UserRecord
63
+
64
+ return session.get(UserRecord, local_owner_id(accounts_config()))
65
+
66
+
67
+ def api_docs_enabled() -> bool:
68
+ """/docs, /redoc and /openapi.json exist only with API_DOCS=1."""
69
+ return os.environ.get("API_DOCS", "").strip() == "1"
70
+
71
+
72
+ def check_auth_config() -> None:
73
+ """Turns per-user scoping on (every request runs as the local owner)."""
74
+ from marketmyna.api.deps import get_config_dep
75
+
76
+ get_config_dep().accounts_enabled = True
77
+
78
+
79
+ def is_authenticated(scope: Scope) -> bool:
80
+ """Always true: the request runs as the local owner."""
81
+ state = scope.setdefault("state", {})
82
+ state["user_id"], state["user_role"] = _session_user(accounts_config(), None)
83
+ return True
84
+
85
+
86
+ def require_admin(request: Request) -> None:
87
+ """Dependency kept on admin routes (AI configuration, integrations). The
88
+ local owner is the admin, so it always passes."""
89
+
90
+
91
+ class AuthMiddleware:
92
+ """Puts every request in the owner's data scope. Pure ASGI (not
93
+ BaseHTTPMiddleware) so SSE streams pass through untouched."""
94
+
95
+ def __init__(self, app: ASGIApp) -> None:
96
+ self.app = app
97
+
98
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
99
+ if scope["type"] not in ("http", "websocket") or scope.get("path", "").startswith(OPEN_PATHS):
100
+ await self.app(scope, receive, send)
101
+ return
102
+ # The owner lookup hits the database on first use: keep it off the event loop.
103
+ await anyio.to_thread.run_sync(is_authenticated, scope)
104
+ token = tenant.set_scope(scope["state"]["user_id"])
105
+ try:
106
+ await self.app(scope, receive, send)
107
+ finally:
108
+ tenant.reset_scope(token)
109
+
110
+
111
+ router = APIRouter()
112
+
113
+
114
+ @router.get("/status")
115
+ def auth_status(request: Request) -> dict:
116
+ return {"authenticated": is_authenticated(request.scope)}
@@ -0,0 +1,66 @@
1
+ """Refuse cross-site writes to the API (CSRF defence in depth).
2
+
3
+ The session is an HttpOnly cookie (marketmyna/api/middlewares/auth.py) with
4
+ SameSite=Lax, which already stops other sites' forms and fetches from
5
+ sending it on POST. This adds a second, header-based check so a cookie
6
+ policy change, an old browser or a sibling subdomain can't be used to make
7
+ a signed-in user's browser change their account, alerts or keys.
8
+
9
+ For POST / PUT / PATCH / DELETE under /api/:
10
+ - Sec-Fetch-Site (sent by every current browser) must be "same-origin" or
11
+ "none"; anything else needs an Origin on the allowed list.
12
+ - Without Sec-Fetch-Site, an Origin header, if present, must match the
13
+ request's own host or the allowed list.
14
+ - No browser headers at all (server-to-server, tests, curl) passes: that
15
+ caller has no victim's cookie to ride on.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ from urllib.parse import urlsplit
23
+
24
+ from starlette.types import ASGIApp, Receive, Scope, Send
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
29
+ SKIP_PREFIXES: tuple[str, ...] = ()
30
+
31
+
32
+ class CSRFMiddleware:
33
+ def __init__(self, app: ASGIApp, allowed_origins: list[str] | None = None) -> None:
34
+ self.app = app
35
+ self.allowed = {o.rstrip("/").lower() for o in (allowed_origins or [])}
36
+
37
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
38
+ if scope["type"] == "http" and not self._allowed(scope):
39
+ logger.warning("CSRF: refused %s %s", scope.get("method"), scope.get("path"))
40
+ body = json.dumps({"detail": "Cross-site request refused"}).encode()
41
+ await send({"type": "http.response.start", "status": 403,
42
+ "headers": [(b"content-type", b"application/json"),
43
+ (b"content-length", str(len(body)).encode())]})
44
+ await send({"type": "http.response.body", "body": body})
45
+ return
46
+ await self.app(scope, receive, send)
47
+
48
+ def _allowed(self, scope: Scope) -> bool:
49
+ path = scope.get("path", "")
50
+ if scope.get("method") not in UNSAFE_METHODS or not path.startswith("/api/"):
51
+ return True
52
+ if path.startswith(SKIP_PREFIXES):
53
+ return True
54
+ headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
55
+ origin = (headers.get("origin") or "").rstrip("/").lower()
56
+ fetch_site = (headers.get("sec-fetch-site") or "").lower()
57
+ if fetch_site:
58
+ return fetch_site in ("same-origin", "none") or (origin != "" and origin in self.allowed)
59
+ if not origin or origin == "null":
60
+ # Some privacy setups send "Origin: null"; without Sec-Fetch-Site
61
+ # there's nothing better to go on, and SameSite=Lax still applies.
62
+ return True
63
+ if origin in self.allowed:
64
+ return True
65
+ host = (headers.get("x-forwarded-host") or headers.get("host") or "").split(",")[0].strip().lower()
66
+ return urlsplit(origin).netloc == host
@@ -0,0 +1,38 @@
1
+ """Uniform error envelope for the API: {"error", "message", "detail"}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from fastapi import FastAPI, HTTPException, Request
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.responses import JSONResponse
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def add_error_handlers(app: FastAPI) -> None:
15
+ @app.exception_handler(HTTPException)
16
+ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
17
+ return JSONResponse(
18
+ status_code=exc.status_code,
19
+ content={"error": "http_error", "message": str(exc.detail), "detail": None},
20
+ )
21
+
22
+ @app.exception_handler(RequestValidationError)
23
+ async def validation_exception_handler(
24
+ request: Request, exc: RequestValidationError
25
+ ) -> JSONResponse:
26
+ return JSONResponse(
27
+ status_code=422,
28
+ content={"error": "validation_error", "message": "Invalid request", "detail": exc.errors()},
29
+ )
30
+
31
+ @app.exception_handler(Exception)
32
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
33
+ logger.exception("Unhandled error on %s %s", request.method, request.url.path)
34
+ detail = str(exc) if logger.isEnabledFor(logging.DEBUG) else None
35
+ return JSONResponse(
36
+ status_code=500,
37
+ content={"error": "internal_error", "message": "Internal server error", "detail": detail},
38
+ )
@@ -0,0 +1,27 @@
1
+ """Treat /api/v1/foo/ the same as /api/v1/foo instead of redirecting.
2
+
3
+ FastAPI's default answer to a trailing-slash mismatch is a 307 to an
4
+ absolute URL built from the Host header and scheme it sees, which behind a
5
+ proxy can be the internal address. Stripping the slash before routing
6
+ avoids the redirect (and its extra round trip) entirely.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from starlette.types import ASGIApp, Receive, Scope, Send
12
+
13
+
14
+ class StripTrailingSlashMiddleware:
15
+ def __init__(self, app: ASGIApp) -> None:
16
+ self.app = app
17
+
18
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
19
+ if scope["type"] in ("http", "websocket"):
20
+ path = scope.get("path", "")
21
+ if len(path) > 1 and path.endswith("/"):
22
+ scope = dict(scope)
23
+ scope["path"] = path.rstrip("/") or "/"
24
+ raw = scope.get("raw_path")
25
+ if raw:
26
+ scope["raw_path"] = raw.rstrip(b"/") or b"/"
27
+ await self.app(scope, receive, send)
File without changes
File without changes
@@ -0,0 +1,166 @@
1
+ """The owner's profile, mounted at /api/auth/account. Single-user build: no
2
+ sign-up, login, plans or billing; every route works on the local owner
3
+ (marketmyna/api/middlewares/auth.py)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from datetime import datetime
9
+ from typing import Optional
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
12
+ from pydantic import BaseModel
13
+
14
+ from marketmyna.api.deps import get_config_dep
15
+ from marketmyna.api.middlewares.auth import accounts_db, current_user_from_request
16
+ from marketmyna.core.compliance import mode_for as compliance_mode_for
17
+ from marketmyna.services import accounts
18
+ from marketmyna.services.accounts import AccountError
19
+ from marketmyna.storage import UserRecord
20
+
21
+ router = APIRouter()
22
+
23
+ # Everything is on for the owner; the frontend reads these shapes.
24
+ ALL_FEATURES_PLAN = {
25
+ "id": "all",
26
+ "name": "Self-hosted",
27
+ "description": "",
28
+ "ai_calls_month": None,
29
+ "limits": {"watchlist_symbols": None, "price_alerts": None, "scheduled_runs_day": None, "portfolios": None},
30
+ "features": {},
31
+ }
32
+
33
+
34
+ def me_dict(user: UserRecord) -> dict:
35
+ return {
36
+ "id": user.id,
37
+ "email": user.email,
38
+ "name": user.display_name or "",
39
+ "role": user.role,
40
+ # "trader" | "investor": the area the menus and pages follow; None shows both.
41
+ "area": user.last_area,
42
+ # IANA zone, e.g. Asia/Kolkata: scheduled report times are in this zone.
43
+ "timezone": user.timezone or "Asia/Kolkata",
44
+ "timezone_auto": user.timezone_auto is not False,
45
+ # Always 'analytics': no buy / sell calls, targets or stops (marketmyna/core/compliance.py).
46
+ "compliance": compliance_mode_for(user.id),
47
+ "plan": ALL_FEATURES_PLAN,
48
+ }
49
+
50
+
51
+ def _owner(request: Request, session) -> UserRecord:
52
+ user = current_user_from_request(request, session)
53
+ if user is None:
54
+ raise HTTPException(status_code=500, detail="The local owner account is missing.")
55
+ return user
56
+
57
+
58
+ @router.get("/me")
59
+ def me(request: Request, config=Depends(get_config_dep)) -> dict:
60
+ with accounts_db(config).get_session() as session:
61
+ return me_dict(_owner(request, session))
62
+
63
+
64
+ class ProfileRequest(BaseModel):
65
+ name: Optional[str] = None
66
+ timezone: Optional[str] = None
67
+ # True: the app keeps `timezone` equal to the device's zone (sent on each visit).
68
+ timezone_auto: Optional[bool] = None
69
+
70
+
71
+ @router.patch("/profile")
72
+ def update_profile(body: ProfileRequest, request: Request, config=Depends(get_config_dep)) -> dict:
73
+ from marketmyna.services.schedule_time import valid_zone
74
+
75
+ name = " ".join((body.name or "").split()) if body.name is not None else None
76
+ if name is not None and len(name) > 80:
77
+ raise HTTPException(status_code=400, detail="Keep the name under 80 characters.")
78
+ if body.timezone is not None and not valid_zone(body.timezone):
79
+ raise HTTPException(status_code=400, detail="Unknown time zone.")
80
+ with accounts_db(config).get_session() as session:
81
+ user = _owner(request, session)
82
+ if name is not None:
83
+ user.display_name = name
84
+ if body.timezone is not None:
85
+ user.timezone = body.timezone
86
+ if body.timezone_auto is not None:
87
+ user.timezone_auto = body.timezone_auto
88
+ session.flush()
89
+ return me_dict(user)
90
+
91
+
92
+ AREAS = ("trader", "investor")
93
+
94
+
95
+ class AreaRequest(BaseModel):
96
+ area: str
97
+
98
+
99
+ @router.post("/area")
100
+ def choose_area(body: AreaRequest, request: Request, config=Depends(get_config_dep)) -> dict:
101
+ """Switch between the Trader and Investor areas (kept on the account)."""
102
+ if body.area not in AREAS:
103
+ raise HTTPException(status_code=400, detail="Choose the Trader or the Investor area.")
104
+ with accounts_db(config).get_session() as session:
105
+ user = _owner(request, session)
106
+ user.last_area = body.area
107
+ session.flush()
108
+ return me_dict(user)
109
+
110
+
111
+ @router.get("/export")
112
+ def export_my_data(request: Request, config=Depends(get_config_dep)) -> Response:
113
+ """Everything the app stores for you as a JSON download. Credentials are
114
+ left out, see data_export.REDACT."""
115
+ from marketmyna.services.data_export import export_user
116
+
117
+ with accounts_db(config).get_session() as session:
118
+ user = _owner(request, session)
119
+ data = export_user(session, user.id)
120
+ name = f"marketmyna-data-{datetime.now():%Y-%m-%d}.json"
121
+ return Response(
122
+ content=json.dumps(data, ensure_ascii=False, indent=2),
123
+ media_type="application/json",
124
+ headers={"Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store"},
125
+ )
126
+
127
+
128
+ # ---- AI keys: paste a key, the provider and model are worked out from it
129
+
130
+
131
+ class ApiKeyRequest(BaseModel):
132
+ provider: Optional[str] = None # omitted: detected from the key
133
+ key: str # one key, or several separated by new lines, spaces or commas
134
+
135
+
136
+ def _keys(session, user: UserRecord, errors: Optional[list] = None) -> dict:
137
+ out = {"providers": list(accounts.BYOK_PROVIDERS), "keys": accounts.api_keys(session, user)}
138
+ if errors is not None:
139
+ out["errors"] = errors
140
+ return out
141
+
142
+
143
+ @router.get("/api-keys")
144
+ def list_api_keys(request: Request, config=Depends(get_config_dep)) -> dict:
145
+ with accounts_db(config).get_session() as session:
146
+ return _keys(session, _owner(request, session))
147
+
148
+
149
+ @router.put("/api-keys")
150
+ def put_api_key(body: ApiKeyRequest, request: Request, config=Depends(get_config_dep)) -> dict:
151
+ with accounts_db(config).get_session() as session:
152
+ user = _owner(request, session)
153
+ try:
154
+ errors = accounts.set_api_keys(session, config, user, body.provider, body.key)
155
+ except AccountError as exc:
156
+ raise HTTPException(status_code=exc.status, detail=exc.message)
157
+ return _keys(session, user, errors)
158
+
159
+
160
+ @router.delete("/api-keys/{key_id}")
161
+ def remove_api_key(key_id: str, request: Request, config=Depends(get_config_dep)) -> dict:
162
+ """key_id: a key's id, or a provider name (removes all its keys)."""
163
+ with accounts_db(config).get_session() as session:
164
+ user = _owner(request, session)
165
+ accounts.delete_api_key(session, user, key_id)
166
+ return _keys(session, user)
@@ -0,0 +1,114 @@
1
+ """AI agent analysis endpoints: the technical/risk/decision pipeline
2
+ (marketmyna/agent/orchestrator.py) over a configured LLM backend, exposed as
3
+ one synchronous call and one Server-Sent-Events streaming call."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextvars
8
+ import json
9
+ import queue
10
+ import threading
11
+ from typing import Any, Literal, Optional
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException
14
+ from fastapi.responses import StreamingResponse
15
+ from pydantic import BaseModel
16
+
17
+ from marketmyna.agent.context import AgentOpinion
18
+ from marketmyna.agent.dashboard import _public_opinion, build_dashboard
19
+ from marketmyna.agent.orchestrator import AgentOrchestrator
20
+ from marketmyna.api.deps import get_config_dep
21
+ from marketmyna.data.resilience import DataSourceUnavailable
22
+ from marketmyna.llm.channels import llm_configured
23
+ from marketmyna.llm.protocol import GenerationError
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ class AnalyzeRequest(BaseModel):
29
+ symbol: str
30
+ market: str = "us"
31
+ mode: str = "standard"
32
+ # The login area: "trader" gets a short-term trade plan, "investor" a long-term view.
33
+ area: Optional[Literal["trader", "investor"]] = None
34
+
35
+
36
+ NO_AI_MESSAGE = (
37
+ "No AI model is set up yet (no llm_model configured). Add an AI key under Settings -> AI keys, "
38
+ "or set LLM_MODEL for a local model. The data cards work without it."
39
+ )
40
+
41
+
42
+ def _require_ai(config) -> None:
43
+ """The last step (the AI summary) needs a model: refuse before the data steps run for nothing."""
44
+ if not llm_configured(config):
45
+ raise HTTPException(status_code=400, detail=NO_AI_MESSAGE)
46
+
47
+
48
+ @router.get("/agent/status")
49
+ def ai_status(config=Depends(get_config_dep)) -> dict:
50
+ """Whether Run AI can work: a saved AI key, or a model set in config / env (e.g. a local Ollama)."""
51
+ return {"configured": llm_configured(config)}
52
+
53
+
54
+ @router.post("/agent/analyze")
55
+ def analyze(body: AnalyzeRequest, config=Depends(get_config_dep)) -> dict:
56
+ _require_ai(config)
57
+ orchestrator = AgentOrchestrator(config=config)
58
+ try:
59
+ ctx = orchestrator.run(body.symbol, body.market, mode=body.mode, area=body.area)
60
+ except GenerationError as exc:
61
+ raise HTTPException(status_code=502, detail=f"LLM backend error: {exc}")
62
+ except DataSourceUnavailable as exc:
63
+ raise HTTPException(status_code=503, detail=f"Market data unavailable: {exc}")
64
+ except ValueError as exc:
65
+ raise HTTPException(status_code=400, detail=str(exc))
66
+ return build_dashboard(ctx)
67
+
68
+
69
+ def stream_agent_events(orchestrator: AgentOrchestrator, body: AnalyzeRequest):
70
+ """A plain generator (usable standalone, e.g. in tests, or wrapped in
71
+ a StreamingResponse) that relays pipeline progress as it actually
72
+ happens. AgentOrchestrator.run() is a single blocking call, so it runs
73
+ in a background thread; its on_stage callback pushes events onto a
74
+ queue this generator drains and yields as SSE `data:` lines, rather
75
+ than collecting everything and yielding it only after run() returns."""
76
+ events: "queue.Queue[Any]" = queue.Queue()
77
+ done = object()
78
+ # The worker runs in the request's context: the signed-in user's data
79
+ # scope and AI key (a bare thread would start empty).
80
+ context = contextvars.copy_context()
81
+
82
+ def on_stage(event: str, stage: str, payload: object) -> None:
83
+ data: dict = {"event": event, "agent": stage}
84
+ if event == "stage_done" and isinstance(payload, AgentOpinion):
85
+ # No per-agent buy / sell, target or stop, as in the dashboard.
86
+ data["opinion"] = _public_opinion(payload.to_dict())
87
+ elif payload is not None:
88
+ data["message"] = str(payload)
89
+ events.put(data)
90
+
91
+ def worker() -> None:
92
+ try:
93
+ ctx = orchestrator.run(body.symbol, body.market, mode=body.mode, on_stage=on_stage, area=body.area)
94
+ except Exception as exc: # noqa: BLE001 - the one place a critical failure surfaces to the client
95
+ events.put({"event": "error", "message": str(exc)})
96
+ else:
97
+ events.put({"event": "done", "dashboard": build_dashboard(ctx)})
98
+ finally:
99
+ events.put(done)
100
+
101
+ threading.Thread(target=context.run, args=(worker,), daemon=True).start()
102
+
103
+ while True:
104
+ item = events.get()
105
+ if item is done:
106
+ return
107
+ yield f"data: {json.dumps(item)}\n\n"
108
+
109
+
110
+ @router.post("/agent/analyze/stream")
111
+ def analyze_stream(body: AnalyzeRequest, config=Depends(get_config_dep)) -> StreamingResponse:
112
+ _require_ai(config)
113
+ orchestrator = AgentOrchestrator(config=config)
114
+ return StreamingResponse(stream_agent_events(orchestrator, body), media_type="text/event-stream")
@@ -0,0 +1,38 @@
1
+ """Platform-agnostic bot-command endpoint: exercises the same
2
+ CommandDispatcher a real chat platform listener uses, without needing
3
+ any chat platform at all - the web app's Bot console, or curl.
4
+
5
+ Open to every signed-in user: the auth middleware scopes each request to
6
+ its user, so /portfolio, /alerts and the rest see only their own rows, and
7
+ the AI commands go through the same plan quota as the Analyze page - the
8
+ same as a linked Telegram chat."""
9
+
10
+ from __future__ import annotations
11
+
12
+ from fastapi import APIRouter, Depends
13
+ from pydantic import BaseModel
14
+
15
+ from marketmyna.api.deps import get_bot_dispatcher
16
+ from marketmyna.bot.models import IncomingMessage
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ class BotCommandRequest(BaseModel):
22
+ text: str
23
+ platform: str = "http"
24
+ chat_id: str = "http"
25
+
26
+
27
+ @router.post("/bot/command")
28
+ def run_bot_command(body: BotCommandRequest, dispatcher=Depends(get_bot_dispatcher)) -> dict:
29
+ message = IncomingMessage(platform=body.platform, chat_id=body.chat_id, text=body.text)
30
+ result = dispatcher.dispatch(message)
31
+ return {"text": result.text, "success": result.success}
32
+
33
+
34
+ @router.get("/bot/commands")
35
+ def list_bot_commands(dispatcher=Depends(get_bot_dispatcher)) -> list[dict]:
36
+ """Every registered command with usage, description, group and an example
37
+ (the console's reference panel and "/" menu)."""
38
+ return [{**c.describe(), "group": group} for group, commands in dispatcher.groups().items() for c in commands]