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,400 @@
1
+ """Quote/history endpoints - thin wrappers over the existing marketmyna/data fetchers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, Response
8
+
9
+ from marketmyna.api.deps import get_config_dep
10
+ from marketmyna.core.constants import Markets, TimeFrame
11
+ from marketmyna.data.china import ChinaAFetcher, HongKongFetcher
12
+ from marketmyna.data.crypto import CryptoDataFetcher
13
+ from marketmyna.data.india import IndiaDataFetcher
14
+ from marketmyna.data.stocks import StockDataFetcher
15
+ from marketmyna.utils.deadline import call_with_deadline
16
+
17
+ router = APIRouter()
18
+
19
+ _FETCHERS = {
20
+ Markets.US: StockDataFetcher(),
21
+ Markets.GLOBAL: StockDataFetcher(),
22
+ Markets.INDIA: IndiaDataFetcher(),
23
+ Markets.CRYPTO: CryptoDataFetcher(),
24
+ Markets.CHINA_A: ChinaAFetcher(),
25
+ Markets.HONG_KONG: HongKongFetcher(),
26
+ }
27
+
28
+
29
+ def _resolve_market(market: str) -> Markets:
30
+ try:
31
+ return Markets(market.lower())
32
+ except ValueError:
33
+ raise HTTPException(status_code=400, detail=f"Unknown market '{market}'")
34
+
35
+
36
+ @router.get("/stocks/{symbol}/quote")
37
+ def get_quote(symbol: str, market: str = Query(default="us")) -> dict:
38
+ fetcher = _FETCHERS[_resolve_market(market)]
39
+ try:
40
+ return call_with_deadline(fetcher.get_quote, 20, symbol)
41
+ except Exception as exc:
42
+ raise HTTPException(status_code=502, detail=f"Failed to fetch quote: {exc}")
43
+
44
+
45
+ @router.get("/stocks/{symbol}/history")
46
+ def get_history(
47
+ symbol: str,
48
+ market: str = Query(default="us"),
49
+ period: str = Query(default="1y"),
50
+ interval: str = Query(default=TimeFrame.DAY_1.value),
51
+ ) -> list[dict]:
52
+ fetcher = _FETCHERS[_resolve_market(market)]
53
+ try:
54
+ timeframe = TimeFrame(interval)
55
+ except ValueError:
56
+ raise HTTPException(status_code=400, detail=f"Unknown interval '{interval}'")
57
+
58
+ try:
59
+ df = call_with_deadline(fetcher.get_history, 40, symbol, period=period, interval=timeframe)
60
+ except Exception as exc:
61
+ raise HTTPException(status_code=502, detail=f"Failed to fetch history: {exc}")
62
+
63
+ if df.empty:
64
+ return []
65
+ return df.reset_index().to_dict(orient="records")
66
+
67
+
68
+ @router.get("/stocks/{symbol}/overview")
69
+ def get_overview(symbol: str, market: str = Query(default="us")) -> dict:
70
+ """Technicals, trend, returns, risk, fundamentals, analysts, events,
71
+ profile and news for one stock - all computed, no AI call."""
72
+ from marketmyna.services.overview import build_overview
73
+
74
+ try:
75
+ return call_with_deadline(build_overview, 60, symbol.strip().upper(), _resolve_market(market).value)
76
+ except TimeoutError as exc:
77
+ raise HTTPException(status_code=504, detail=f"Overview timed out: {exc}")
78
+
79
+
80
+ def _financials(symbol: str, market: str, period: str) -> dict:
81
+ from marketmyna.services.financials import cached_financials
82
+
83
+ if period not in ("annual", "quarterly"):
84
+ raise HTTPException(status_code=400, detail="period must be 'annual' or 'quarterly'")
85
+ try:
86
+ return call_with_deadline(cached_financials, 60, symbol.strip().upper(), _resolve_market(market).value, period)
87
+ except TimeoutError as exc:
88
+ raise HTTPException(status_code=504, detail=f"Financial statements timed out: {exc}")
89
+ except ValueError as exc:
90
+ raise HTTPException(status_code=404, detail=str(exc))
91
+ except Exception as exc:
92
+ raise HTTPException(status_code=502, detail=f"Failed to fetch financial statements: {exc}")
93
+
94
+
95
+ @router.get("/stocks/{symbol}/financials")
96
+ def get_financials(symbol: str, market: str = Query(default="us"), period: str = Query(default="annual")) -> dict:
97
+ """Income statement, balance sheet and cash flow with derived ratios,
98
+ rule-based findings and a plain-English summary - no AI call."""
99
+ return _financials(symbol, market, period)
100
+
101
+
102
+ @router.post("/stocks/{symbol}/financials/ai-summary")
103
+ def get_financials_ai_summary(
104
+ symbol: str,
105
+ market: str = Query(default="us"),
106
+ period: str = Query(default="annual"),
107
+ config=Depends(get_config_dep),
108
+ ) -> dict:
109
+ """AI analyst narrative over the three statements (uses the configured LLM)."""
110
+ from marketmyna.llm.channels import llm_configured
111
+ from marketmyna.services.financials import ai_summary
112
+
113
+ if not llm_configured(config):
114
+ raise HTTPException(status_code=409, detail="No AI model configured. Add one in Settings to get an AI summary.")
115
+ data = _financials(symbol, market, period)
116
+ text = call_with_deadline(ai_summary, 120, data, config)
117
+ if not text:
118
+ raise HTTPException(status_code=502, detail="The AI model did not return a summary. Try again or check Settings.")
119
+ return {"symbol": data["symbol"], "period": data["period"], "summary": text}
120
+
121
+
122
+ @router.get("/stocks/{symbol}/shariah")
123
+ def get_shariah(symbol: str, market: str = Query(default="us")) -> dict:
124
+ """Shariah screen: business activity, debt / cash / receivables ratios
125
+ and interest income (S&P + AAOIFI limits), plus purification - no AI."""
126
+ from marketmyna.services.shariah import build_shariah_profile
127
+
128
+ try:
129
+ return call_with_deadline(build_shariah_profile, 60, symbol.strip().upper(), _resolve_market(market).value)
130
+ except TimeoutError as exc:
131
+ raise HTTPException(status_code=504, detail=f"Ethical screen timed out: {exc}")
132
+ except ValueError as exc:
133
+ raise HTTPException(status_code=404, detail=str(exc))
134
+ except Exception as exc:
135
+ raise HTTPException(status_code=502, detail=f"Failed to run the ethical screen: {exc}")
136
+
137
+
138
+ def _leadership(symbol: str, market: str) -> dict:
139
+ from marketmyna.services.leadership import build_leadership
140
+
141
+ try:
142
+ return call_with_deadline(build_leadership, 45, symbol.strip().upper(), _resolve_market(market).value)
143
+ except TimeoutError as exc:
144
+ raise HTTPException(status_code=504, detail=f"Leadership data timed out: {exc}")
145
+ except ValueError as exc:
146
+ raise HTTPException(status_code=404, detail=str(exc))
147
+ except Exception as exc:
148
+ raise HTTPException(status_code=502, detail=f"Failed to fetch leadership data: {exc}")
149
+
150
+
151
+ @router.get("/stocks/{symbol}/leadership")
152
+ def get_leadership(symbol: str, market: str = Query(default="us")) -> dict:
153
+ """CEO, other officers and governance risk scores - filing data, no AI."""
154
+ return _leadership(symbol, market)
155
+
156
+
157
+ @router.get("/stocks/{symbol}/ownership")
158
+ def get_ownership(symbol: str, market: str = Query(default="us")) -> dict:
159
+ """Insider / institution split, top holders, insider trades, short interest."""
160
+ from marketmyna.services.ownership import build
161
+
162
+ return build(symbol.upper(), market.lower())
163
+
164
+
165
+ @router.post("/stocks/{symbol}/mdna/ai-summary")
166
+ def get_mdna_summary(symbol: str, market: str = Query(default="us"), config=Depends(get_config_dep)) -> dict:
167
+ """AI summary of the MD&A section of the latest 10-K / 10-Q (US SEC filers)."""
168
+ from marketmyna.llm.channels import llm_configured
169
+ from marketmyna.services.sec_mdna import SecNotConfigured, ai_summary
170
+
171
+ if market.lower() != "us":
172
+ raise HTTPException(status_code=400, detail="Management discussion summaries are for US (SEC) filers only")
173
+ if not llm_configured(config):
174
+ raise HTTPException(status_code=409, detail="No AI model configured. Add one in Settings to summarise filings.")
175
+ try:
176
+ return ai_summary(symbol.upper(), config)
177
+ except SecNotConfigured as exc:
178
+ raise HTTPException(status_code=409, detail=str(exc))
179
+ except ValueError as exc:
180
+ raise HTTPException(status_code=404, detail=str(exc))
181
+ except Exception as exc:
182
+ raise HTTPException(status_code=502, detail=f"Filing summary unavailable: {exc}")
183
+
184
+
185
+ @router.post("/stocks/{symbol}/leadership/ai-brief")
186
+ def get_leadership_ai_brief(symbol: str, market: str = Query(default="us"), config=Depends(get_config_dep)) -> dict:
187
+ """AI brief on the CEO: introduction, achievements, pitfalls, verdict."""
188
+ from marketmyna.data.news_search import stock_news
189
+ from marketmyna.llm.channels import llm_configured
190
+ from marketmyna.services.leadership import ai_ceo_brief
191
+
192
+ if not llm_configured(config):
193
+ raise HTTPException(status_code=409, detail="No AI model configured. Add one in Settings to get a CEO brief.")
194
+ data = _leadership(symbol, market)
195
+ try:
196
+ news = stock_news(data["symbol"], data["market"], limit=10, name=data.get("name"))
197
+ headlines = [n["title"] for n in news if n.get("title")]
198
+ except Exception:
199
+ headlines = []
200
+ brief = call_with_deadline(ai_ceo_brief, 120, data, config, headlines)
201
+ if not brief:
202
+ raise HTTPException(status_code=502, detail="The AI model did not return a usable brief. Try again or check Settings.")
203
+ return {"symbol": data["symbol"], "ceo": data["ceo"]["name"], **brief}
204
+
205
+
206
+ @router.get("/compare")
207
+ def compare_stocks(
208
+ s: str = Query(description="Comma-separated SYMBOL:market pairs, e.g. AAPL:us,RELIANCE:india"),
209
+ period: str = Query(default="1y", description="6mo | 1y | 3y | 5y"),
210
+ ) -> dict:
211
+ """2-5 instruments side by side: rebased prices, returns, risk, fundamentals, correlation."""
212
+ from marketmyna.services.compare import compare
213
+
214
+ items = []
215
+ for part in s.split(","):
216
+ if not part.strip():
217
+ continue
218
+ sym, _, mkt = part.partition(":")
219
+ mkt = (mkt or "us").lower()
220
+ _resolve_market(mkt)
221
+ items.append((sym, mkt))
222
+ try:
223
+ return call_with_deadline(compare, 60, items, period)
224
+ except ValueError as exc:
225
+ raise HTTPException(status_code=400, detail=str(exc))
226
+
227
+
228
+ @router.get("/stocks/{symbol}/peers")
229
+ def get_peers(symbol: str, market: str = Query(default="us"), limit: int = Query(default=6, ge=2, le=10)) -> dict:
230
+ """Same-market companies in the same industry (or sector), closest in size."""
231
+ from marketmyna.services.compare import peers
232
+
233
+ _resolve_market(market)
234
+ return call_with_deadline(peers, 60, symbol, market, limit)
235
+
236
+
237
+ @router.get("/stocks/{symbol}/valuation")
238
+ def get_valuation(symbol: str, market: str = Query(default="us")) -> dict:
239
+ """Valuation meter: P/E, P/B and P/S against the sector median of the
240
+ market's largest companies, plus a growth-at-a-reasonable-price check."""
241
+ from marketmyna.services.valuation import meter
242
+
243
+ _resolve_market(market)
244
+ return call_with_deadline(meter, 90, symbol.upper(), market.lower())
245
+
246
+
247
+ @router.get("/stocks/{symbol}/dcf")
248
+ def get_dcf(symbol: str, market: str = Query(default="us")) -> dict:
249
+ """Discounted cash flow with its default assumptions, reverse DCF (growth
250
+ the price implies) and the Graham number. The page recomputes the DCF
251
+ as the viewer edits the assumptions."""
252
+ from marketmyna.services.dcf import build
253
+
254
+ try:
255
+ return call_with_deadline(build, 90, symbol.strip().upper(), _resolve_market(market).value)
256
+ except TimeoutError as exc:
257
+ raise HTTPException(status_code=504, detail=f"DCF timed out: {exc}")
258
+ except ValueError as exc:
259
+ raise HTTPException(status_code=404, detail=str(exc))
260
+
261
+
262
+ @router.get("/stocks/{symbol}/checklists")
263
+ def get_checklists(symbol: str, market: str = Query(default="us")) -> dict:
264
+ """Graham, Buffett-style, Lynch, Greenblatt and Marks-style rules, each pass / fail with its number."""
265
+ from marketmyna.services.investor_lenses import build
266
+
267
+ try:
268
+ return call_with_deadline(build, 90, symbol.strip().upper(), _resolve_market(market).value)
269
+ except TimeoutError as exc:
270
+ raise HTTPException(status_code=504, detail=f"Checklists timed out: {exc}")
271
+ except ValueError as exc:
272
+ raise HTTPException(status_code=404, detail=str(exc))
273
+
274
+
275
+ @router.get("/stocks/{symbol}/estimates")
276
+ def get_estimates(symbol: str, market: str = Query(default="us")) -> dict:
277
+ """Consensus EPS / revenue, revisions over 90 days, and recent beats or misses."""
278
+ from marketmyna.services.analysis_run import yahoo_symbol
279
+ from marketmyna.services.estimates import build
280
+
281
+ _resolve_market(market)
282
+ if market.lower() == "crypto":
283
+ return {"available": False, "periods": [], "history": []}
284
+ return call_with_deadline(build, 45, yahoo_symbol(symbol, market.lower()))
285
+
286
+
287
+ @router.get("/stocks/{symbol}/dcf/model.xlsx")
288
+ def get_dcf_model(symbol: str, market: str = Query(default="us"),
289
+ growth: Optional[float] = Query(default=None, ge=-0.5, le=1.0),
290
+ discount: Optional[float] = Query(default=None, gt=0, le=1.0),
291
+ terminal: Optional[float] = Query(default=None, ge=-0.05, le=0.2)) -> Response:
292
+ """The DCF as an Excel workbook with live formulas and a sensitivity grid,
293
+ at the rates the viewer chose on the page (defaults otherwise)."""
294
+ from marketmyna.services import dcf, dcf_model
295
+
296
+ sym = symbol.strip().upper()
297
+ try:
298
+ data = call_with_deadline(dcf.build, 90, sym, _resolve_market(market).value)
299
+ body = dcf_model.build(data, growth, discount, terminal)
300
+ except TimeoutError as exc:
301
+ raise HTTPException(status_code=504, detail=f"DCF timed out: {exc}")
302
+ except ValueError as exc:
303
+ raise HTTPException(status_code=404, detail=str(exc))
304
+ return Response(body, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
305
+ headers={"Content-Disposition": f'attachment; filename="{sym}-dcf-model.xlsx"'})
306
+
307
+
308
+ @router.get("/stocks/{symbol}/earnings")
309
+ def get_earnings(symbol: str, market: str = Query(default="us")) -> dict:
310
+ """Earnings preview (next date, consensus, past reactions, options-implied
311
+ move, what to watch) and review (actual vs estimate, price reaction)."""
312
+ from marketmyna.services.earnings import build
313
+
314
+ _resolve_market(market)
315
+ if market.lower() == "crypto":
316
+ return {"available": False, "history": []}
317
+ return call_with_deadline(build, 60, symbol.upper(), market.lower())
318
+
319
+
320
+ @router.get("/etf/{symbol}")
321
+ def etf_lookthrough(symbol: str, market: str = Query(default="us")) -> dict:
322
+ """ETF holdings, sector mix and costs; is_etf=false for anything else."""
323
+ from marketmyna.services.etf import lookthrough
324
+
325
+ _resolve_market(market)
326
+ out = call_with_deadline(lookthrough, 45, symbol, market)
327
+ out.pop("holdings", None) # full list is only needed for overlap
328
+ return out
329
+
330
+
331
+ @router.get("/etf-overlap")
332
+ def etf_overlap(a: str, b: str, market_a: str = Query(default="us"), market_b: Optional[str] = None) -> dict:
333
+ """How much of two ETFs' weight sits in the same stocks."""
334
+ from marketmyna.services.etf import lookthrough, overlap
335
+
336
+ market_b = market_b or market_a
337
+ _resolve_market(market_a)
338
+ _resolve_market(market_b)
339
+ x = call_with_deadline(lookthrough, 45, a, market_a)
340
+ y = call_with_deadline(lookthrough, 45, b, market_b)
341
+ if not (x.get("status") == "ok" and y.get("status") == "ok"):
342
+ return {"a": a.upper(), "b": b.upper(), "available": False}
343
+ return {"available": True, **overlap(x, y)}
344
+
345
+
346
+ @router.get("/stocks/{symbol}/dividends")
347
+ def get_dividends(symbol: str, market: str = Query(default="us")) -> dict:
348
+ """Payments, yearly totals, trailing yield, 5-year growth and years without a cut."""
349
+ from marketmyna.services.analysis_run import yahoo_symbol
350
+ from marketmyna.services.dividends import build
351
+
352
+ _resolve_market(market)
353
+ if market.lower() == "crypto":
354
+ return {"pays": False}
355
+ return call_with_deadline(build, 45, yahoo_symbol(symbol, market.lower()))
356
+
357
+
358
+ def _social(symbol: str, market: str, config, refresh: bool = False) -> dict:
359
+ from marketmyna.api.deps import get_database_manager
360
+ from marketmyna.services import social_sentiment
361
+
362
+ market = _resolve_market(market).value
363
+ try:
364
+ db = get_database_manager()
365
+ except Exception:
366
+ db = None
367
+ try:
368
+ return call_with_deadline(social_sentiment.build, 150, symbol, market, config, db, refresh)
369
+ except TimeoutError as exc:
370
+ raise HTTPException(status_code=504, detail=f"Social sentiment timed out: {exc}")
371
+
372
+
373
+ @router.get("/stocks/{symbol}/social")
374
+ def get_social_sentiment(
375
+ symbol: str,
376
+ market: str = Query(default="us"),
377
+ refresh: bool = Query(default=False),
378
+ config=Depends(get_config_dep),
379
+ ) -> dict:
380
+ """What people say on Reddit / StockTwits / ValuePickr / Hacker News:
381
+ score, bull/bear split, themes, timeline and top posts. Lexicon-scored,
382
+ no AI; cached 20 minutes per stock."""
383
+ return _social(symbol, market, config, refresh)
384
+
385
+
386
+ @router.post("/stocks/{symbol}/social/ai-summary")
387
+ def get_social_ai_summary(symbol: str, market: str = Query(default="us"), config=Depends(get_config_dep)) -> dict:
388
+ """AI digest of the chatter: summary, bull and bear arguments, hype risk."""
389
+ from marketmyna.llm.channels import llm_configured
390
+ from marketmyna.services.social_sentiment import ai_summary
391
+
392
+ if not llm_configured(config):
393
+ raise HTTPException(status_code=409, detail="No AI model configured. Add one in Settings to summarise the chatter.")
394
+ data = _social(symbol, market, config)
395
+ if not data["posts"]:
396
+ raise HTTPException(status_code=404, detail="No recent posts to summarise.")
397
+ summary = call_with_deadline(ai_summary, 120, data, config)
398
+ if not summary:
399
+ raise HTTPException(status_code=502, detail="The AI model did not return a usable summary. Try again or check Settings.")
400
+ return {"symbol": data["symbol"], "posts": data["posts"], **summary}
@@ -0,0 +1,157 @@
1
+ """Notification-channel settings over HTTP - an API face on the existing
2
+ marketmyna/notifiers system. Field list/kinds come from
3
+ marketmyna/notifiers/settings_schema.py, the single source of truth. AI keys are
4
+ under /api/auth/account/api-keys; data-source keys in data_sources.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ from typing import Any, List, Optional
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException
13
+ from pydantic import BaseModel, Field, create_model
14
+
15
+ from marketmyna.api.deps import get_config_dep
16
+ from marketmyna.api.middlewares.auth import require_admin
17
+ from marketmyna.notifiers.dispatcher import NotificationDispatcher
18
+ from marketmyna.notifiers.settings_schema import CHANNELS, is_secret_kind, iter_fields
19
+
20
+ router = APIRouter()
21
+
22
+ # kind -> the HTTP-facing Python type for that field. List fields are taken
23
+ # as a native JSON array here (not a comma-string - HTTP payloads are
24
+ # natively structured, so there's no reason to make the client stuff a list
25
+ # into one string).
26
+ _KIND_TYPES = {
27
+ "text": Optional[str],
28
+ "password": Optional[str],
29
+ "multiline": Optional[str],
30
+ "int": Optional[int],
31
+ "float": Optional[float],
32
+ "bool": Optional[bool],
33
+ "list": Optional[List[str]],
34
+ "secret_list": Optional[List[str]], # list of secrets, never echoed back
35
+ }
36
+
37
+ # One flat pydantic model covering every channel attr, built from the same
38
+ # CHANNELS list the dialog uses, so the two surfaces can't drift apart.
39
+ NotificationSettingsUpdate = create_model(
40
+ "NotificationSettingsUpdate",
41
+ **{
42
+ attr: (_KIND_TYPES[kind], Field(default=None))
43
+ for _channel, attr, _label, kind in iter_fields()
44
+ },
45
+ )
46
+
47
+
48
+
49
+ def _check_telegram_ids(overrides: dict, config) -> None:
50
+ """The commonest Telegram mistake: pasting the bot's own number (the part
51
+ of the token before ':') as the Chat ID. Bots can't message themselves."""
52
+ token = overrides.get("telegram_bot_token") or getattr(config, "telegram_bot_token", None) or ""
53
+ chat_ids = overrides.get("telegram_chat_id", getattr(config, "telegram_chat_id", None)) or ""
54
+ bot_number = str(token).split(":")[0]
55
+ if bot_number and bot_number in [c.strip() for c in str(chat_ids).split(",")]:
56
+ raise HTTPException(
57
+ status_code=400,
58
+ detail=(f"Chat ID {bot_number} is your bot's own number (from the token), not your chat. "
59
+ "Open your bot in Telegram, send /start, and paste the chat ID it replies with."),
60
+ )
61
+
62
+
63
+ @router.get("/system-config/notifications", dependencies=[Depends(require_admin)])
64
+ def get_notification_settings(config=Depends(get_config_dep)) -> dict[str, Any]:
65
+ """Current notification config, grouped by channel. Secret fields are
66
+ reported as {"is_set": bool}, never as their raw value."""
67
+ result: dict[str, dict[str, Any]] = {}
68
+ for channel_name, fields in CHANNELS:
69
+ channel_out: dict[str, Any] = {}
70
+ for attr, _label, kind in fields:
71
+ value = getattr(config, attr, None)
72
+ if is_secret_kind(kind):
73
+ channel_out[attr] = {"is_set": bool(value)}
74
+ else:
75
+ channel_out[attr] = value
76
+ result[channel_name] = channel_out
77
+ return result
78
+
79
+
80
+ @router.put("/system-config/notifications", dependencies=[Depends(require_admin)])
81
+ def update_notification_settings(
82
+ body: NotificationSettingsUpdate, config=Depends(get_config_dep) # type: ignore[valid-type]
83
+ ) -> dict[str, Any]:
84
+ """Partial update: only fields explicitly present in the request body change."""
85
+ overrides = body.model_dump(exclude_unset=True)
86
+ _check_telegram_ids(overrides, config)
87
+ for attr, value in overrides.items():
88
+ setattr(config, attr, value)
89
+ config.save()
90
+ return get_notification_settings(config=config)
91
+
92
+
93
+ @router.post("/system-config/notifications/test", dependencies=[Depends(require_admin)])
94
+ def test_notification_settings(
95
+ body: NotificationSettingsUpdate, config=Depends(get_config_dep) # type: ignore[valid-type]
96
+ ) -> dict[str, Any]:
97
+ """Fire a live test message through every channel that would be
98
+ configured once `body`'s overrides are applied - without saving them,
99
+ mirroring SettingsDialog._on_test."""
100
+ overrides = body.model_dump(exclude_unset=True)
101
+ _check_telegram_ids(overrides, config)
102
+ temp_config = dataclasses.replace(config, **overrides)
103
+ dispatcher = NotificationDispatcher(temp_config)
104
+
105
+ configured = dispatcher.configured_channels()
106
+ if not configured:
107
+ return {
108
+ "configured_channels": [],
109
+ "results": {},
110
+ "message": "No channel is fully configured yet - fill in a channel's fields first.",
111
+ }
112
+
113
+ results = dispatcher.send_all(
114
+ "This is a test notification from MarketMyna settings.",
115
+ title="MarketMyna Test",
116
+ )
117
+ return {"configured_channels": configured, "results": results}
118
+
119
+
120
+ _ROUTING = [
121
+ ("report", "notification_report_channels", "Daily reports", "Scheduled analysis reports"),
122
+ ("alert", "notification_alert_channels", "Alerts", "Price and portfolio alerts"),
123
+ ("system_error", "notification_system_error_channels", "System errors", "A scheduled run that failed completely"),
124
+ ]
125
+
126
+
127
+ class NotificationRoutingUpdate(BaseModel):
128
+ report: Optional[List[str]] = None
129
+ alert: Optional[List[str]] = None
130
+ system_error: Optional[List[str]] = None
131
+
132
+
133
+ @router.get("/system-config/notification-routing", dependencies=[Depends(require_admin)])
134
+ def get_notification_routing(config=Depends(get_config_dep)) -> dict[str, Any]:
135
+ """Which configured channels receive each kind of message. An empty
136
+ list means "every configured channel"."""
137
+ dispatcher = NotificationDispatcher(config)
138
+ return {
139
+ "channels": dispatcher.configured_channels(),
140
+ "purposes": [{"id": pid, "label": label, "description": desc} for pid, _attr, label, desc in _ROUTING],
141
+ "routes": {pid: list(getattr(config, attr, None) or []) for pid, attr, _l, _d in _ROUTING},
142
+ }
143
+
144
+
145
+ @router.put("/system-config/notification-routing", dependencies=[Depends(require_admin)])
146
+ def update_notification_routing(body: NotificationRoutingUpdate, config=Depends(get_config_dep)) -> dict[str, Any]:
147
+ known = set(NotificationDispatcher(config).channel_names())
148
+ for pid, attr, _label, _desc in _ROUTING:
149
+ value = getattr(body, pid)
150
+ if value is None:
151
+ continue
152
+ unknown = [c for c in value if c not in known]
153
+ if unknown:
154
+ raise HTTPException(status_code=400, detail=f"Unknown channel '{unknown[0]}'")
155
+ setattr(config, attr, list(dict.fromkeys(value)) or None)
156
+ config.save()
157
+ return get_notification_routing(config=config)
@@ -0,0 +1,112 @@
1
+ """Thesis tracker (marketmyna/services/thesis.py): one thesis per stock, its update
2
+ log, and a check against live data."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query
9
+ from pydantic import BaseModel, Field
10
+
11
+ from marketmyna.api.deps import get_config_dep
12
+ from marketmyna.utils.deadline import call_with_deadline
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ class Pillar(BaseModel):
18
+ text: str = Field(max_length=300)
19
+ status: str = "on_track"
20
+
21
+
22
+ class ThesisIn(BaseModel):
23
+ symbol: str = Field(min_length=1, max_length=40)
24
+ market: str = Field(min_length=1, max_length=32)
25
+ statement: Optional[str] = Field(default=None, max_length=2000)
26
+ pillars: Optional[list[Pillar]] = Field(default=None, max_length=10)
27
+ risks: Optional[list[str]] = Field(default=None, max_length=10)
28
+ exit_below: Optional[float] = None
29
+ exit_note: Optional[str] = Field(default=None, max_length=500)
30
+ target: Optional[float] = None
31
+ conviction: Optional[str] = None
32
+ clear_exit: bool = False
33
+ clear_target: bool = False
34
+
35
+
36
+ class LogIn(BaseModel):
37
+ note: str = Field(min_length=1, max_length=1000)
38
+ impact: str
39
+ conviction: Optional[str] = None
40
+
41
+
42
+ def _key(symbol: str, market: str) -> tuple[str, str]:
43
+ market = market.lower()
44
+ return (symbol.strip() if market == "mf" else symbol.strip().upper()), market
45
+
46
+
47
+ @router.get("/theses")
48
+ def list_theses(check: bool = Query(default=False), config=Depends(get_config_dep)) -> list[dict]:
49
+ """Every thesis; with check=true each also gets its live data check."""
50
+ from marketmyna.services import thesis
51
+
52
+ rows = thesis.list_all(config)
53
+ if not check:
54
+ return rows
55
+ return call_with_deadline(lambda: [thesis.with_check(t) for t in rows], 120)
56
+
57
+
58
+ @router.get("/theses/{symbol}")
59
+ def get_thesis(symbol: str, market: str = Query(default="us"), config=Depends(get_config_dep)) -> dict:
60
+ from marketmyna.services import thesis
61
+
62
+ t = thesis.get(config, *_key(symbol, market))
63
+ if t is None:
64
+ return {"thesis": None}
65
+ return {"thesis": call_with_deadline(thesis.with_check, 60, t)}
66
+
67
+
68
+ @router.put("/theses")
69
+ def save_thesis(body: ThesisIn, config=Depends(get_config_dep)) -> dict:
70
+ from marketmyna.services import thesis
71
+
72
+ fields = body.model_dump(exclude_none=True, exclude={"symbol", "market", "clear_exit", "clear_target"})
73
+ if body.clear_exit:
74
+ fields["exit_below"] = None
75
+ if body.clear_target:
76
+ fields["target"] = None
77
+ try:
78
+ t = thesis.save(config, *_key(body.symbol, body.market), **fields)
79
+ except thesis.ThesisError as exc:
80
+ raise HTTPException(status_code=400, detail=str(exc))
81
+ return {"thesis": call_with_deadline(thesis.with_check, 60, t)}
82
+
83
+
84
+ @router.post("/theses/{thesis_id}/log")
85
+ def add_log(thesis_id: int, body: LogIn, config=Depends(get_config_dep)) -> dict:
86
+ from marketmyna.services import thesis
87
+
88
+ try:
89
+ t = thesis.add_log(config, thesis_id, body.note, body.impact, body.conviction)
90
+ except thesis.ThesisError as exc:
91
+ raise HTTPException(status_code=400, detail=str(exc))
92
+ if t is None:
93
+ raise HTTPException(status_code=404, detail="Thesis not found")
94
+ return {"thesis": call_with_deadline(thesis.with_check, 60, t)}
95
+
96
+
97
+ @router.post("/theses/{thesis_id}/reviewed")
98
+ def reviewed(thesis_id: int, config=Depends(get_config_dep)) -> dict:
99
+ from marketmyna.services import thesis
100
+
101
+ t = thesis.mark_reviewed(config, thesis_id)
102
+ if t is None:
103
+ raise HTTPException(status_code=404, detail="Thesis not found")
104
+ return {"thesis": call_with_deadline(thesis.with_check, 60, t)}
105
+
106
+
107
+ @router.delete("/theses/{thesis_id}", status_code=204)
108
+ def delete_thesis(thesis_id: int, config=Depends(get_config_dep)) -> None:
109
+ from marketmyna.services import thesis
110
+
111
+ if not thesis.delete(config, thesis_id):
112
+ raise HTTPException(status_code=404, detail="Thesis not found")