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,411 @@
1
+ """Portfolio review: what in the current mix could be changed, why, and how.
2
+
3
+ Turns numbers the app already computes (weights, sectors, risk share,
4
+ correlation, beta / drawdown from analytics.py, the fund health check from
5
+ mf_insights.py) into a ranked list of findings. Each finding says what we see,
6
+ why it matters and which levers the user has, and points at the section of the
7
+ Portfolio page that shows the detail.
8
+
9
+ Wording follows marketmyna/core/compliance.py: a finding states facts, the user's
10
+ own limits and general levers, never "sell X, buy Y", an amount to trim or a
11
+ target allocation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from datetime import datetime
18
+ from typing import Optional
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # The user's own limits (percent). Bounds keep a typo from switching a check off.
23
+ DEFAULT_LIMITS = {"max_holding": 20.0, "max_sector": 35.0, "max_cash": 20.0, "loss_review": 25.0, "min_holdings": 5}
24
+ LIMIT_BOUNDS = {"max_holding": (5, 100), "max_sector": (10, 100), "max_cash": (0, 100), "loss_review": (5, 90),
25
+ "min_holdings": (1, 50)}
26
+
27
+ SEVERITY_COST = {"high": 15, "medium": 8, "low": 3}
28
+ SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
29
+
30
+ # Sections on the Portfolio page a finding links to (Card ids).
31
+ HOLDINGS, ANALYTICS, FUNDS, FUND_HEALTH, TRANSACTIONS = (
32
+ "section-holdings", "section-analytics", "section-funds", "section-fund-health", "section-transactions")
33
+
34
+
35
+ def clean_limits(values: Optional[dict]) -> dict:
36
+ out = dict(DEFAULT_LIMITS)
37
+ for key, raw in (values or {}).items():
38
+ if key not in DEFAULT_LIMITS or raw is None:
39
+ continue
40
+ lo, hi = LIMIT_BOUNDS[key]
41
+ v = float(raw)
42
+ if not lo <= v <= hi:
43
+ raise ValueError(f"{key.replace('_', ' ')} must be between {lo} and {hi}")
44
+ out[key] = int(v) if key == "min_holdings" else v
45
+ return out
46
+
47
+
48
+ def _money(v: float, currency: str) -> str:
49
+ return f"{currency} {v:,.0f}"
50
+
51
+
52
+ def _pct(v: float, digits: int = 0) -> str:
53
+ return f"{v:.{digits}f}%"
54
+
55
+
56
+ def _finding(id_: str, severity: str, title: str, fact: str, why: str, levers: list[str],
57
+ section: str, rule: Optional[str] = None, symbols: Optional[list[str]] = None) -> dict:
58
+ return {"id": id_, "severity": severity, "title": title, "fact": fact, "why": why, "levers": levers,
59
+ "section": section, "rule": rule, "symbols": symbols or []}
60
+
61
+
62
+ def _first_buys(transactions: list[dict]) -> dict[str, datetime]:
63
+ out: dict[str, datetime] = {}
64
+ for t in transactions or []:
65
+ if str(t.get("transaction_type", "")).lower() != "buy" or not t.get("date"):
66
+ continue
67
+ try:
68
+ d = datetime.fromisoformat(str(t["date"])[:19])
69
+ except ValueError:
70
+ continue
71
+ s = t.get("symbol")
72
+ if s and (s not in out or d < out[s]):
73
+ out[s] = d
74
+ return out
75
+
76
+
77
+ def build(summary: dict, *, analytics: Optional[dict] = None, funds: Optional[dict] = None,
78
+ sectors: Optional[dict[str, str]] = None, limits: Optional[dict] = None,
79
+ names: Optional[dict[str, str]] = None, now: Optional[datetime] = None) -> dict:
80
+ """summary: PortfolioManager summary; analytics: analytics.analyze() output;
81
+ funds: mf_insights.insights() output; sectors: symbol -> broad sector for
82
+ stocks / ETFs; names: display names (fund codes -> scheme names)."""
83
+ from ..services.mf_insights import asset_class
84
+
85
+ limits = clean_limits(limits)
86
+ names = names or {}
87
+ sectors = sectors or {}
88
+ cur = summary.get("currency") or ""
89
+ holdings = [h for h in (summary.get("holdings") or {}).values() if (h.get("market_value") or 0) > 0]
90
+ invested = sum(float(h["market_value"]) for h in holdings)
91
+ cash = float(summary.get("cash_balance") or 0)
92
+ total = invested + max(cash, 0)
93
+ label = lambda s: names.get(s, s) # noqa: E731
94
+ is_fund = lambda h: (h.get("market") or "").lower() == "mf" # noqa: E731
95
+
96
+ findings: list[dict] = []
97
+ good: list[str] = []
98
+ if not holdings or invested <= 0:
99
+ return {"score": None, "grade": None, "findings": [], "good": [], "limits": limits,
100
+ "summary": "Add holdings to get a review."}
101
+
102
+ weights = {h["symbol"]: float(h["market_value"]) / invested for h in holdings}
103
+ by_symbol = {h["symbol"]: h for h in holdings}
104
+
105
+ # 1. One holding too big
106
+ cap = limits["max_holding"] / 100
107
+ big = sorted(((s, w) for s, w in weights.items() if w > cap), key=lambda x: -x[1])
108
+ # A diversified fund spreads its own money; only flag funds well past the cap.
109
+ big = [(s, w) for s, w in big if not is_fund(by_symbol[s]) or w > max(cap, 0.5)]
110
+ if big:
111
+ s, w = big[0]
112
+ others = f" (also {', '.join(f'{label(x)} {_pct(y * 100)}' for x, y in big[1:3])})" if len(big) > 1 else ""
113
+ levers = ["Reduce the position, or add to other holdings so its share falls.",
114
+ "Direct new money elsewhere until it is back under your limit.",
115
+ "Or raise your limit if the size is deliberate."]
116
+ findings.append(_finding(
117
+ "concentration", "high" if w > cap * 1.5 else "medium", "One holding is a big share of the portfolio",
118
+ f"{label(s)} is {_pct(w * 100)} of what you hold{others}.",
119
+ f"A 20% fall in {label(s)} alone would cut the whole portfolio by about {_pct(w * 20, 1)}.",
120
+ levers, HOLDINGS, rule=f"Your limit: {_pct(limits['max_holding'])} per holding",
121
+ symbols=[x for x, _ in big]))
122
+ else:
123
+ good.append(f"No holding is above your {_pct(limits['max_holding'])} limit.")
124
+
125
+ # 2. One sector too big (stocks / ETFs with a known sector)
126
+ by_sector: dict[str, float] = {}
127
+ for h in holdings:
128
+ sec = sectors.get(h["symbol"])
129
+ if sec and sec != "Other" and not is_fund(h):
130
+ by_sector[sec] = by_sector.get(sec, 0) + weights[h["symbol"]]
131
+ if by_sector:
132
+ sec, w = max(by_sector.items(), key=lambda kv: kv[1])
133
+ members = [label(h["symbol"]) for h in holdings if sectors.get(h["symbol"]) == sec]
134
+ if w > limits["max_sector"] / 100:
135
+ findings.append(_finding(
136
+ "sector", "high" if w > 0.6 else "medium", "Heavy in one sector",
137
+ f"{_pct(w * 100)} is in {sec} ({', '.join(members[:5])}{'…' if len(members) > 5 else ''}).",
138
+ "Stocks in one sector tend to fall together on the same news (rates, regulation, a demand slump).",
139
+ ["Add holdings from sectors you don't own yet.",
140
+ f"Put new money outside {sec} for a while.",
141
+ "A broad index fund or ETF spreads money across all sectors in one step."],
142
+ HOLDINGS, rule=f"Your limit: {_pct(limits['max_sector'])} per sector", symbols=members))
143
+ elif len(by_sector) >= 3:
144
+ good.append(f"Stocks are spread over {len(by_sector)} sectors; the largest, {sec}, is {_pct(w * 100)}.")
145
+
146
+ # 3-5. From the risk analytics
147
+ if analytics:
148
+ per = analytics.get("holdings") or []
149
+ risky = [h for h in per if (h.get("risk_contribution") or 0) > max(h["weight"] * 1.5, h["weight"] + 0.1)
150
+ and (h.get("risk_contribution") or 0) >= 0.2]
151
+ if risky and len(per) > 1:
152
+ h = max(risky, key=lambda x: x["risk_contribution"])
153
+ findings.append(_finding(
154
+ "risk_share", "medium", "One holding drives most of the swings",
155
+ f"{label(h['symbol'])} is {_pct(h['weight'] * 100)} of the value but {_pct(h['risk_contribution'] * 100)} "
156
+ f"of the portfolio's ups and downs (volatility {_pct((h.get('volatility') or 0) * 100)} a year).",
157
+ "Your day-to-day result mostly depends on this one position, more than its size suggests.",
158
+ ["A smaller position would bring its risk share closer to its weight.",
159
+ "Steadier holdings that move differently lower its effect.",
160
+ "Or keep it, knowing where the risk sits."],
161
+ ANALYTICS, symbols=[h["symbol"]]))
162
+
163
+ corr = analytics.get("correlation") or {}
164
+ syms, matrix = corr.get("symbols") or [], corr.get("matrix") or []
165
+ fund_syms = {h["symbol"] for h in holdings if is_fund(h)}
166
+ pairs = [(syms[i], syms[j], matrix[i][j]) for i in range(len(syms)) for j in range(i + 1, len(syms))
167
+ if matrix[i][j] is not None and matrix[i][j] >= 0.8
168
+ and not (syms[i] in fund_syms and syms[j] in fund_syms)] # fund pairs: fund check below
169
+ if pairs:
170
+ a, b, c = max(pairs, key=lambda p: p[2])
171
+ more = f" ({len(pairs) - 1} more pair{'s' if len(pairs) > 2 else ''} like this)" if len(pairs) > 1 else ""
172
+ findings.append(_finding(
173
+ "correlation", "medium" if c >= 0.9 else "low", "Holdings that move together",
174
+ f"{label(a)} and {label(b)} have moved almost in step over the last year (correlation {c:.2f}){more}.",
175
+ "Two holdings that rise and fall together give you the risk of one bigger position, not two.",
176
+ ["Keep the one you prefer and put the rest elsewhere.",
177
+ "Add something that moved differently (another sector, bonds, gold)."],
178
+ ANALYTICS, symbols=[a, b]))
179
+
180
+ m = analytics.get("metrics") or {}
181
+ beta, dd = m.get("beta"), m.get("max_drawdown")
182
+ bench = (analytics.get("benchmark") or {}).get("name") or "the index"
183
+ if (beta is not None and beta > 1.2) or (dd is not None and dd < -0.25):
184
+ parts = []
185
+ if beta is not None:
186
+ parts.append(f"beta {beta:.2f} (moves about {beta:.1f}x {bench})")
187
+ if dd is not None:
188
+ parts.append(f"worst fall in the last year {_pct(dd * 100)}")
189
+ findings.append(_finding(
190
+ "market_risk", "medium", "Swings more than the market",
191
+ f"The current mix has {' and '.join(parts)}.",
192
+ f"In a market fall this mix would likely drop more than {bench}; the question is whether you could hold through that.",
193
+ ["Add lower-beta or defensive holdings, or a debt / hybrid fund as a cushion.",
194
+ "Keep some cash so you never have to sell in a fall.",
195
+ "If your horizon is long and you accept the swings, no change is needed."],
196
+ ANALYTICS))
197
+ elif beta is not None:
198
+ good.append(f"Overall swings are close to or below the market (beta {beta:.2f}).")
199
+
200
+ # 6. Asset mix: everything in equity
201
+ equity = sum(weights[h["symbol"]] for h in holdings
202
+ if not is_fund(h) or asset_class(h.get("category", ""), h.get("name", "")) == "Equity")
203
+ if len(holdings) >= 3 and equity > 0.95 and cash / total < 0.05:
204
+ findings.append(_finding(
205
+ "asset_mix", "low", "All in equity",
206
+ f"{_pct(equity * 100)} is in stocks or equity funds, with little cash.",
207
+ "Equity is the growth engine but has no cushion: a 30% market fall hits almost all of it.",
208
+ ["If you need part of this money within 3-5 years, keep that part in debt funds or deposits.",
209
+ "Gold or a hybrid fund can soften falls.",
210
+ "For a long horizon and steady income, all-equity can be a deliberate choice."],
211
+ HOLDINGS))
212
+
213
+ # 7. Number of positions
214
+ stocks = [h for h in holdings if not is_fund(h)]
215
+ n_funds = len(holdings) - len(stocks)
216
+ if stocks and not n_funds and len(stocks) < limits["min_holdings"]:
217
+ findings.append(_finding(
218
+ "few_holdings", "medium", "Few holdings",
219
+ f"The portfolio has {len(stocks)} stock{'s' if len(stocks) != 1 else ''} and no funds.",
220
+ "With few positions, one bad result (a fraud, a profit warning) can do lasting damage.",
221
+ [f"Build up to at least {limits['min_holdings']} holdings from different sectors over time.",
222
+ "Or hold an index fund / ETF alongside your picks as the diversified core."],
223
+ HOLDINGS, rule=f"Your minimum: {limits['min_holdings']} holdings"))
224
+ tiny = [h["symbol"] for h in holdings if weights[h["symbol"]] < 0.02]
225
+ if len(tiny) >= 3:
226
+ findings.append(_finding(
227
+ "tiny", "low", "Many very small positions",
228
+ f"{len(tiny)} holdings are under 2% each ({', '.join(label(s) for s in tiny[:5])}{'…' if len(tiny) > 5 else ''}).",
229
+ "Positions this small barely move the result but still need tracking.",
230
+ ["Build up the ones you believe in to a size that matters.",
231
+ "Close or merge the rest to simplify."],
232
+ HOLDINGS, symbols=tiny))
233
+
234
+ # 8. Idle cash
235
+ if total > 0 and cash / total * 100 > limits["max_cash"]:
236
+ findings.append(_finding(
237
+ "cash", "low", "A lot of cash waiting",
238
+ f"Cash is {_money(cash, cur)}, {_pct(cash / total * 100)} of the portfolio.",
239
+ "Cash is safe but loses to inflation over time.",
240
+ ["Invest it in steps (e.g. monthly) rather than all at once.",
241
+ "Park it in a liquid fund or sweep deposit while you decide.",
242
+ "Keep it if it is your emergency fund or earmarked for a near-term need."],
243
+ HOLDINGS, rule=f"Your limit: {_pct(limits['max_cash'])} in cash"))
244
+
245
+ # 9. Big losers
246
+ first = _first_buys(summary.get("transactions") or [])
247
+ now = now or datetime.now()
248
+ losers = sorted(((h["symbol"], float(h.get("unrealized_gain_percent") or 0)) for h in holdings
249
+ if (h.get("unrealized_gain_percent") or 0) <= -limits["loss_review"]), key=lambda x: x[1])
250
+ if losers:
251
+ def age(s: str) -> str:
252
+ d = first.get(s)
253
+ if not d:
254
+ return ""
255
+ months = (now - d).days // 30
256
+ return f", held {months // 12}y {months % 12}m" if months >= 12 else f", held {months}m"
257
+ loss_value = sum(float(h.get("unrealized_gain") or 0) for h in holdings if h["symbol"] in {s for s, _ in losers})
258
+ findings.append(_finding(
259
+ "losers", "medium", "Positions well below what you paid",
260
+ "; ".join(f"{label(s)} {_pct(p, 1)}{age(s)}" for s, p in losers[:4]) + f". Unrealized loss {_money(-loss_value, cur)}.",
261
+ "A big loss is worth a fresh look: is the reason you bought still true, or are you waiting to get even?",
262
+ ["Re-check the original reason for owning it (results, debt, management, sector).",
263
+ "Booked losses can offset gains for tax; check the rules for your country or ask a tax adviser.",
264
+ "If the reason still holds, you may keep it; set a review date so it is a decision, not a habit."],
265
+ HOLDINGS, rule=f"Your review level: {_pct(limits['loss_review'])} below cost", symbols=[s for s, _ in losers]))
266
+
267
+ # 10. Mutual fund health (overlap, plan costs, options)
268
+ if funds and funds.get("funds"):
269
+ kinds = funds.get("issue_kinds") or []
270
+ texts = funds.get("issues") or []
271
+ fund_levers = {
272
+ "same_category": ("Funds doing the same job", "medium",
273
+ "Two funds in one category usually own many of the same stocks; you pay twice for one exposure.",
274
+ ["Keep the one with the better long-term record and lower cost.",
275
+ "Stop new SIPs in the other first; switch out later with exit load and tax in mind."]),
276
+ "lockstep": ("Funds moving in lockstep", "medium",
277
+ "Funds that move together add cost and paperwork, not diversification.",
278
+ ["Consolidate into fewer funds.",
279
+ "Use the slot for something different (mid/small cap, international, debt)."]),
280
+ "regular_plan": ("Paying for Regular plans", "medium",
281
+ "Regular plans pay a distributor commission every year, usually 0.5-1% more than Direct.",
282
+ ["Switch future SIPs to the Direct plan of the same fund.",
283
+ "Move existing units when exit load and capital gains tax make it worth it."]),
284
+ "idcw": ("Dividend (IDCW) options", "low",
285
+ "IDCW payouts are taxed at your income slab every year and stop compounding.",
286
+ ["Growth option of the same fund suits long-term goals better.",
287
+ "Need regular income? A planned withdrawal (SWP) from Growth is usually more tax-efficient."]),
288
+ "one_asset_class": ("Funds all in one asset class", "low",
289
+ "No debt or hybrid cushion among your funds.",
290
+ ["Add a debt or hybrid fund for money you may need sooner."]),
291
+ }
292
+ for kind, text in zip(kinds, texts):
293
+ if kind == "one_asset_class" and any(f["id"] == "asset_mix" for f in findings):
294
+ continue
295
+ title, sev, why, levers = fund_levers.get(kind, ("Mutual fund check", "low", "", []))
296
+ findings.append(_finding(f"fund_{kind}", sev, title, text, why, levers, FUND_HEALTH))
297
+ if not kinds:
298
+ good.append("Mutual funds: no overlap, plan-cost or option issues found.")
299
+
300
+ findings.sort(key=lambda f: SEVERITY_ORDER[f["severity"]])
301
+ score = max(0, 100 - sum(SEVERITY_COST[f["severity"]] for f in findings))
302
+ grade = "Good" if score >= 80 else "Fair" if score >= 60 else "Needs attention"
303
+ return {
304
+ "score": score,
305
+ "grade": grade,
306
+ "findings": findings,
307
+ "good": good,
308
+ "limits": limits,
309
+ "summary": (f"{len(findings)} thing{'s' if len(findings) != 1 else ''} worth a look"
310
+ if findings else "Nothing stands out against your limits."),
311
+ "as_of": (analytics or {}).get("as_of"),
312
+ }
313
+
314
+
315
+ # ------------------------------------------------------------- one call for API and bot
316
+
317
+ def for_portfolio(manager, name: str, config) -> dict:
318
+ """Builds the portfolio review from the summary, risk analytics, fund
319
+ health check, stock sectors and the user's own limits."""
320
+ from ..data.listings import get_store
321
+ from ..services.mf_holdings import MARKET, latest
322
+ from ..services.mf_insights import insights
323
+ from .analytics import MF_PREFIX, _download_closes, analyze
324
+
325
+ summary = manager.get_portfolio_summary(name)
326
+ for h in summary.get("holdings", {}).values(): # scheme name / category on fund holdings
327
+ if (h.get("market") or "").lower() == MARKET and (info := latest(h["symbol"])):
328
+ h.update(name=info["name"], category=info["category"])
329
+ holdings = list(summary["holdings"].values())
330
+ try:
331
+ risk = analyze(holdings)
332
+ except ValueError:
333
+ risk = None
334
+ funds = [h for h in holdings if (h.get("market") or "").lower() == MARKET]
335
+ fund_check = None
336
+ if funds:
337
+ closes = _download_closes([MF_PREFIX + f["symbol"] for f in funds])
338
+ fund_check = insights(funds, {k[len(MF_PREFIX):]: v for k, v in closes.items()})
339
+ keys = [((h.get("market") or "us").lower(), kind, h["symbol"]) for h in holdings
340
+ if (h.get("market") or "").lower() != MARKET for kind in ("stock", "etf")]
341
+ try:
342
+ rows = get_store().lookup(keys) if keys else {}
343
+ except Exception: # sectors are a nice-to-have; the store may be cold
344
+ rows = {}
345
+ sectors = {sym: r.get("category") for (_, _, sym), r in rows.items() if r.get("category")}
346
+ names = {h["symbol"]: h["name"].split(" - ")[0] for h in funds if h.get("name")}
347
+ return build(summary, analytics=risk, funds=fund_check, sectors=sectors, names=names,
348
+ limits=get_limits(config, name))
349
+
350
+
351
+ # ------------------------------------------------------------- the user's limits
352
+
353
+ def get_limits(config, name: str) -> dict:
354
+ from ..services.mf_lumpsum import _portfolio
355
+ from ..storage import DatabaseManager, PortfolioReviewRuleRecord, get_db_url
356
+
357
+ with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
358
+ p = _portfolio(session, name)
359
+ row = session.query(PortfolioReviewRuleRecord).filter_by(portfolio_id=p.id).first()
360
+ saved = dict(row.limits or {}) if row else {}
361
+ return clean_limits(saved)
362
+
363
+
364
+ def set_limits(config, name: str, values: dict) -> dict:
365
+ from ..services.mf_lumpsum import _portfolio
366
+ from ..storage import DatabaseManager, PortfolioReviewRuleRecord, get_db_url
367
+
368
+ limits = clean_limits(values)
369
+ with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
370
+ p = _portfolio(session, name)
371
+ row = session.query(PortfolioReviewRuleRecord).filter_by(portfolio_id=p.id).first()
372
+ row = row or PortfolioReviewRuleRecord(portfolio_id=p.id)
373
+ row.limits = limits
374
+ session.add(row)
375
+ return limits
376
+
377
+
378
+ # ------------------------------------------------------------- AI write-up
379
+
380
+ def ai_summary(review: dict, config) -> Optional[dict]:
381
+ """A short analyst-style read of the review: an explanation without buy /
382
+ sell advice (marketmyna/core/compliance.py)."""
383
+ from ..llm.backend_factory import create_generation_backend
384
+ from ..llm.json_reply import parse_object
385
+ from ..llm.protocol import GenerationConfig
386
+
387
+ system = (
388
+ "You explain portfolio data to retail investors for an analytics tool that is not a registered "
389
+ "adviser. Using only the findings given, explain which ones matter most and why, and what the user "
390
+ "could look into. Never tell them to buy, sell, hold or switch a specific security and give no target "
391
+ "allocation. Reply with ONLY a JSON object: "
392
+ '{"summary": "2-3 sentences", "priorities": ["up to 3 points to look into, most important first"], '
393
+ '"keep": "one thing that looks fine"}'
394
+ )
395
+ lines = [f"Score {review.get('score')}/100 ({review.get('grade')})."]
396
+ for f in review.get("findings") or []:
397
+ lines.append(f"- [{f['severity']}] {f['title']}: {f['fact']} {f.get('rule') or ''}")
398
+ for g in review.get("good") or []:
399
+ lines.append(f"+ {g}")
400
+ try:
401
+ result = create_generation_backend(config).generate(
402
+ "\n".join(lines), GenerationConfig(max_tokens=600, timeout=90), system_prompt=system)
403
+ except Exception as exc:
404
+ logger.warning("Portfolio AI review unavailable: %s", exc)
405
+ raise
406
+ data = parse_object(result.text)
407
+ if not data or not data.get("summary"):
408
+ return None
409
+ return {"summary": str(data["summary"]),
410
+ "priorities": [str(p) for p in (data.get("priorities") or [])][:3],
411
+ "keep": data.get("keep")}
@@ -0,0 +1 @@
1
+ """In-process background services (scheduler, ...) for the MarketMyna API."""