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,163 @@
1
+ """Macro dashboard endpoint (marketmyna/services/macro.py)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/macro")
11
+ def macro_dashboard() -> dict:
12
+ """Latest US / India / global macro series with changes and sparklines."""
13
+ from marketmyna.services.macro import build
14
+
15
+ return build()
16
+
17
+
18
+ @router.get("/macro/commodities")
19
+ def commodities() -> dict:
20
+ """Metals, energy and grains futures, plus gold / silver in rupees, with
21
+ the latest price of each contract."""
22
+ from marketmyna.services import commodities, live_market
23
+
24
+ def run() -> dict:
25
+ prices = live_market.latest(commodities.tickers())
26
+ return {**commodities.build(live=prices), "live": live_market.status_for(prices)}
27
+
28
+ return live_market.memo(("commodities",), run)
29
+
30
+
31
+ @router.get("/macro/cot")
32
+ def commitments_of_traders() -> dict:
33
+ """CFTC positioning (hedge funds, producers) in the main commodity futures."""
34
+ from marketmyna.services.cot import build
35
+
36
+ return build()
37
+
38
+
39
+ @router.get("/macro/eia")
40
+ def us_oil_inventories() -> dict:
41
+ """EIA weekly US crude, gasoline and distillate stocks vs the 5-year average."""
42
+ from marketmyna.services.eia_stocks import build
43
+
44
+ return build()
45
+
46
+
47
+ @router.get("/macro/india-flows")
48
+ def india_flows() -> dict:
49
+ """FII / DII cash-market flows and participant positioning in index derivatives (NSE)."""
50
+ from marketmyna.services.india_flows import build
51
+
52
+ return build()
53
+
54
+
55
+ @router.get("/macro/mood")
56
+ def market_mood(market: str = "india") -> dict:
57
+ """Fear-to-greed gauge from breadth, moving averages, highs / lows, momentum and VIX (crypto: funding instead of VIX)."""
58
+ from fastapi import HTTPException
59
+
60
+ from marketmyna.data.listings import get_store
61
+ from marketmyna.services import live_market
62
+ from marketmyna.services.analysis_run import MARKET_INDICES
63
+ from marketmyna.services.market_mood import VOL_INDEX, build
64
+
65
+ market = market.lower()
66
+ if market == "crypto":
67
+ from marketmyna.services import crypto_mood
68
+
69
+ def run_crypto() -> dict:
70
+ live = live_market.market("crypto")
71
+ return {**crypto_mood.build(live_market.merge(get_store().rows("crypto"), live["quotes"])), "live": live["status"]}
72
+
73
+ return live_market.memo(("mood", market), run_crypto)
74
+ if market not in ("india", "us", "hong_kong", "china_a"):
75
+ raise HTTPException(status_code=400, detail="Market mood covers India, US, Hong Kong, China A and crypto")
76
+ def run() -> dict:
77
+ live = live_market.market(market)
78
+ rows = live_market.merge(get_store().rows(market), live["quotes"])
79
+ prices = live_market.for_tickers(market, live["quotes"])
80
+ extra = [MARKET_INDICES[market][0][0]] + ([VOL_INDEX[market][0]] if market in VOL_INDEX else [])
81
+ prices.update(live_market.latest(extra))
82
+ return {**build(market, rows=rows, live=prices), "live": live["status"]}
83
+
84
+ return live_market.memo(("mood", market), run)
85
+
86
+
87
+ @router.get("/macro/yield-curve")
88
+ def yield_curve() -> dict:
89
+ """US Treasury yield curve now vs 1 month and 1 year ago."""
90
+ from marketmyna.services.macro import yield_curve as build
91
+
92
+ return build()
93
+
94
+
95
+ @router.get("/macro/india")
96
+ def india_rates() -> dict:
97
+ """RBI policy and money-market rates, India yield curve, IBJA gold / silver."""
98
+ from marketmyna.services.india_rates import build
99
+
100
+ return build()
101
+
102
+
103
+ @router.get("/macro/crude-sensitivity")
104
+ def crude_sensitivity(market: str = "india") -> dict:
105
+ """How oil-linked stocks (india | us | hong_kong | china_a) have moved with
106
+ crude in local currency, net of their market."""
107
+ from marketmyna.services.crude_sensitivity import build
108
+
109
+ return build(market=market.lower())
110
+
111
+
112
+ @router.get("/macro/rates")
113
+ def market_rates(market: str = "us") -> dict:
114
+ """Rates & bullion for us | hong_kong | china_a | crypto (India: /macro/india)."""
115
+ from marketmyna.services.rates import build
116
+
117
+ return build(market)
118
+
119
+
120
+ @router.get("/macro/world")
121
+ def world_markets() -> dict:
122
+ """World stock indices, currencies, US yields, VIX term structure, India VIX
123
+ and the crypto fear & greed index."""
124
+ from marketmyna.services import live_market, world_markets
125
+
126
+ def run() -> dict:
127
+ prices = live_market.latest(world_markets.tickers())
128
+ return {**world_markets.build(live=prices), "live": live_market.status_for(prices)}
129
+
130
+ return live_market.memo(("world",), run)
131
+
132
+
133
+ @router.get("/macro/valuation")
134
+ def market_valuation() -> dict:
135
+ """US market P/E, CAPE and yields against their history since 1871; Nifty
136
+ index P/E, P/B and dividend yield (history kept from daily snapshots)."""
137
+ from marketmyna.services.market_valuation import build
138
+
139
+ return build()
140
+
141
+
142
+ @router.get("/macro/countries")
143
+ def country_macro(codes: str = "") -> dict:
144
+ """IMF growth, inflation, debt, deficits and GDP for chosen countries
145
+ (ISO3 codes, comma separated; blank = a default set of 14)."""
146
+ from marketmyna.services.country_macro import build
147
+
148
+ picked = [c.strip() for c in codes.split(",") if c.strip()][:30] or None
149
+ try:
150
+ return build(picked)
151
+ except Exception as exc: # nothing saved yet and the IMF did not answer
152
+ raise HTTPException(status_code=503, detail="IMF data is unavailable right now. Try again later.") from exc
153
+
154
+
155
+ @router.get("/macro/central-banks")
156
+ def central_banks() -> dict:
157
+ """Policy rates of about 40 central banks: level, last move, 1-year change, real rate."""
158
+ from marketmyna.services.central_banks import build
159
+
160
+ try:
161
+ return build()
162
+ except Exception as exc: # nothing saved yet and the BIS did not answer
163
+ raise HTTPException(status_code=503, detail="Central bank rates are unavailable right now.") from exc
@@ -0,0 +1,530 @@
1
+ """Portfolio CRUD - thin wrapper over the existing (now DB-backed) PortfolioManager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query, Response
9
+ from pydantic import BaseModel, Field
10
+
11
+ from marketmyna.api.deps import get_config_dep
12
+ from marketmyna.portfolio.manager import PortfolioManager
13
+ from marketmyna.portfolio.models import TransactionType
14
+ from marketmyna.utils.deadline import call_with_deadline
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ def get_portfolio_manager(config=Depends(get_config_dep)) -> PortfolioManager:
20
+ return PortfolioManager(config.data_dir, config=config)
21
+
22
+
23
+ class CreatePortfolioRequest(BaseModel):
24
+ name: str = Field(min_length=1, max_length=60)
25
+ currency: str = "USD"
26
+ kind: str = Field(default="stocks", pattern="^(stocks|funds)$") # funds = mutual funds
27
+
28
+
29
+ class RenamePortfolioRequest(BaseModel):
30
+ name: str = Field(min_length=1, max_length=60)
31
+
32
+
33
+ class ParseCASRequest(BaseModel):
34
+ file_b64: str = Field(min_length=1, max_length=14_000_000) # ~10 MB PDF
35
+ password: str = Field(default="", max_length=64)
36
+
37
+
38
+ class ReconcileRequest(BaseModel):
39
+ filename: str = Field(default="statement.csv", max_length=200)
40
+ file_b64: str = Field(min_length=1, max_length=14_000_000) # ~10 MB
41
+ password: str = Field(default="", max_length=64)
42
+
43
+
44
+ class ReconcileFix(BaseModel):
45
+ symbol: str = Field(min_length=1, max_length=40)
46
+ market: str = Field(default="india", max_length=32)
47
+ type: str = Field(pattern="^(buy|sell|split)$")
48
+ quantity: float = Field(gt=0)
49
+ price: float = Field(ge=0)
50
+ date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
51
+ notes: str = Field(default="Reconciliation", max_length=200)
52
+
53
+
54
+ class ReconcileApplyRequest(BaseModel):
55
+ fixes: list[ReconcileFix] = Field(min_length=1, max_length=500)
56
+
57
+
58
+ class ImportFund(BaseModel):
59
+ amfi: str = Field(min_length=1, max_length=12)
60
+ scheme: str = ""
61
+ units: float = Field(ge=0)
62
+ nav: float = Field(gt=0)
63
+ cost: Optional[float] = Field(default=None, ge=0)
64
+ date: Optional[str] = None
65
+
66
+
67
+ class ImportFundsRequest(BaseModel):
68
+ holdings: list[ImportFund] = Field(min_length=1, max_length=500)
69
+
70
+
71
+ class AddTransactionRequest(BaseModel):
72
+ symbol: str
73
+ transaction_type: TransactionType
74
+ quantity: float
75
+ price: float
76
+ date: Optional[datetime] = None
77
+ fees: float = 0.0
78
+ notes: str = ""
79
+ market: str = "US"
80
+
81
+
82
+ @router.get("/portfolio")
83
+ def list_portfolios(manager: PortfolioManager = Depends(get_portfolio_manager)) -> list[str]:
84
+ return manager.get_all_portfolios()
85
+
86
+
87
+ def _summary(manager: PortfolioManager, name: str) -> dict:
88
+ """Portfolio summary with its kind and scheme name / category on mutual fund holdings."""
89
+ from marketmyna.services.mf_holdings import MARKET, latest
90
+
91
+ out = manager.get_portfolio_summary(name)
92
+ portfolio = manager.portfolios[name]
93
+ out["kind"] = portfolio_kind(portfolio.kind, [h.market for h in portfolio.holdings.values()])
94
+ for h in out.get("holdings", {}).values():
95
+ if (h.get("market") or "").lower() == MARKET and (info := latest(h["symbol"])):
96
+ h.update(name=info["name"], amc=info["amc"], category=info["category"], nav_date=info["nav_date"])
97
+ return out
98
+
99
+
100
+ def portfolio_kind(kind: Optional[str], markets) -> str:
101
+ """A portfolio's kind; old rows without one are "funds" when they hold only mutual funds."""
102
+ if kind in ("stocks", "funds"):
103
+ return kind
104
+ return "funds" if {(m or "").lower() for m in markets} == {"mf"} else "stocks"
105
+
106
+
107
+ @router.get("/portfolio/{name}")
108
+ def get_portfolio(name: str, manager: PortfolioManager = Depends(get_portfolio_manager)) -> dict:
109
+ try:
110
+ return _summary(manager, name)
111
+ except ValueError as exc:
112
+ raise HTTPException(status_code=404, detail=str(exc))
113
+
114
+
115
+ @router.post("/portfolio/cas/parse")
116
+ def parse_cas(body: ParseCASRequest) -> dict:
117
+ """Read a CAMS / KFintech CAS PDF into per-scheme holdings for preview.
118
+ The file and password are used in memory only and never stored."""
119
+ import base64
120
+ import binascii
121
+
122
+ from marketmyna.services.mf_holdings import CASError
123
+ from marketmyna.services.mf_holdings import parse_cas as parse
124
+
125
+ try:
126
+ pdf = base64.b64decode(body.file_b64, validate=True)
127
+ except (binascii.Error, ValueError):
128
+ raise HTTPException(status_code=400, detail="File is not valid base64")
129
+ if not pdf.startswith(b"%PDF"):
130
+ raise HTTPException(status_code=400, detail="That file is not a PDF")
131
+ try:
132
+ return parse(pdf, body.password.strip())
133
+ except CASError as exc:
134
+ raise HTTPException(status_code=422, detail=str(exc))
135
+
136
+
137
+ @router.post("/portfolio/{name}/reconcile")
138
+ def reconcile(name: str, body: ReconcileRequest, manager: PortfolioManager = Depends(get_portfolio_manager)) -> dict:
139
+ """Tie the portfolio out against a broker holdings export or a CAS PDF, and
140
+ check the stored holdings against the transaction history. Read-only: fixes
141
+ are only proposed. The file is used in memory and never stored."""
142
+ import base64
143
+ import binascii
144
+
145
+ from marketmyna.services import reconcile as rc
146
+
147
+ if name not in manager.portfolios:
148
+ raise HTTPException(status_code=404, detail=f"Portfolio '{name}' not found")
149
+ try:
150
+ data = base64.b64decode(body.file_b64, validate=True)
151
+ except (binascii.Error, ValueError):
152
+ raise HTTPException(status_code=400, detail="File is not valid base64")
153
+ try:
154
+ kind, rows = rc.read_statement(body.filename, data, body.password.strip())
155
+ except rc.StatementError as exc:
156
+ raise HTTPException(status_code=422, detail=str(exc))
157
+ unknown: list[dict] = []
158
+ if kind == "broker":
159
+ from marketmyna.data.listings import get_store
160
+
161
+ rows, unknown = rc.resolve(rows, [r for r in get_store().rows("india") if r.get("type") in ("stock", "etf")])
162
+ portfolio = manager.portfolios[name]
163
+ result = call_with_deadline(rc.compare, 60, rc.holdings_for(portfolio, kind), rows)
164
+ return {"kind": kind, "rows_read": len(rows) + len(unknown),
165
+ "unresolved": [{"symbol": u["symbol"], "name": u["name"], "isin": u["isin"], "quantity": u["quantity"]}
166
+ for u in unknown],
167
+ **result, "ledger": rc.ledger_check(portfolio)}
168
+
169
+
170
+ @router.post("/portfolio/{name}/reconcile/apply")
171
+ def reconcile_apply(name: str, body: ReconcileApplyRequest,
172
+ manager: PortfolioManager = Depends(get_portfolio_manager)) -> dict:
173
+ """Post the fixes the user ticked, oldest first; each one stands alone."""
174
+ if name not in manager.portfolios:
175
+ raise HTTPException(status_code=404, detail=f"Portfolio '{name}' not found")
176
+ applied, errors = 0, []
177
+ for f in sorted(body.fixes, key=lambda f: f.date):
178
+ try:
179
+ manager.add_transaction(
180
+ portfolio_name=name, symbol=f.symbol.upper() if f.market != "mf" else f.symbol,
181
+ transaction_type=TransactionType(f.type), quantity=f.quantity, price=f.price,
182
+ date=datetime.fromisoformat(f.date), notes=f.notes, market=f.market,
183
+ )
184
+ applied += 1
185
+ except ValueError as exc:
186
+ errors.append(f"{f.symbol}: {exc}")
187
+ return {"applied": applied, "errors": errors, "portfolio": _summary(manager, name)}
188
+
189
+
190
+ @router.post("/portfolio/{name}/import-funds")
191
+ def import_funds(
192
+ name: str, body: ImportFundsRequest, manager: PortfolioManager = Depends(get_portfolio_manager)
193
+ ) -> dict:
194
+ """Bring mutual fund units in line with a statement (adds only the differences)."""
195
+ from marketmyna.services.mf_holdings import MARKET, plan_import
196
+
197
+ if name not in manager.portfolios:
198
+ raise HTTPException(status_code=404, detail=f"Portfolio '{name}' not found")
199
+ held = {s: h.quantity for s, h in manager.portfolios[name].holdings.items() if (h.market or "").lower() == MARKET}
200
+ plan = plan_import(held, [f.model_dump() for f in body.holdings])
201
+ for t in plan:
202
+ try:
203
+ manager.add_transaction(
204
+ portfolio_name=name, symbol=t["symbol"], transaction_type=TransactionType(t["type"]),
205
+ quantity=t["quantity"], price=t["price"], date=datetime.fromisoformat(t["date"]),
206
+ notes=t["notes"], market=MARKET,
207
+ )
208
+ except ValueError as exc:
209
+ raise HTTPException(status_code=400, detail=str(exc))
210
+ return {"applied": len(plan), "unchanged": len(body.holdings) - len(plan), "portfolio": _summary(manager, name)}
211
+
212
+
213
+ @router.get("/portfolio/{name}/analytics")
214
+ def portfolio_analytics(name: str, manager: PortfolioManager = Depends(get_portfolio_manager)) -> dict:
215
+ """Risk / return ratios, correlation and optimizer targets for the current mix."""
216
+ from marketmyna.portfolio.analytics import analyze
217
+
218
+ try:
219
+ summary = manager.get_portfolio_summary(name)
220
+ except ValueError as exc:
221
+ raise HTTPException(status_code=404, detail=str(exc))
222
+ try:
223
+ out = analyze(list(summary["holdings"].values()))
224
+ except ValueError as exc:
225
+ raise HTTPException(status_code=422, detail=str(exc))
226
+ # Fund holdings are keyed by AMFI code; show their names in the warnings
227
+ from marketmyna.services.mf_holdings import MARKET, latest
228
+
229
+ names = {s: info["name"].split(" - ")[0] for s, h in summary["holdings"].items()
230
+ if (h.get("market") or "").lower() == MARKET and (info := latest(s))}
231
+ if names:
232
+ import re
233
+
234
+ pattern = re.compile(r"\b(" + "|".join(map(re.escape, names)) + r")\b")
235
+ out["warnings"] = [pattern.sub(lambda m: names[m.group(1)], w) for w in out.get("warnings", [])]
236
+ # A recommended mix is Investment Adviser territory (marketmyna/core/compliance.py).
237
+ out["compliance"] = "analytics"
238
+ out["optimizer"] = None
239
+ return out
240
+
241
+
242
+ @router.get("/funds/{code}/nav")
243
+ def fund_nav(code: str, on: Optional[str] = None) -> dict:
244
+ """Official AMFI NAV of a scheme on a date (the last one before it on a
245
+ holiday), so the fund form can fill NAV and work out units from an amount."""
246
+ from datetime import date
247
+
248
+ from marketmyna.services.mf_holdings import nav_on
249
+
250
+ try:
251
+ day = date.fromisoformat(on) if on else date.today()
252
+ except ValueError:
253
+ raise HTTPException(status_code=400, detail="Date must be YYYY-MM-DD")
254
+ out = nav_on(code, day)
255
+ if out is None:
256
+ raise HTTPException(status_code=404, detail="No NAV found for this fund on or before that date")
257
+ return out
258
+
259
+
260
+ @router.get("/portfolio/{name}/funds")
261
+ def portfolio_funds(name: str, manager: PortfolioManager = Depends(get_portfolio_manager)) -> dict:
262
+ """Mutual fund view: per fund value, XIRR, best SIP day and lumpsum window check."""
263
+ from marketmyna.services.mf_lumpsum import portfolio_funds as build
264
+
265
+ try:
266
+ summary = _summary(manager, name)
267
+ except ValueError as exc:
268
+ raise HTTPException(status_code=404, detail=str(exc))
269
+ return build(summary)
270
+
271
+
272
+ @router.post("/portfolio/{name}/funds/{code}/ai-review")
273
+ def fund_ai_review(name: str, code: str, manager: PortfolioManager = Depends(get_portfolio_manager),
274
+ config=Depends(get_config_dep)) -> dict:
275
+ """AI read of one held fund's dip (a lumpsum view for the owner, an
276
+ explanation for everyone else)."""
277
+ from marketmyna.llm.channels import llm_configured
278
+ from marketmyna.llm.protocol import GenerationError
279
+ from marketmyna.services.mf_lumpsum import ai_review
280
+
281
+ if name not in manager.get_all_portfolios():
282
+ raise HTTPException(status_code=404, detail=f"Portfolio '{name}' not found")
283
+ held = {s for s, h in manager.portfolios[name].holdings.items() if (h.market or "").lower() == "mf"}
284
+ if code not in held:
285
+ raise HTTPException(status_code=404, detail="That fund isn't in this portfolio.")
286
+ if not llm_configured(config):
287
+ raise HTTPException(status_code=409, detail="No AI model is set up. Add an AI key under Settings → AI keys.")
288
+ try:
289
+ out = call_with_deadline(ai_review, 100, code, config)
290
+ except GenerationError as exc:
291
+ raise HTTPException(status_code=402, detail=str(exc))
292
+ if not out:
293
+ raise HTTPException(status_code=502, detail="The AI model did not return a usable review. Try again.")
294
+ return out
295
+
296
+
297
+ class DipRulesRequest(BaseModel):
298
+ levels: list[float] = Field(default_factory=list)
299
+ overrides: dict[str, list[float]] = Field(default_factory=dict)
300
+
301
+
302
+ @router.get("/portfolio/{name}/dip-alerts")
303
+ def get_dip_alerts(name: str, config=Depends(get_config_dep)) -> dict:
304
+ """The user's own % dip-alert levels: portfolio default and per-fund overrides."""
305
+ from marketmyna.services.mf_lumpsum import get_rules
306
+
307
+ try:
308
+ return get_rules(config, name)
309
+ except LookupError as exc:
310
+ raise HTTPException(status_code=404, detail=str(exc))
311
+
312
+
313
+ @router.put("/portfolio/{name}/dip-alerts")
314
+ def set_dip_alerts(name: str, body: DipRulesRequest, config=Depends(get_config_dep)) -> dict:
315
+ from marketmyna.services.mf_lumpsum import set_rules
316
+
317
+ try:
318
+ return set_rules(config, name, body.levels, body.overrides)
319
+ except LookupError as exc:
320
+ raise HTTPException(status_code=404, detail=str(exc))
321
+ except ValueError as exc:
322
+ raise HTTPException(status_code=400, detail=str(exc))
323
+
324
+
325
+ @router.post("/portfolio/{name}/dip-alerts/test")
326
+ def test_dip_alert(name: str, config=Depends(get_config_dep)) -> dict:
327
+ """Sends a sample dip alert through the user's channels."""
328
+ from marketmyna.services.mf_lumpsum import send_test_alert
329
+
330
+ try:
331
+ out = send_test_alert(config, name)
332
+ except LookupError as exc:
333
+ raise HTTPException(status_code=404, detail=str(exc))
334
+ if not any(out["sent"].values()):
335
+ raise HTTPException(status_code=409, detail="No channel reached you. Turn on push, email or Telegram in Settings first.")
336
+ return out
337
+
338
+
339
+ def _fund_health(summary: dict) -> dict:
340
+ from marketmyna.portfolio.analytics import MF_PREFIX, _download_closes
341
+ from marketmyna.services.mf_holdings import MARKET
342
+ from marketmyna.services.mf_insights import insights
343
+
344
+ funds = [h for h in summary["holdings"].values() if (h.get("market") or "").lower() == MARKET]
345
+ if not funds:
346
+ return {"funds": 0}
347
+ closes = _download_closes([MF_PREFIX + f["symbol"] for f in funds])
348
+ navs = {k[len(MF_PREFIX):]: v for k, v in closes.items()}
349
+ return insights(funds, navs)
350
+
351
+
352
+ @router.get("/portfolio/{name}/fund-insights")
353
+ def fund_insights(name: str, manager: PortfolioManager = Depends(get_portfolio_manager)) -> dict:
354
+ """Health check for the mutual funds held: category mix, duplicates, similar-moving funds, plan costs."""
355
+ try:
356
+ summary = _summary(manager, name)
357
+ except ValueError as exc:
358
+ raise HTTPException(status_code=404, detail=str(exc))
359
+ return _fund_health(summary)
360
+
361
+
362
+ @router.post("/portfolio/{name}/funds/ai-review")
363
+ def funds_ai_review(name: str, refresh: bool = Query(default=False),
364
+ manager: PortfolioManager = Depends(get_portfolio_manager), config=Depends(get_config_dep)) -> dict:
365
+ """AI read of the whole fund portfolio: mix, overlap, look-through
366
+ concentration and each fund's role (marketmyna/services/mf_portfolio_ai.py)."""
367
+ from marketmyna.llm.channels import llm_configured
368
+ from marketmyna.llm.protocol import GenerationError
369
+ from marketmyna.services import mf_portfolio_ai
370
+ from marketmyna.services.mf_lumpsum import portfolio_funds
371
+
372
+ try:
373
+ summary = _summary(manager, name)
374
+ except ValueError as exc:
375
+ raise HTTPException(status_code=404, detail=str(exc))
376
+ if not llm_configured(config):
377
+ raise HTTPException(status_code=409, detail="No AI model is set up. Add an AI key under Settings → AI keys.")
378
+
379
+ def run():
380
+ return mf_portfolio_ai.review(name, portfolio_funds(summary), _fund_health(summary), config,
381
+ use_cache=not refresh)
382
+
383
+ try:
384
+ out = call_with_deadline(run, 240)
385
+ except LookupError as exc:
386
+ raise HTTPException(status_code=404, detail=str(exc))
387
+ except GenerationError as exc:
388
+ raise HTTPException(status_code=402, detail=str(exc))
389
+ except TimeoutError as exc:
390
+ raise HTTPException(status_code=504, detail=str(exc))
391
+ if not out:
392
+ raise HTTPException(status_code=502, detail="The AI model did not return a usable review. Try again.")
393
+ return out
394
+
395
+
396
+ def _review(manager: PortfolioManager, name: str, config) -> dict:
397
+ from marketmyna.portfolio.review import for_portfolio
398
+
399
+ return for_portfolio(manager, name, config)
400
+
401
+
402
+ @router.get("/portfolio/{name}/review")
403
+ def portfolio_review(name: str, manager: PortfolioManager = Depends(get_portfolio_manager),
404
+ config=Depends(get_config_dep)) -> dict:
405
+ """What in the current mix could be changed, why, and the levers for it."""
406
+ try:
407
+ return _review(manager, name, config)
408
+ except (ValueError, LookupError) as exc:
409
+ raise HTTPException(status_code=404, detail=str(exc))
410
+
411
+
412
+ class ReviewLimitsRequest(BaseModel):
413
+ max_holding: Optional[float] = None
414
+ max_sector: Optional[float] = None
415
+ max_cash: Optional[float] = None
416
+ loss_review: Optional[float] = None
417
+ min_holdings: Optional[int] = None
418
+
419
+
420
+ @router.put("/portfolio/{name}/review/limits")
421
+ def set_review_limits(name: str, body: ReviewLimitsRequest, config=Depends(get_config_dep)) -> dict:
422
+ from marketmyna.portfolio.review import set_limits
423
+
424
+ try:
425
+ return set_limits(config, name, body.model_dump(exclude_none=True))
426
+ except LookupError as exc:
427
+ raise HTTPException(status_code=404, detail=str(exc))
428
+ except ValueError as exc:
429
+ raise HTTPException(status_code=400, detail=str(exc))
430
+
431
+
432
+ @router.post("/portfolio/{name}/review/ai")
433
+ def portfolio_review_ai(name: str, manager: PortfolioManager = Depends(get_portfolio_manager),
434
+ config=Depends(get_config_dep)) -> dict:
435
+ """AI analyst read of the review: priorities for the owner, an explanation for everyone else."""
436
+ from marketmyna.llm.channels import llm_configured
437
+ from marketmyna.llm.protocol import GenerationError
438
+ from marketmyna.portfolio.review import ai_summary
439
+
440
+ if not llm_configured(config):
441
+ raise HTTPException(status_code=409, detail="No AI model is set up. Add an AI key under Settings → AI keys.")
442
+ try:
443
+ review = _review(manager, name, config)
444
+ except (ValueError, LookupError) as exc:
445
+ raise HTTPException(status_code=404, detail=str(exc))
446
+ if not review["findings"] and not review["good"]:
447
+ raise HTTPException(status_code=422, detail="Add holdings first.")
448
+ try:
449
+ out = call_with_deadline(ai_summary, 100, review, config)
450
+ except GenerationError as exc:
451
+ raise HTTPException(status_code=402, detail=str(exc))
452
+ if not out:
453
+ raise HTTPException(status_code=502, detail="The AI model did not return a usable review. Try again.")
454
+ return out
455
+
456
+
457
+ @router.post("/portfolio", status_code=201)
458
+ def create_portfolio(
459
+ body: CreatePortfolioRequest,
460
+ manager: PortfolioManager = Depends(get_portfolio_manager),
461
+ config=Depends(get_config_dep),
462
+ ) -> dict:
463
+ try:
464
+ portfolio = manager.create_portfolio(body.name.strip(), currency=body.currency, kind=body.kind)
465
+ except ValueError as exc:
466
+ raise HTTPException(status_code=409, detail=str(exc))
467
+ return portfolio.to_dict()
468
+
469
+
470
+ @router.post("/portfolio/{name}/transactions", status_code=201)
471
+ def add_transaction(
472
+ name: str,
473
+ body: AddTransactionRequest,
474
+ manager: PortfolioManager = Depends(get_portfolio_manager),
475
+ ) -> dict:
476
+ market = "mf" if (body.market or "").lower() == "mf" else body.market
477
+ try:
478
+ manager.add_transaction(
479
+ portfolio_name=name,
480
+ symbol=body.symbol,
481
+ transaction_type=body.transaction_type,
482
+ quantity=body.quantity,
483
+ price=body.price,
484
+ date=body.date,
485
+ fees=body.fees,
486
+ notes=body.notes,
487
+ # Mutual funds are keyed by AMFI code under market "mf" (any case from the form).
488
+ market=market,
489
+ )
490
+ except ValueError as exc:
491
+ raise HTTPException(status_code=400, detail=str(exc))
492
+ return _summary(manager, name)
493
+
494
+
495
+ @router.patch("/portfolio/{name}")
496
+ def rename_portfolio(
497
+ name: str, body: RenamePortfolioRequest, manager: PortfolioManager = Depends(get_portfolio_manager)
498
+ ) -> dict:
499
+ if name not in manager.portfolios:
500
+ raise HTTPException(status_code=404, detail=f"Portfolio '{name}' not found")
501
+ try:
502
+ portfolio = manager.rename_portfolio(name, body.name)
503
+ except ValueError as exc:
504
+ raise HTTPException(status_code=409, detail=str(exc))
505
+ return _summary(manager, portfolio.name)
506
+
507
+
508
+ @router.delete("/portfolio/{name}", status_code=204)
509
+ def delete_portfolio(name: str, manager: PortfolioManager = Depends(get_portfolio_manager)) -> Response:
510
+ try:
511
+ manager.delete_portfolio(name)
512
+ except ValueError as exc:
513
+ raise HTTPException(status_code=404, detail=str(exc))
514
+ return Response(status_code=204)
515
+
516
+
517
+ @router.delete("/portfolio/{name}/transactions/{index}")
518
+ def delete_transaction(
519
+ name: str, index: int, manager: PortfolioManager = Depends(get_portfolio_manager)
520
+ ) -> dict:
521
+ """Remove one transaction and rebuild holdings from the rest of the history."""
522
+ if name not in manager.portfolios:
523
+ raise HTTPException(status_code=404, detail=f"Portfolio '{name}' not found")
524
+ try:
525
+ manager.delete_transaction(name, index)
526
+ except IndexError as exc:
527
+ raise HTTPException(status_code=404, detail=str(exc))
528
+ except ValueError as exc:
529
+ raise HTTPException(status_code=409, detail=str(exc))
530
+ return _summary(manager, name)