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