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,6 @@
1
+ """Portfolio management module."""
2
+
3
+ from .manager import PortfolioManager
4
+ from .models import Holding, Portfolio, Transaction
5
+
6
+ __all__ = ["Portfolio", "Holding", "Transaction", "PortfolioManager"]
@@ -0,0 +1,336 @@
1
+ """Portfolio risk analytics: return / risk ratios, drawdown, beta, VaR, per-holding
2
+ risk contribution, correlation and an optimizer suggestion; tracking error,
3
+ information ratio and up / down capture against the benchmark; stress
4
+ scenarios (past market falls and flat index shocks, via each holding's beta);
5
+ and default inputs for the Monte Carlo projection the page runs in the
6
+ browser (frontend/src/lib/montecarlo.ts).
7
+
8
+ The current allocation is replayed over the last year of daily closes (the
9
+ usual "what would today's mix have done" view). Ratio definitions follow the
10
+ OpenBB quantitative extension (Apache 2.0); the optimizers are the existing
11
+ marketmyna/ai/portfolio_optimizer.py.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import math
18
+ from typing import Callable, Optional
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from ..core.numbers import finite as _f
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ TRADING_DAYS = 252
28
+ # Annual risk-free rate by market (rough 1-year government yield).
29
+ RISK_FREE = {"india": 0.065, "us": 0.04, "china_a": 0.015, "hong_kong": 0.035, "crypto": 0.04}
30
+ MIN_BARS = 60
31
+
32
+ # Market falls replayed in the stress test: (label, start = the peak, end = the trough)
33
+ # on the market's benchmark, with each holding moving by its beta times the index.
34
+ EPISODES = [
35
+ ("2008 financial crisis", "2007-10-01", "2009-03-31"),
36
+ ("2020 Covid crash", "2020-01-14", "2020-03-31"),
37
+ ("2022 rate-rise sell-off", "2021-10-01", "2022-10-31"),
38
+ ]
39
+ SHOCKS = (-0.10, -0.20, -0.30)
40
+ # Long-run return assumption for the Monte Carlo projection (nominal, a year):
41
+ # trailing one-year returns are far too noisy to project from.
42
+ LONG_RUN_RETURN = {"india": 0.11, "us": 0.08, "hong_kong": 0.07, "china_a": 0.07, "crypto": 0.10}
43
+ _long_bench: dict[str, tuple[float, pd.Series]] = {}
44
+
45
+ Fetch = Callable[[list[str]], dict[str, pd.Series]]
46
+
47
+
48
+ MF_PREFIX = "MF:" # mutual fund holdings: NAV history by AMFI scheme code
49
+
50
+
51
+ def _download_closes(tickers: list[str]) -> dict[str, pd.Series]:
52
+ from ..trading.scanner import _cache
53
+
54
+ funds = [t for t in tickers if t.startswith(MF_PREFIX)]
55
+ stocks = [t for t in tickers if not t.startswith(MF_PREFIX)]
56
+ out = {t: df["close"].astype(float) for t, df in (_cache.get(stocks, "1d", "1y") if stocks else {}).items()}
57
+ if funds:
58
+ from ..data.mutual_funds import MutualFundDataFetcher
59
+
60
+ mf = MutualFundDataFetcher()
61
+ for t in funds:
62
+ df = mf.get_historical_nav(int(t[len(MF_PREFIX):]), days=TRADING_DAYS + 10)
63
+ if not df.empty:
64
+ out[t] = df["nav"].astype(float)
65
+ return out
66
+
67
+
68
+ def _market(value: str) -> str:
69
+ m = (value or "us").lower()
70
+ return "india" if m == "mf" else m # Indian mutual funds: INR, Nifty benchmark
71
+
72
+
73
+ def _benchmark(market: str) -> tuple[str, str]:
74
+ from ..services.analysis_run import MARKET_INDICES
75
+
76
+ return (MARKET_INDICES.get(market) or MARKET_INDICES["us"])[0]
77
+
78
+
79
+ def _drawdown(returns: pd.Series) -> float:
80
+ curve = (1 + returns).cumprod()
81
+ return float((curve / curve.cummax() - 1).min())
82
+
83
+
84
+ def _benchmark_long(ticker: str) -> Optional[pd.Series]:
85
+ """Full daily history of the benchmark (for the stress windows), kept a day."""
86
+ import time
87
+
88
+ hit = _long_bench.get(ticker)
89
+ if hit and time.time() - hit[0] < 86400:
90
+ return hit[1]
91
+ try:
92
+ import yfinance as yf
93
+
94
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
95
+
96
+ with YF_DOWNLOAD_LOCK:
97
+ df = yf.download(ticker, start="2007-06-01", interval="1d", progress=False, auto_adjust=True)
98
+ close = df["Close"]
99
+ close = close.iloc[:, 0] if isinstance(close, pd.DataFrame) else close
100
+ close.index = pd.DatetimeIndex(close.index).tz_localize(None)
101
+ close = close.dropna()
102
+ except Exception as exc:
103
+ logger.info("Benchmark history %s unavailable: %s", ticker, exc)
104
+ return None
105
+ _long_bench[ticker] = (time.time(), close)
106
+ return close
107
+
108
+
109
+ def stress(weights: dict[str, float], betas: dict[str, Optional[float]], bench: Optional[pd.Series]) -> list[dict]:
110
+ """Estimated portfolio move in past market falls and flat index shocks:
111
+ each holding moves by its beta times the index (beta 1 when unknown)."""
112
+ port_beta = sum(w * (betas.get(s) if betas.get(s) is not None else 1.0) for s, w in weights.items())
113
+ out = []
114
+ if bench is not None and len(bench):
115
+ for label, start, end in EPISODES:
116
+ window = bench[(bench.index >= start) & (bench.index <= end)]
117
+ if len(window) < 10:
118
+ continue
119
+ peak_at = window.idxmax()
120
+ after = window[window.index >= peak_at]
121
+ trough_at = after.idxmin()
122
+ move = float(after.min() / window.max() - 1)
123
+ out.append({"label": label, "index_move": move, "portfolio_move": move * port_beta,
124
+ "from": str(peak_at.date()), "to": str(trough_at.date())})
125
+ for shock in SHOCKS:
126
+ out.append({"label": f"Index falls {abs(shock):.0%}", "index_move": shock, "portfolio_move": shock * port_beta,
127
+ "from": None, "to": None})
128
+ return out
129
+
130
+
131
+ def versus_benchmark(port: pd.Series, bench: pd.Series) -> dict:
132
+ """Tracking error, information ratio and up / down capture on daily returns."""
133
+ pair = pd.concat([port, bench], axis=1, keys=["p", "b"]).dropna()
134
+ if len(pair) < MIN_BARS - 1:
135
+ return {}
136
+ active = pair["p"] - pair["b"]
137
+ te = float(active.std()) * math.sqrt(TRADING_DAYS)
138
+ ann = lambda r: float((1 + r).prod()) ** (TRADING_DAYS / len(r)) - 1 # noqa: E731
139
+ up, down = pair[pair["b"] > 0], pair[pair["b"] < 0]
140
+ return {
141
+ "tracking_error": te,
142
+ "information_ratio": _f((ann(pair["p"]) - ann(pair["b"])) / te) if te else None,
143
+ "up_capture": _f(up["p"].mean() / up["b"].mean()) if len(up) else None,
144
+ "down_capture": _f(down["p"].mean() / down["b"].mean()) if len(down) else None,
145
+ }
146
+
147
+
148
+ def ratios(returns: pd.Series, rf: float) -> dict:
149
+ """Annualised return / volatility, Sharpe, Sortino, max drawdown, 1-day 95% VaR / CVaR."""
150
+ mean, std = float(returns.mean()), float(returns.std())
151
+ ann_return = float((1 + returns).prod()) ** (TRADING_DAYS / len(returns)) - 1
152
+ ann_vol = std * math.sqrt(TRADING_DAYS)
153
+ downside = returns[returns < 0]
154
+ down_dev = float(np.sqrt((downside ** 2).sum() / len(returns))) * math.sqrt(TRADING_DAYS)
155
+ var = float(np.percentile(returns, 5))
156
+ tail = returns[returns <= var]
157
+ return {
158
+ "annual_return": ann_return,
159
+ "volatility": ann_vol,
160
+ "sharpe": _f((mean * TRADING_DAYS - rf) / ann_vol) if ann_vol else None,
161
+ "sortino": _f((mean * TRADING_DAYS - rf) / down_dev) if down_dev else None,
162
+ "max_drawdown": _drawdown(returns),
163
+ "var_95": -var,
164
+ "cvar_95": -float(tail.mean()) if len(tail) else -var,
165
+ }
166
+
167
+
168
+ def analyze(holdings: list[dict], fetch: Fetch = _download_closes) -> dict:
169
+ """holdings: [{symbol, market, market_value}] -> analytics payload.
170
+
171
+ Raises ValueError when there is nothing to analyse."""
172
+ from ..services.analysis_run import yahoo_symbol
173
+
174
+ rows = [h for h in holdings if (_f(h.get("market_value")) or 0) > 0]
175
+ if not rows:
176
+ raise ValueError("No holdings with a current value to analyse")
177
+ warnings: list[str] = []
178
+
179
+ value_by_market: dict[str, float] = {}
180
+ for h in rows:
181
+ value_by_market[_market(h.get("market"))] = value_by_market.get(_market(h.get("market")), 0) + float(h["market_value"])
182
+ main_market = max(value_by_market, key=value_by_market.get)
183
+ if len(value_by_market) > 1:
184
+ warnings.append("Holdings span several markets; weights mix currencies without conversion.")
185
+ bench_ticker, bench_name = _benchmark(main_market)
186
+ rf = RISK_FREE.get(main_market, 0.04)
187
+
188
+ ticker_of = {h["symbol"]: MF_PREFIX + h["symbol"] if (h.get("market") or "").lower() == "mf"
189
+ else yahoo_symbol(h["symbol"], _market(h.get("market"))) for h in rows}
190
+ closes = fetch(list(dict.fromkeys([*ticker_of.values(), bench_ticker])))
191
+
192
+ series = {}
193
+ for sym, t in ticker_of.items():
194
+ s = closes.get(t)
195
+ if s is None or len(s.dropna()) < MIN_BARS:
196
+ warnings.append(f"{sym}: not enough price history, left out.")
197
+ continue
198
+ series[sym] = s
199
+ if not series:
200
+ raise ValueError("No price history available for these holdings")
201
+
202
+ prices = pd.DataFrame(series)
203
+ prices.index = pd.DatetimeIndex(prices.index).tz_localize(None).normalize()
204
+ prices = prices[~prices.index.duplicated(keep="last")].sort_index().ffill().dropna()
205
+ returns = prices.pct_change().dropna()
206
+ if len(returns) < MIN_BARS - 1:
207
+ raise ValueError("Not enough overlapping price history across holdings")
208
+
209
+ values = {h["symbol"]: float(h["market_value"]) for h in rows if h["symbol"] in prices}
210
+ total = sum(values.values())
211
+ weights = pd.Series({s: values[s] / total for s in prices.columns})
212
+ port = returns.dot(weights)
213
+
214
+ bench = closes.get(bench_ticker)
215
+ bench_ret = None
216
+ if bench is not None and len(bench.dropna()) >= MIN_BARS:
217
+ b = bench.copy()
218
+ b.index = pd.DatetimeIndex(b.index).tz_localize(None).normalize()
219
+ b = b[~b.index.duplicated(keep="last")].sort_index()
220
+ bench_ret = b.reindex(prices.index).ffill().pct_change().reindex(returns.index)
221
+ else:
222
+ warnings.append(f"Benchmark {bench_name} unavailable; beta not computed.")
223
+
224
+ def beta(r: pd.Series) -> Optional[float]:
225
+ if bench_ret is None:
226
+ return None
227
+ pair = pd.concat([r, bench_ret], axis=1).dropna()
228
+ var = float(pair.iloc[:, 1].var())
229
+ return _f(float(pair.iloc[:, 0].cov(pair.iloc[:, 1])) / var) if var else None
230
+
231
+ metrics = ratios(port, rf)
232
+ metrics["beta"] = beta(port)
233
+ if bench_ret is not None:
234
+ metrics.update(versus_benchmark(port, bench_ret))
235
+ metrics["var_95_value"] = metrics["var_95"] * total
236
+ metrics["cvar_95_value"] = metrics["cvar_95"] * total
237
+
238
+ cov = returns.cov() * TRADING_DAYS
239
+ port_var = float(weights @ cov @ weights)
240
+ contrib = weights * (cov @ weights) / port_var if port_var else weights * 0
241
+ per_holding = []
242
+ for sym in prices.columns:
243
+ r = ratios(returns[sym], rf)
244
+ per_holding.append({
245
+ "symbol": sym, "weight": float(weights[sym]), "annual_return": r["annual_return"],
246
+ "volatility": r["volatility"], "max_drawdown": r["max_drawdown"], "beta": beta(returns[sym]),
247
+ "risk_contribution": _f(contrib[sym]),
248
+ })
249
+ per_holding.sort(key=lambda x: -x["weight"])
250
+
251
+ top = max(per_holding, key=lambda x: x["risk_contribution"] or 0)
252
+ if len(per_holding) > 1 and (top["risk_contribution"] or 0) > max(0.4, top["weight"] * 1.5):
253
+ warnings.append(f"{top['symbol']} carries {top['risk_contribution']:.0%} of portfolio risk "
254
+ f"on a {top['weight']:.0%} weight.")
255
+
256
+ corr = returns.corr()
257
+ pairs = [(a, b, float(corr.loc[a, b])) for i, a in enumerate(corr.columns) for b in corr.columns[i + 1:]]
258
+ high = [p for p in pairs if p[2] >= 0.8]
259
+ if high:
260
+ a, b, c = max(high, key=lambda p: p[2])
261
+ warnings.append(f"{a} and {b} move together (correlation {c:.2f}); they diversify little.")
262
+
263
+ curve = pd.DataFrame({"portfolio": (1 + port).cumprod() * 100})
264
+ if bench_ret is not None:
265
+ curve["benchmark"] = (1 + bench_ret.fillna(0)).cumprod() * 100
266
+ curve = curve.resample("W-FRI").last().dropna(how="all")
267
+ if bench_ret is not None:
268
+ metrics["benchmark_return"] = ratios(bench_ret.dropna(), rf)["annual_return"]
269
+
270
+ betas = {h["symbol"]: h["beta"] for h in per_holding}
271
+ stress_rows = stress(weights.to_dict(), betas, _benchmark_long(bench_ticker) if fetch is _download_closes else None)
272
+
273
+ return {
274
+ "as_of": str(returns.index[-1].date()),
275
+ "stress": [{k: (_f(v) if isinstance(v, float) else v) for k, v in r.items()} for r in stress_rows],
276
+ "projection": {"expected_return": LONG_RUN_RETURN.get(main_market, 0.08),
277
+ "volatility": _f(metrics["volatility"])},
278
+ "days": len(returns),
279
+ "market": main_market,
280
+ "benchmark": {"symbol": bench_ticker, "name": bench_name},
281
+ "risk_free": rf,
282
+ "total_value": total,
283
+ "metrics": {k: _f(v) for k, v in metrics.items()},
284
+ "holdings": per_holding,
285
+ "correlation": {"symbols": list(corr.columns),
286
+ "matrix": [[_f(v) for v in row] for row in corr.to_numpy()]},
287
+ "optimizer": _optimize(prices, weights, total, rf) if len(prices.columns) >= 2 else None,
288
+ "curve": [{"time": str(i.date()), **{k: _f(v) for k, v in row.items()}} for i, row in curve.iterrows()],
289
+ "warnings": warnings,
290
+ }
291
+
292
+
293
+ def _optimize(prices: pd.DataFrame, weights: pd.Series, total: float, rf: float) -> dict:
294
+ """Max-Sharpe, risk-parity, hierarchical risk parity and minimum-CVaR target
295
+ weights, plus the trades to reach the max-Sharpe mix."""
296
+ from ..ai.portfolio_optimizer import PortfolioOptimizer
297
+
298
+ opt = PortfolioOptimizer(risk_free_rate=rf)
299
+ returns = opt.calculate_returns(prices)
300
+ # Uncapped max-Sharpe piles everything into the best past performer; cap each holding
301
+ cap = min(1.0, max(0.4, 1.5 / len(prices.columns)))
302
+ out: dict = {"current": _mix(opt, weights.to_numpy(), returns), "max_weight": cap}
303
+ try:
304
+ result = opt.optimize(prices, current_weights=weights.to_dict(), portfolio_value=total,
305
+ method="mean_variance", max_weight=cap)
306
+ out["max_sharpe"] = {**_mix(opt, np.array([result.optimal_weights[s] for s in prices.columns]), returns),
307
+ "weights": {s: round(w, 4) for s, w in result.optimal_weights.items()}}
308
+ out["trades"] = result.rebalance_trades
309
+ out["suggestions"] = result.suggestions
310
+ except Exception as exc:
311
+ logger.warning("Max-Sharpe optimisation failed: %s", exc)
312
+ try:
313
+ rp = opt.risk_parity_optimization(returns)
314
+ rp = rp / rp.sum()
315
+ out["risk_parity"] = {**_mix(opt, rp, returns),
316
+ "weights": {s: round(float(w), 4) for s, w in zip(prices.columns, rp)}}
317
+ except Exception as exc:
318
+ logger.warning("Risk-parity optimisation failed: %s", exc)
319
+ from .optimizers import hrp, min_cvar
320
+
321
+ for key, build in (("hrp", lambda: hrp(returns)), ("min_cvar", lambda: min_cvar(returns, max_weight=cap))):
322
+ try:
323
+ w = build().reindex(prices.columns).fillna(0)
324
+ out[key] = {**_mix(opt, w.to_numpy(), returns),
325
+ "weights": {s: round(float(v), 4) for s, v in w.items()}}
326
+ except Exception as exc:
327
+ logger.warning("%s optimisation failed: %s", key, exc)
328
+ return out
329
+
330
+
331
+ def _mix(opt, w: np.ndarray, returns: pd.DataFrame) -> dict:
332
+ from .optimizers import cvar
333
+
334
+ r, vol, sharpe = opt.calculate_portfolio_metrics(w, returns)
335
+ return {"expected_return": _f(r), "volatility": _f(vol), "sharpe": _f(sharpe),
336
+ "cvar_95": _f(cvar(returns.to_numpy() @ np.asarray(w, dtype=float)))}