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,195 @@
|
|
|
1
|
+
"""AI review of a whole mutual fund portfolio (Portfolio page).
|
|
2
|
+
|
|
3
|
+
Builds a fact sheet from what the page already computes - the fund table
|
|
4
|
+
(marketmyna/services/mf_lumpsum.portfolio_funds) and the health check
|
|
5
|
+
(marketmyna/services/mf_insights.insights) - plus a look-through of the stocks and
|
|
6
|
+
sectors held across all funds, then asks the model to explain it. Wording
|
|
7
|
+
follows marketmyna/core/compliance.py: the owner gets a view and suggested actions,
|
|
8
|
+
public users an explanation without a recommendation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor, wait
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
CACHE_SECONDS = 6 * 3600
|
|
24
|
+
# Reasoning models (gpt-oss, Gemini thinking) spend part of max_tokens before
|
|
25
|
+
# answering; too tight a budget cuts the JSON off mid-way.
|
|
26
|
+
MAX_TOKENS = 4500
|
|
27
|
+
LOOK_THROUGH_SECONDS = 60 # holdings files are cached after the first read; don't hang on a slow one
|
|
28
|
+
MIN_COVERAGE = 50 # % of the portfolio whose holdings must be known to show the look-through
|
|
29
|
+
_cache: dict[tuple, tuple[float, dict]] = {}
|
|
30
|
+
_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _r(x, nd=1):
|
|
35
|
+
return round(x, nd) if isinstance(x, (int, float)) else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _short(name: str) -> str:
|
|
39
|
+
return (name or "").split(" - ")[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def look_through(weights: dict[str, float]) -> dict:
|
|
43
|
+
"""Stocks and sectors across all funds, each fund's holdings scaled by its
|
|
44
|
+
share of the portfolio. Funds whose holdings can't be read are skipped and
|
|
45
|
+
reported in `covered_pct`."""
|
|
46
|
+
from . import fund_holdings
|
|
47
|
+
|
|
48
|
+
pool = ThreadPoolExecutor(max_workers=8)
|
|
49
|
+
futures = {pool.submit(fund_holdings.for_fund, code): code for code in weights}
|
|
50
|
+
done, _ = wait(futures, timeout=LOOK_THROUGH_SECONDS)
|
|
51
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
52
|
+
|
|
53
|
+
stocks: dict[str, float] = {}
|
|
54
|
+
sectors: dict[str, float] = {}
|
|
55
|
+
covered = 0.0
|
|
56
|
+
for fut in done:
|
|
57
|
+
try:
|
|
58
|
+
h = fut.result()
|
|
59
|
+
except Exception:
|
|
60
|
+
continue
|
|
61
|
+
if not isinstance(h, dict) or h.get("status") != "ok":
|
|
62
|
+
continue
|
|
63
|
+
w = weights[futures[fut]]
|
|
64
|
+
covered += w
|
|
65
|
+
for x in h.get("holdings") or []:
|
|
66
|
+
if x.get("kind") not in ("equity", "foreign_equity") or not x.get("weight"):
|
|
67
|
+
continue
|
|
68
|
+
stocks[x["name"]] = stocks.get(x["name"], 0) + w * x["weight"]
|
|
69
|
+
for s in h.get("sectors") or []:
|
|
70
|
+
if s.get("weight"):
|
|
71
|
+
sectors[s["name"]] = sectors.get(s["name"], 0) + w * s["weight"]
|
|
72
|
+
|
|
73
|
+
def top(d, n):
|
|
74
|
+
return [f"{k} {_r(v)}%" for k, v in sorted(d.items(), key=lambda kv: -kv[1])[:n]]
|
|
75
|
+
|
|
76
|
+
return {"covered_pct": _r(covered * 100, 0), "top_stocks_of_portfolio": top(stocks, 10),
|
|
77
|
+
"top_sectors_of_portfolio": top(sectors, 8)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def facts(funds_view: dict, health: dict) -> dict:
|
|
81
|
+
funds = [f for f in funds_view.get("funds") or [] if (f.get("value") or 0) > 0]
|
|
82
|
+
total = sum(f["value"] for f in funds)
|
|
83
|
+
invested = sum(f.get("invested") or 0 for f in funds)
|
|
84
|
+
weights = {f["code"]: f["value"] / total for f in funds} if total else {}
|
|
85
|
+
out: dict = {
|
|
86
|
+
"funds": len(funds),
|
|
87
|
+
"invested": _r(invested, 0),
|
|
88
|
+
"value": _r(total, 0),
|
|
89
|
+
"gain_pct": _r((total - invested) / invested * 100, 2) if invested else None,
|
|
90
|
+
"holdings": [
|
|
91
|
+
{"code": f["code"], "fund": _short(f.get("name")), "category": f.get("category"),
|
|
92
|
+
"asset_class": f.get("asset_class"), "weight_pct": _r(weights[f["code"]] * 100),
|
|
93
|
+
"gain_pct": _r(f.get("gain_pct"), 2),
|
|
94
|
+
"below_high_pct": _r(((f.get("lumpsum") or {}).get("below_high") or 0) * 100)
|
|
95
|
+
if (f.get("lumpsum") or {}).get("status") == "ok" else None}
|
|
96
|
+
for f in sorted(funds, key=lambda f: -f["value"])
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
if health.get("funds"):
|
|
100
|
+
out["health_check"] = {
|
|
101
|
+
"score": health.get("score"), "grade": health.get("grade"),
|
|
102
|
+
"asset_classes_pct": {a["name"]: _r(a["weight"] * 100) for a in health.get("asset_classes") or []},
|
|
103
|
+
"categories": [f"{c['name']} {_r(c['weight'] * 100)}% ({c['count']} funds)" for c in health.get("categories") or []],
|
|
104
|
+
"funds_that_move_together": [f"{p['a_name']} & {p['b_name']} (correlation {_r(p['correlation'], 2)})"
|
|
105
|
+
for p in (health.get("similar_pairs") or [])[:5]],
|
|
106
|
+
"issues": list(health.get("issues") or [])[:6],
|
|
107
|
+
}
|
|
108
|
+
if weights:
|
|
109
|
+
lt = look_through(weights)
|
|
110
|
+
if (lt["covered_pct"] or 0) >= MIN_COVERAGE:
|
|
111
|
+
out["look_through"] = lt
|
|
112
|
+
else: # a few small funds' stocks would misstate the whole portfolio
|
|
113
|
+
out["look_through"] = {"covered_pct": lt["covered_pct"],
|
|
114
|
+
"note": "Holdings are known for too little of the portfolio; say nothing about its stocks or sectors."}
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _system() -> str:
|
|
119
|
+
shape = ('{"summary": "3-4 sentences on the portfolio as a whole", "strengths": ["2-4 short points"], '
|
|
120
|
+
'"concerns": ["2-4 short points"], "sections": {"mix": "1-2 sentences", "overlap": "1-2 sentences", '
|
|
121
|
+
'"concentration": "1-2 sentences", "risk": "1-2 sentences"}, '
|
|
122
|
+
'"funds": [{"code": "scheme code from the data", "note": "one sentence on this fund\'s role in the portfolio"}]')
|
|
123
|
+
return (
|
|
124
|
+
"You explain a mutual fund portfolio to its owner for an analytics tool that is not registered with SEBI as "
|
|
125
|
+
"an investment adviser or research analyst. Using only the figures given, describe how the portfolio is made "
|
|
126
|
+
"up: asset mix, categories, funds that move together, look-through stocks and sectors, and how concentrated "
|
|
127
|
+
"it is. Gains since a recent purchase are too short to say anything about a fund. Describe observations "
|
|
128
|
+
"only. Never recommend buying, selling, switching, adding, merging or rebalancing, and give no view on "
|
|
129
|
+
"whether the portfolio or any fund is good. Reply with ONLY a JSON object: " + shape + "}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse(text: str, codes: set[str]) -> Optional[dict]:
|
|
134
|
+
from ..llm.json_reply import parse_object
|
|
135
|
+
|
|
136
|
+
data = parse_object(text)
|
|
137
|
+
if not data or not data.get("summary"):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def points(key, n=4):
|
|
141
|
+
return [str(x) for x in (data.get(key) or []) if x][:n]
|
|
142
|
+
|
|
143
|
+
sections = data.get("sections") if isinstance(data.get("sections"), dict) else {}
|
|
144
|
+
notes = [{"code": str(f.get("code")), "note": str(f["note"])} for f in data.get("funds") or []
|
|
145
|
+
if isinstance(f, dict) and f.get("note") and str(f.get("code")) in codes]
|
|
146
|
+
out = {
|
|
147
|
+
"summary": str(data["summary"]),
|
|
148
|
+
"strengths": points("strengths"),
|
|
149
|
+
"concerns": points("concerns"),
|
|
150
|
+
"sections": {k: str(sections[k]) for k in ("mix", "overlap", "concentration", "risk") if sections.get(k)},
|
|
151
|
+
"funds": notes,
|
|
152
|
+
"view": None, # no verdict or actions (marketmyna/core/compliance.py)
|
|
153
|
+
"actions": [],
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def review(name: str, funds_view: dict, health: dict, config, use_cache: bool = True) -> Optional[dict]:
|
|
159
|
+
"""The AI review, or None when the reply can't be used. Raises LookupError
|
|
160
|
+
with no funds; lets GenerationError through for the endpoint."""
|
|
161
|
+
from ..core import tenant
|
|
162
|
+
from ..llm.backend_factory import create_generation_backend
|
|
163
|
+
from ..llm.protocol import GenerationConfig
|
|
164
|
+
|
|
165
|
+
funds = [f for f in funds_view.get("funds") or [] if (f.get("value") or 0) > 0]
|
|
166
|
+
if not funds:
|
|
167
|
+
raise LookupError("This portfolio has no mutual funds to review.")
|
|
168
|
+
# Same user, portfolio and holdings -> same review; any trade changes the key.
|
|
169
|
+
holdings = ",".join(sorted(f"{f['code']}:{round(f.get('units') or 0, 3)}" for f in funds))
|
|
170
|
+
key = (str(tenant.current()), name, hashlib.sha1(holdings.encode()).hexdigest())
|
|
171
|
+
if use_cache:
|
|
172
|
+
with _lock:
|
|
173
|
+
hit = _cache.get(key)
|
|
174
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
175
|
+
return hit[1]
|
|
176
|
+
|
|
177
|
+
sheet = facts(funds_view, health)
|
|
178
|
+
result = create_generation_backend(config).generate(
|
|
179
|
+
"Mutual fund portfolio (JSON):\n" + json.dumps(sheet, default=str),
|
|
180
|
+
GenerationConfig(max_tokens=MAX_TOKENS, timeout=150), system_prompt=_system())
|
|
181
|
+
out = _parse(result.text, {f["code"] for f in funds})
|
|
182
|
+
if out is None:
|
|
183
|
+
text = result.text or ""
|
|
184
|
+
logger.warning("%s: unusable reply from %s (%d chars): %s ... %s", "Fund portfolio AI review", result.model,
|
|
185
|
+
len(text), " ".join(text[:150].split()), " ".join(text[-150:].split()))
|
|
186
|
+
return None
|
|
187
|
+
names = {f["code"]: _short(f.get("name")) for f in funds}
|
|
188
|
+
weights = {h["code"]: h["weight_pct"] for h in sheet["holdings"]}
|
|
189
|
+
for n in out["funds"]:
|
|
190
|
+
n.update(name=names.get(n["code"]), weight_pct=weights.get(n["code"]))
|
|
191
|
+
out.update(generated_at=time.strftime("%Y-%m-%d %H:%M"), compliance="analytics",
|
|
192
|
+
look_through=sheet.get("look_through"))
|
|
193
|
+
with _lock:
|
|
194
|
+
_cache[key] = (time.time(), out)
|
|
195
|
+
return out
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Exchange-confirmed dates for Indian stocks, from NSE's bulk feeds:
|
|
2
|
+
|
|
3
|
+
- board meetings (results, dividend, fund raising, split, bonus…), the
|
|
4
|
+
official results date that Yahoo's estimate is replaced with;
|
|
5
|
+
- corporate actions by ex-date (dividends with the amount, splits, bonus,
|
|
6
|
+
rights, buybacks).
|
|
7
|
+
|
|
8
|
+
One fetch covers every stock for the next four months, cached and saved on
|
|
9
|
+
disk through econ_calendar's source cache.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from datetime import date, datetime, timedelta
|
|
16
|
+
from typing import Callable, Optional
|
|
17
|
+
|
|
18
|
+
MEETINGS_URL = "https://www.nseindia.com/api/corporate-board-meetings?index=equities&from_date={a}&to_date={b}"
|
|
19
|
+
ACTIONS_URL = "https://www.nseindia.com/api/corporates-corporateActions?index=equities&from_date={a}&to_date={b}"
|
|
20
|
+
AHEAD_DAYS = 125
|
|
21
|
+
TTL = 6 * 3600
|
|
22
|
+
|
|
23
|
+
# words in a meeting's purpose / description -> the topic shown
|
|
24
|
+
TOPICS = [("financial result", "results"), ("dividend", "dividend"), ("bonus", "bonus"), ("split", "stock split"),
|
|
25
|
+
("sub-division", "stock split"), ("buy back", "buyback"), ("buyback", "buyback"),
|
|
26
|
+
("fund raising", "fund raising"), ("rights", "rights issue"), ("merger", "merger"),
|
|
27
|
+
("amalgamation", "merger"), ("demerger", "demerger")]
|
|
28
|
+
|
|
29
|
+
# corporate action subject -> label
|
|
30
|
+
ACTION_LABELS = [("dividend", "Dividend"), ("bonus", "Bonus issue"), ("split", "Stock split"),
|
|
31
|
+
("sub-division", "Stock split"), ("rights", "Rights issue"), ("buy back", "Buyback"),
|
|
32
|
+
("buyback", "Buyback"), ("demerger", "Demerger"), ("amalgamation", "Merger"), ("agm", "AGM")]
|
|
33
|
+
|
|
34
|
+
FetchJson = Callable[[str], object]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _day(text) -> Optional[date]:
|
|
38
|
+
try:
|
|
39
|
+
return datetime.strptime(str(text).strip(), "%d-%b-%Y").date()
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def topics(text: str) -> list[str]:
|
|
45
|
+
low = (text or "").lower()
|
|
46
|
+
out: list[str] = []
|
|
47
|
+
for word, topic in TOPICS:
|
|
48
|
+
if word in low and topic not in out and not (topic == "merger" and "demerger" in low):
|
|
49
|
+
out.append(topic)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_meetings(rows) -> list[dict]:
|
|
54
|
+
"""[{symbol, date, topics}], one per company and meeting day."""
|
|
55
|
+
merged: dict[tuple[str, str], list[str]] = {}
|
|
56
|
+
for r in rows or []:
|
|
57
|
+
d = _day(r.get("bm_date"))
|
|
58
|
+
sym = str(r.get("bm_symbol") or "").upper()
|
|
59
|
+
if not d or not sym:
|
|
60
|
+
continue
|
|
61
|
+
found = topics(f"{r.get('bm_purpose') or ''} {r.get('bm_desc') or ''}")
|
|
62
|
+
have = merged.setdefault((sym, d.isoformat()), [])
|
|
63
|
+
have += [t for t in found if t not in have]
|
|
64
|
+
return [{"symbol": s, "date": d, "topics": t} for (s, d), t in merged.items()]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def action_label(subject: str) -> str:
|
|
68
|
+
low = (subject or "").lower()
|
|
69
|
+
return next((label for word, label in ACTION_LABELS if word in low), "Corporate action")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_actions(rows) -> list[dict]:
|
|
73
|
+
"""[{symbol, date (ex-date), label, subject}]."""
|
|
74
|
+
out = []
|
|
75
|
+
for r in rows or []:
|
|
76
|
+
d = _day(r.get("exDate"))
|
|
77
|
+
sym = str(r.get("symbol") or "").upper()
|
|
78
|
+
subject = re.sub(r"\s+", " ", str(r.get("subject") or "")).strip()
|
|
79
|
+
if d and sym and subject:
|
|
80
|
+
out.append({"symbol": sym, "date": d.isoformat(), "label": action_label(subject), "subject": subject})
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fetch_json(url: str) -> object:
|
|
85
|
+
from .market_calendar import _get_json
|
|
86
|
+
|
|
87
|
+
return _get_json(url)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def india_events(fetch: FetchJson = _fetch_json, today: Optional[date] = None) -> dict:
|
|
91
|
+
"""{"meetings": [...], "actions": [...]} from a few days back to four months ahead."""
|
|
92
|
+
from .econ_calendar import _source
|
|
93
|
+
|
|
94
|
+
today = today or date.today()
|
|
95
|
+
a, b = (today - timedelta(days=5)).strftime("%d-%m-%Y"), (today + timedelta(days=AHEAD_DAYS)).strftime("%d-%m-%Y")
|
|
96
|
+
meetings, _ = _source("nse_board_meetings", lambda: parse_meetings(fetch(MEETINGS_URL.format(a=a, b=b))), ttl=TTL)
|
|
97
|
+
actions, _ = _source("nse_corporate_actions", lambda: parse_actions(fetch(ACTIONS_URL.format(a=a, b=b))), ttl=TTL)
|
|
98
|
+
return {"meetings": meetings, "actions": actions}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Stock overview for the Analyze page: every computed fact about one
|
|
2
|
+
stock - technicals, trend, returns, risk, fundamentals, analysts, events,
|
|
3
|
+
profile and news - without any AI call.
|
|
4
|
+
|
|
5
|
+
Each section is built independently and best-effort: a missing source
|
|
6
|
+
(e.g. no fundamentals for crypto) yields {"error": ...} for that section
|
|
7
|
+
only, never a failed page.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import math
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
from ..core.numbers import finite as _num
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pct_change(series, bars: int) -> Optional[float]:
|
|
23
|
+
if len(series) <= bars:
|
|
24
|
+
return None
|
|
25
|
+
past = float(series.iloc[-bars - 1])
|
|
26
|
+
return (float(series.iloc[-1]) / past - 1) * 100 if past else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _section(fn: Callable[[], dict]) -> dict:
|
|
30
|
+
try:
|
|
31
|
+
return fn()
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
logger.info("Overview section failed: %s", exc)
|
|
34
|
+
return {"error": str(exc) or type(exc).__name__}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _technical(history) -> dict:
|
|
38
|
+
from ..analysis.technical import TechnicalAnalyzer
|
|
39
|
+
|
|
40
|
+
if history is None or len(history) < 30:
|
|
41
|
+
raise ValueError("not enough price history for indicators")
|
|
42
|
+
close = history["close"].astype(float)
|
|
43
|
+
last = float(close.iloc[-1])
|
|
44
|
+
summary = TechnicalAnalyzer(history).get_summary()
|
|
45
|
+
|
|
46
|
+
notes = {
|
|
47
|
+
"rsi": "Momentum 0-100: below 30 oversold, above 70 overbought",
|
|
48
|
+
"macd": "Trend momentum; reads bullish or bearish as MACD crosses its signal line",
|
|
49
|
+
"ma": "Price vs 20- and 50-day averages (value shown: 20-day average)",
|
|
50
|
+
"bollinger": "Position inside the Bollinger band, % (0 = lower band, 100 = upper band)",
|
|
51
|
+
"stochastic": "Where the close sits in the recent range: below 20 oversold, above 80 overbought",
|
|
52
|
+
}
|
|
53
|
+
indicators = [
|
|
54
|
+
{"key": key, "name": {"rsi": "RSI (14)", "macd": "MACD", "ma": "Moving averages",
|
|
55
|
+
"bollinger": "Bollinger bands", "stochastic": "Stochastic %K"}.get(key, key),
|
|
56
|
+
"value": _num(info["value"]), "signal": info["signal"], "note": notes.get(key, "")}
|
|
57
|
+
for key, info in summary["indicators"].items()
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
extra, patterns = _extra_technicals(history)
|
|
61
|
+
indicators += extra
|
|
62
|
+
counts = {"buy": sum(i["signal"] in ("buy", "strong_buy") for i in indicators),
|
|
63
|
+
"sell": sum(i["signal"] in ("sell", "strong_sell") for i in indicators)}
|
|
64
|
+
counts["neutral"] = len(indicators) - counts["buy"] - counts["sell"]
|
|
65
|
+
if counts["buy"] > counts["sell"] + counts["neutral"]:
|
|
66
|
+
overall = "buy"
|
|
67
|
+
elif counts["sell"] > counts["buy"] + counts["neutral"]:
|
|
68
|
+
overall = "sell"
|
|
69
|
+
else:
|
|
70
|
+
overall = "neutral"
|
|
71
|
+
|
|
72
|
+
averages = []
|
|
73
|
+
for period in (20, 50, 100, 200):
|
|
74
|
+
if len(close) >= period:
|
|
75
|
+
value = float(close.tail(period).mean())
|
|
76
|
+
averages.append({"period": period, "value": value, "above": last > value,
|
|
77
|
+
"distance_pct": (last / value - 1) * 100})
|
|
78
|
+
above = [a for a in averages if a["period"] in (50, 200)]
|
|
79
|
+
if len(above) == 2 and all(a["above"] for a in above):
|
|
80
|
+
trend = "uptrend"
|
|
81
|
+
elif len(above) == 2 and not any(a["above"] for a in above):
|
|
82
|
+
trend = "downtrend"
|
|
83
|
+
else:
|
|
84
|
+
trend = "sideways / mixed"
|
|
85
|
+
|
|
86
|
+
returns = {label: _pct_change(close, bars) for label, bars in
|
|
87
|
+
(("1W", 5), ("1M", 21), ("3M", 63), ("6M", 126), ("1Y", 250))}
|
|
88
|
+
window = close.tail(252)
|
|
89
|
+
return {
|
|
90
|
+
"overall": overall,
|
|
91
|
+
"counts": counts,
|
|
92
|
+
"indicators": indicators,
|
|
93
|
+
"patterns": patterns,
|
|
94
|
+
"averages": averages,
|
|
95
|
+
"trend": trend,
|
|
96
|
+
"returns": returns,
|
|
97
|
+
"high_52w": float(window.max()),
|
|
98
|
+
"low_52w": float(window.min()),
|
|
99
|
+
"from_high_pct": (last / float(window.max()) - 1) * 100,
|
|
100
|
+
"as_of": str(history.index[-1])[:10],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _extra_technicals(history) -> tuple[list[dict], list[dict]]:
|
|
105
|
+
"""Oscillators and trend gauges from marketmyna/trading/indicators.py, plus the
|
|
106
|
+
candlestick patterns and divergences showing on the latest bar."""
|
|
107
|
+
from ..trading import indicators as ind
|
|
108
|
+
|
|
109
|
+
df = history.rename(columns=str.lower).astype(float)
|
|
110
|
+
out: list[dict] = []
|
|
111
|
+
|
|
112
|
+
def add(key, name, value, signal, note):
|
|
113
|
+
value = _num(value)
|
|
114
|
+
if value is not None:
|
|
115
|
+
out.append({"key": key, "name": name, "value": value, "signal": signal, "note": note})
|
|
116
|
+
|
|
117
|
+
band = lambda v, lo, hi: "neutral" if v is None else "buy" if v <= lo else "sell" if v >= hi else "neutral" # noqa: E731
|
|
118
|
+
c = _num(ind.cci(df).iloc[-1]) if len(df) >= 20 else None
|
|
119
|
+
add("cci", "CCI (20)", c, band(c, -100, 100), "Distance from the average price: below -100 oversold, above +100 overbought")
|
|
120
|
+
if "volume" in df and df["volume"].fillna(0).sum() > 0 and len(df) >= 15:
|
|
121
|
+
m = _num(ind.mfi(df).iloc[-1])
|
|
122
|
+
add("mfi", "Money flow index", m, band(m, 20, 80), "Volume-weighted RSI: below 20 oversold, above 80 overbought")
|
|
123
|
+
w = _num(ind.williams_r(df).iloc[-1]) if len(df) >= 14 else None
|
|
124
|
+
add("williams_r", "Williams %R", w, band(w, -80, -20), "Close vs 14-day range: below -80 oversold, above -20 overbought")
|
|
125
|
+
if len(df) >= 30:
|
|
126
|
+
a = ind.adx(df).iloc[-1]
|
|
127
|
+
strength = _num(a["adx"])
|
|
128
|
+
sig = "neutral" if strength is None or strength < 25 else "buy" if a["plus_di"] > a["minus_di"] else "sell"
|
|
129
|
+
add("adx", "ADX (14)", strength, sig, "Trend strength: above 25 is a trending market; direction from +DI vs -DI")
|
|
130
|
+
ar = _num(ind.aroon(df)["osc"].iloc[-1])
|
|
131
|
+
add("aroon", "Aroon oscillator", ar, "neutral" if ar is None else "buy" if ar >= 50 else "sell" if ar <= -50 else "neutral",
|
|
132
|
+
"Recency of 25-day highs vs lows: above +50 uptrend, below -50 downtrend")
|
|
133
|
+
if len(df) >= 80:
|
|
134
|
+
ich = ind.ichimoku(df).iloc[-1]
|
|
135
|
+
last = float(df["close"].iloc[-1])
|
|
136
|
+
top, bottom = max(ich["span_a"], ich["span_b"]), min(ich["span_a"], ich["span_b"])
|
|
137
|
+
sig = "buy" if last > top else "sell" if last < bottom else "neutral"
|
|
138
|
+
add("ichimoku", "Ichimoku cloud", (last / top - 1) * 100 if last > top else (last / bottom - 1) * 100 if last < bottom else 0.0,
|
|
139
|
+
sig, "Price vs the cloud, % outside it: above = bullish, inside = undecided, below = bearish")
|
|
140
|
+
|
|
141
|
+
patterns = [{"key": k, "name": ind.CANDLE_PATTERNS[k][0], "bias": ind.CANDLE_PATTERNS[k][1]}
|
|
142
|
+
for k in ind.candle_patterns(df)]
|
|
143
|
+
if len(df) >= 40:
|
|
144
|
+
for label, osc in (("RSI", ind.rsi(df["close"])), ("MACD", ind.macd(df["close"])["macd"])):
|
|
145
|
+
kind = ind.divergence(df, osc)
|
|
146
|
+
if kind:
|
|
147
|
+
patterns.append({"key": f"{label.lower()}_{kind}_div", "name": f"{label} {kind} divergence", "bias": kind})
|
|
148
|
+
return out, patterns
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _risk(history, info: dict) -> dict:
|
|
152
|
+
if history is None or len(history) < 30:
|
|
153
|
+
raise ValueError("not enough price history for risk")
|
|
154
|
+
close = history["close"].astype(float)
|
|
155
|
+
returns = close.pct_change().dropna()
|
|
156
|
+
volatility = float(returns.std() * math.sqrt(252))
|
|
157
|
+
drawdown = float(((close - close.cummax()) / close.cummax()).min())
|
|
158
|
+
level = "high" if volatility > 0.5 or drawdown < -0.35 else "medium" if volatility > 0.25 or drawdown < -0.2 else "low"
|
|
159
|
+
return {
|
|
160
|
+
"level": level,
|
|
161
|
+
"volatility_pct": volatility * 100,
|
|
162
|
+
"max_drawdown_pct": drawdown * 100,
|
|
163
|
+
"beta": _num(info.get("beta")),
|
|
164
|
+
"worst_day_pct": float(returns.min() * 100),
|
|
165
|
+
"best_day_pct": float(returns.max() * 100),
|
|
166
|
+
"realized": _realized(history),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _realized(history) -> Optional[dict]:
|
|
171
|
+
from ..trading.volatility import summary
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
return summary(history.rename(columns=str.lower))
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
logger.info("Realized volatility unavailable: %s", exc)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _fundamentals(info: dict) -> dict:
|
|
181
|
+
from ..analysis.fundamental import FundamentalAnalyzer, yfinance_fundamentals
|
|
182
|
+
|
|
183
|
+
if not info or not (info.get("marketCap") or info.get("trailingPE") or info.get("totalRevenue")):
|
|
184
|
+
raise ValueError("no fundamentals published for this instrument")
|
|
185
|
+
norm = yfinance_fundamentals(info)
|
|
186
|
+
summary = FundamentalAnalyzer(norm).get_summary()
|
|
187
|
+
|
|
188
|
+
def pct(key):
|
|
189
|
+
v = norm.get(key)
|
|
190
|
+
return v * 100 if v is not None else None
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"score": summary.get("overall_score"),
|
|
194
|
+
"valuation_status": (summary.get("valuation") or {}).get("status"),
|
|
195
|
+
"health_rating": (summary.get("financial_health") or {}).get("rating"),
|
|
196
|
+
"currency": info.get("financialCurrency") or info.get("currency"),
|
|
197
|
+
"groups": [
|
|
198
|
+
{"title": "Valuation", "items": [
|
|
199
|
+
{"label": "Market cap", "value": _num(info.get("marketCap")), "kind": "money"},
|
|
200
|
+
{"label": "P/E (trailing)", "value": norm.get("pe_ratio"), "kind": "ratio"},
|
|
201
|
+
{"label": "P/E (forward)", "value": norm.get("forward_pe"), "kind": "ratio"},
|
|
202
|
+
{"label": "PEG", "value": norm.get("peg_ratio"), "kind": "ratio"},
|
|
203
|
+
{"label": "Price / book", "value": norm.get("price_to_book"), "kind": "ratio"},
|
|
204
|
+
{"label": "Price / sales", "value": norm.get("price_to_sales"), "kind": "ratio"},
|
|
205
|
+
{"label": "EV / EBITDA", "value": _num(info.get("enterpriseToEbitda")), "kind": "ratio"},
|
|
206
|
+
]},
|
|
207
|
+
{"title": "Profitability", "items": [
|
|
208
|
+
{"label": "Return on equity", "value": pct("return_on_equity"), "kind": "pct"},
|
|
209
|
+
{"label": "Return on assets", "value": pct("return_on_assets"), "kind": "pct"},
|
|
210
|
+
{"label": "Profit margin", "value": pct("profit_margin"), "kind": "pct"},
|
|
211
|
+
{"label": "Operating margin", "value": _num(info.get("operatingMargins")) and info["operatingMargins"] * 100, "kind": "pct"},
|
|
212
|
+
{"label": "EPS (trailing)", "value": _num(info.get("trailingEps")), "kind": "ratio"},
|
|
213
|
+
]},
|
|
214
|
+
{"title": "Growth", "items": [
|
|
215
|
+
{"label": "Revenue growth (YoY)", "value": pct("revenue_growth"), "kind": "pct"},
|
|
216
|
+
{"label": "Earnings growth (YoY)", "value": pct("earnings_growth"), "kind": "pct"},
|
|
217
|
+
{"label": "Revenue (TTM)", "value": _num(info.get("totalRevenue")), "kind": "money"},
|
|
218
|
+
]},
|
|
219
|
+
{"title": "Financial health", "items": [
|
|
220
|
+
{"label": "Debt / equity", "value": norm.get("debt_to_equity"), "kind": "ratio"},
|
|
221
|
+
{"label": "Current ratio", "value": norm.get("current_ratio"), "kind": "ratio"},
|
|
222
|
+
{"label": "Total cash", "value": _num(info.get("totalCash")), "kind": "money"},
|
|
223
|
+
{"label": "Total debt", "value": _num(info.get("totalDebt")), "kind": "money"},
|
|
224
|
+
{"label": "Free cash flow", "value": _num(info.get("freeCashflow")), "kind": "money"},
|
|
225
|
+
]},
|
|
226
|
+
{"title": "Dividend", "items": [
|
|
227
|
+
{"label": "Dividend yield", "value": pct("dividend_yield"), "kind": "pct"},
|
|
228
|
+
{"label": "Payout ratio", "value": pct("payout_ratio"), "kind": "pct"},
|
|
229
|
+
{"label": "Dividend per share", "value": _num(info.get("dividendRate")), "kind": "ratio"},
|
|
230
|
+
]},
|
|
231
|
+
],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _analysts(info: dict) -> dict:
|
|
236
|
+
if not info.get("recommendationKey") and not info.get("targetMeanPrice"):
|
|
237
|
+
raise ValueError("no analyst coverage published")
|
|
238
|
+
return {
|
|
239
|
+
"recommendation": info.get("recommendationKey"),
|
|
240
|
+
"mean_score": _num(info.get("recommendationMean")), # 1 = strong buy ... 5 = sell
|
|
241
|
+
"count": info.get("numberOfAnalystOpinions"),
|
|
242
|
+
"target_mean": _num(info.get("targetMeanPrice")),
|
|
243
|
+
"target_high": _num(info.get("targetHighPrice")),
|
|
244
|
+
"target_low": _num(info.get("targetLowPrice")),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _events(yf_symbol: str) -> dict:
|
|
249
|
+
import yfinance as yf
|
|
250
|
+
|
|
251
|
+
from .analysis_run import _to_str_date
|
|
252
|
+
|
|
253
|
+
cal = yf.Ticker(yf_symbol).calendar or {}
|
|
254
|
+
out = {
|
|
255
|
+
"earnings_date": _to_str_date(cal.get("Earnings Date")),
|
|
256
|
+
"ex_dividend_date": _to_str_date(cal.get("Ex-Dividend Date")),
|
|
257
|
+
"dividend_date": _to_str_date(cal.get("Dividend Date")),
|
|
258
|
+
"eps_estimate": _num(cal.get("Earnings Average")),
|
|
259
|
+
"revenue_estimate": _num(cal.get("Revenue Average")),
|
|
260
|
+
}
|
|
261
|
+
if not any(out.values()):
|
|
262
|
+
raise ValueError("no upcoming events published")
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _profile(info: dict, market: str) -> dict:
|
|
267
|
+
if not info:
|
|
268
|
+
raise ValueError("no company profile available")
|
|
269
|
+
return {
|
|
270
|
+
"name": info.get("longName") or info.get("shortName"),
|
|
271
|
+
"sector": info.get("sector"),
|
|
272
|
+
"industry": info.get("industry"),
|
|
273
|
+
"country": info.get("country"),
|
|
274
|
+
"employees": info.get("fullTimeEmployees"),
|
|
275
|
+
"website": info.get("website"),
|
|
276
|
+
"summary": info.get("longBusinessSummary"),
|
|
277
|
+
"exchange": info.get("exchange") or info.get("fullExchangeName"),
|
|
278
|
+
"currency": info.get("currency"),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def build_overview(symbol: str, market: str) -> dict:
|
|
283
|
+
"""Technicals, risk, fundamentals, analysts, events and news for one
|
|
284
|
+
stock, without per-stock verdicts (public_view)."""
|
|
285
|
+
return public_view(_build_overview(symbol, market))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def public_view(out: dict) -> dict:
|
|
289
|
+
"""What each indicator reads (bullish / bearish / neutral) instead of a
|
|
290
|
+
buy / sell call, the overall reading and its counts, and the pattern
|
|
291
|
+
lean; no "overvalued / undervalued" label (docs/future/sebi-compliance-mode.md,
|
|
292
|
+
step 10). Trend, moving averages and returns stay; the attributed broker
|
|
293
|
+
consensus is shown as bullish / neutral / bearish, without price targets."""
|
|
294
|
+
tech = dict(out.get("technical") or {})
|
|
295
|
+
if "indicators" in tech:
|
|
296
|
+
tech["indicators"] = [{**i, "signal": reading(i.get("signal"))} for i in tech["indicators"]]
|
|
297
|
+
if "overall" in tech:
|
|
298
|
+
tech["overall"] = reading(tech["overall"])
|
|
299
|
+
fund = dict(out.get("fundamentals") or {})
|
|
300
|
+
fund.pop("valuation_status", None)
|
|
301
|
+
# Third-party consensus as a direction, not a call, and without the brokers' price targets.
|
|
302
|
+
analysts = {k: v for k, v in (out.get("analysts") or {}).items() if k not in ("target_mean", "target_high", "target_low")}
|
|
303
|
+
if analysts.get("recommendation"):
|
|
304
|
+
analysts["recommendation"] = reading(analysts["recommendation"]) or analysts["recommendation"]
|
|
305
|
+
return {**out, "technical": tech, "fundamentals": fund, "analysts": analysts, "compliance": "analytics"}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
_READINGS = {"strong_buy": "bullish", "buy": "bullish", "bullish": "bullish", "outperform": "bullish",
|
|
309
|
+
"strong_sell": "bearish", "sell": "bearish", "bearish": "bearish", "underperform": "bearish",
|
|
310
|
+
"hold": "neutral", "neutral": "neutral"}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def reading(signal):
|
|
314
|
+
"""A buy / sell signal as what the indicator reads, not a call."""
|
|
315
|
+
return _READINGS.get(str(signal).lower()) if signal else None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _build_overview(symbol: str, market: str) -> dict:
|
|
319
|
+
from ..agent.agents import _resolve_fetcher
|
|
320
|
+
from ..data.news_search import stock_news
|
|
321
|
+
from .analysis_run import yahoo_symbol
|
|
322
|
+
|
|
323
|
+
yf_symbol = yahoo_symbol(symbol, market)
|
|
324
|
+
|
|
325
|
+
def load_history():
|
|
326
|
+
return _resolve_fetcher(market).get_history(symbol, period="1y")
|
|
327
|
+
|
|
328
|
+
def load_info():
|
|
329
|
+
if market == "crypto":
|
|
330
|
+
return {}
|
|
331
|
+
import yfinance as yf
|
|
332
|
+
|
|
333
|
+
return yf.Ticker(yf_symbol).info or {}
|
|
334
|
+
|
|
335
|
+
# History, company info and news are independent network calls.
|
|
336
|
+
with ThreadPoolExecutor(max_workers=3) as pool:
|
|
337
|
+
history_f = pool.submit(load_history)
|
|
338
|
+
info_f = pool.submit(load_info)
|
|
339
|
+
news_f = pool.submit(lambda: stock_news(symbol, market, limit=8))
|
|
340
|
+
try:
|
|
341
|
+
history = history_f.result()
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
logger.info("Overview history for %s failed: %s", symbol, exc)
|
|
344
|
+
history = None
|
|
345
|
+
try:
|
|
346
|
+
info = info_f.result()
|
|
347
|
+
except Exception as exc:
|
|
348
|
+
logger.info("Overview info for %s failed: %s", symbol, exc)
|
|
349
|
+
info = {}
|
|
350
|
+
try:
|
|
351
|
+
news = news_f.result()
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
news = {"error": str(exc)}
|
|
354
|
+
|
|
355
|
+
not_applicable = {"error": "not applicable to crypto"}
|
|
356
|
+
return {
|
|
357
|
+
"symbol": symbol,
|
|
358
|
+
"market": market,
|
|
359
|
+
"technical": _section(lambda: _technical(history)),
|
|
360
|
+
"risk": _section(lambda: _risk(history, info)),
|
|
361
|
+
"fundamentals": not_applicable if market == "crypto" else _section(lambda: _fundamentals(info)),
|
|
362
|
+
"analysts": not_applicable if market == "crypto" else _section(lambda: _analysts(info)),
|
|
363
|
+
"events": not_applicable if market == "crypto" else _section(lambda: _events(yf_symbol)),
|
|
364
|
+
"profile": not_applicable if market == "crypto" else _section(lambda: _profile(info, market)),
|
|
365
|
+
"news": news if isinstance(news, dict) else {"items": news},
|
|
366
|
+
}
|