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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- marketmyna-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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)))}
|