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