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,70 @@
|
|
|
1
|
+
"""Mutual-fund NAV snapshots for the scheduled report (Indian AMFI schemes
|
|
2
|
+
via mfapi.in, free and keyless - the same source daily_stock_analysis's
|
|
3
|
+
MF_LIST uses)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_MFAPI = "https://api.mfapi.in/mf/{code}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _return_since(points: list[tuple[datetime, float]], days: int) -> Optional[float]:
|
|
19
|
+
"""% change from the last NAV on/before `days` ago to the latest NAV."""
|
|
20
|
+
if len(points) < 2:
|
|
21
|
+
return None
|
|
22
|
+
latest_date, latest_nav = points[0]
|
|
23
|
+
cutoff = latest_date - timedelta(days=days)
|
|
24
|
+
past = next((nav for day, nav in points if day <= cutoff), None)
|
|
25
|
+
if not past:
|
|
26
|
+
return None
|
|
27
|
+
return (latest_nav - past) / past * 100
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fund_snapshot(code: str) -> Optional[dict]:
|
|
31
|
+
"""Latest NAV, day change and 1M/1Y returns for one scheme code."""
|
|
32
|
+
try:
|
|
33
|
+
resp = requests.get(_MFAPI.format(code=code.strip()), timeout=15)
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
payload = resp.json()
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
logger.warning("NAV for scheme %s unavailable: %s", code, exc)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
points = []
|
|
41
|
+
for row in payload.get("data") or []:
|
|
42
|
+
try:
|
|
43
|
+
points.append((datetime.strptime(row["date"], "%d-%m-%Y"), float(row["nav"])))
|
|
44
|
+
except (KeyError, ValueError):
|
|
45
|
+
continue
|
|
46
|
+
if not points:
|
|
47
|
+
return None
|
|
48
|
+
points.sort(key=lambda p: p[0], reverse=True)
|
|
49
|
+
latest_date, nav = points[0]
|
|
50
|
+
previous = points[1][1] if len(points) > 1 else None
|
|
51
|
+
meta = payload.get("meta") or {}
|
|
52
|
+
return {
|
|
53
|
+
"code": str(code).strip(),
|
|
54
|
+
"name": meta.get("scheme_name") or str(code),
|
|
55
|
+
"category": meta.get("scheme_category"),
|
|
56
|
+
"nav": nav,
|
|
57
|
+
"nav_date": latest_date.date().isoformat(),
|
|
58
|
+
"change_percent": (nav - previous) / previous * 100 if previous else None,
|
|
59
|
+
"return_1m": _return_since(points, 30),
|
|
60
|
+
"return_1y": _return_since(points, 365),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fund_snapshots(codes) -> list[dict]:
|
|
65
|
+
out = []
|
|
66
|
+
for code in dict.fromkeys(str(c).strip() for c in codes or [] if str(c).strip()):
|
|
67
|
+
snap = fund_snapshot(code)
|
|
68
|
+
if snap:
|
|
69
|
+
out.append(snap)
|
|
70
|
+
return out
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Hot stocks, by what each side of the app is looking for.
|
|
2
|
+
|
|
3
|
+
Trader: what is moving *today* and tradeable - built only from the listings
|
|
4
|
+
store (price, day change, volume, market cap, 52-week range), so it is
|
|
5
|
+
instant and refreshes with prices:
|
|
6
|
+
momentum big move on heavy trading
|
|
7
|
+
breakouts within 2% of the 52-week high and up today
|
|
8
|
+
activity traded far more of its size than the market usually does
|
|
9
|
+
selloffs down 3% or more on heavy trading
|
|
10
|
+
bounces near the 52-week low but bouncing hard today
|
|
11
|
+
breakdowns at or below the 52-week low and falling
|
|
12
|
+
Boards that need the 52-week range say so when a market's listing feed has
|
|
13
|
+
none (the US feed carries no range).
|
|
14
|
+
|
|
15
|
+
"Activity" is turnover (traded value / market cap) against the median
|
|
16
|
+
turnover of the same universe: there is no free average-volume field in the
|
|
17
|
+
listings, and a stock trading 4x the market's typical share of its size is
|
|
18
|
+
the same signal traders look for in volume surges.
|
|
19
|
+
|
|
20
|
+
Investor: businesses the market is re-pricing over weeks to months, with the
|
|
21
|
+
fundamentals to back them - the screens' Yahoo fundamentals (shared 6-hour
|
|
22
|
+
cache) combined with where price sits in its 52-week range:
|
|
23
|
+
quality_dip strong, profitable, low debt, 10-30% off the high
|
|
24
|
+
earnings fast earnings and revenue growth, price near highs
|
|
25
|
+
value_turning cheap against its own sector and climbing off the low
|
|
26
|
+
compounders high returns, fat margins, little debt, at new highs
|
|
27
|
+
income dividend payers with sustainable payouts, off the high
|
|
28
|
+
funds_adding (India) stocks mutual funds added most last month
|
|
29
|
+
Crypto has no fundamentals, so investors get size-and-range boards there.
|
|
30
|
+
|
|
31
|
+
Facts, not calls: every row carries a plain "why" line, never buy / sell.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import math
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from typing import Callable, Optional
|
|
42
|
+
|
|
43
|
+
from ..core.numbers import finite as _f
|
|
44
|
+
from ..core.numbers import median as _median
|
|
45
|
+
|
|
46
|
+
TOP = 20 # rows per board
|
|
47
|
+
INVESTOR_TTL = 6 * 3600
|
|
48
|
+
|
|
49
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
50
|
+
_lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _base(r: dict, market: str) -> dict:
|
|
54
|
+
return {"symbol": r["symbol"], "name": r.get("name"), "market": market, "price": r.get("price"),
|
|
55
|
+
"change_percent": r.get("change_percent"), "market_cap": r.get("market_cap"),
|
|
56
|
+
"currency": r.get("currency"), "sector": r.get("category") or r.get("sector")}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _range(r: dict) -> tuple[Optional[float], Optional[float]]:
|
|
60
|
+
"""Percent from the 52-week high (<= 0) and above the 52-week low (>= 0)."""
|
|
61
|
+
p, hi, lo = _f(r.get("price")), _f(r.get("high_52w")), _f(r.get("low_52w"))
|
|
62
|
+
return ((p / hi - 1) * 100 if p and hi else None, (p / lo - 1) * 100 if p and lo else None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _board(key: str, title: str, description: str, rows: list[dict], note: Optional[str] = None) -> dict:
|
|
66
|
+
return {"key": key, "title": title, "description": description, "rows": rows[:TOP], "note": note}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------- trader
|
|
70
|
+
|
|
71
|
+
def trader(market: str, rows: Optional[list[dict]] = None, size: int = 500, live: Optional[dict] = None) -> dict:
|
|
72
|
+
"""`live` = live_market.market(): boards are ranked on live prices, among
|
|
73
|
+
live-priced names only."""
|
|
74
|
+
from ..data.listings import get_store
|
|
75
|
+
from .live_market import merge
|
|
76
|
+
|
|
77
|
+
store = get_store()
|
|
78
|
+
if rows is None:
|
|
79
|
+
rows = store.rows(market)
|
|
80
|
+
quotes = (live or {}).get("quotes") or {}
|
|
81
|
+
if quotes:
|
|
82
|
+
rows = merge(rows, quotes, only_live=True)
|
|
83
|
+
kind = "crypto" if market == "crypto" else "stock"
|
|
84
|
+
priced = [r for r in rows if r.get("type") == kind and _f(r.get("price")) and r.get("change_percent") is not None
|
|
85
|
+
and (r.get("volume") or 0) > 0]
|
|
86
|
+
size_key = "volume" if market == "crypto" else "market_cap"
|
|
87
|
+
universe = sorted((r for r in priced if r.get(size_key)), key=lambda r: -r[size_key])[:max(50, size)]
|
|
88
|
+
|
|
89
|
+
# Tradeable only: drop the thinnest third by traded value.
|
|
90
|
+
traded = sorted(universe, key=lambda r: r["price"] * r["volume"])
|
|
91
|
+
liquid = traded[len(traded) // 3:]
|
|
92
|
+
|
|
93
|
+
turnovers = [r["price"] * r["volume"] / r["market_cap"] for r in liquid if r.get("market_cap")]
|
|
94
|
+
typical = _median(turnovers)
|
|
95
|
+
|
|
96
|
+
def activity(r: dict) -> Optional[float]:
|
|
97
|
+
if not typical or not r.get("market_cap"):
|
|
98
|
+
return None
|
|
99
|
+
return (r["price"] * r["volume"] / r["market_cap"]) / typical
|
|
100
|
+
|
|
101
|
+
def row(r: dict, why: str) -> dict:
|
|
102
|
+
from_high, from_low = _range(r)
|
|
103
|
+
act = activity(r)
|
|
104
|
+
return {**_base(r, market), "traded_value": r["price"] * r["volume"], "activity": act,
|
|
105
|
+
"from_high": from_high, "from_low": from_low, "why": why}
|
|
106
|
+
|
|
107
|
+
def act_text(r: dict) -> str:
|
|
108
|
+
a = activity(r)
|
|
109
|
+
return f" on {a:.1f}× usual activity" if a and a >= 1.5 else ""
|
|
110
|
+
|
|
111
|
+
chg = lambda r: r["change_percent"] # noqa: E731
|
|
112
|
+
|
|
113
|
+
momentum = sorted((r for r in liquid if chg(r) >= 2), key=lambda r: -(chg(r) * (1 + min(activity(r) or 1, 5)) ** 0.5))
|
|
114
|
+
breakouts = []
|
|
115
|
+
for r in liquid:
|
|
116
|
+
from_high, _ = _range(r)
|
|
117
|
+
if from_high is not None and from_high >= -2 and chg(r) > 0:
|
|
118
|
+
breakouts.append(r)
|
|
119
|
+
breakouts.sort(key=lambda r: -chg(r))
|
|
120
|
+
unusual = sorted((r for r in liquid if (activity(r) or 0) >= 2.5), key=lambda r: -(activity(r) or 0))
|
|
121
|
+
selloffs = sorted((r for r in liquid if chg(r) <= -3), key=lambda r: chg(r) * (1 + min(activity(r) or 1, 5)) ** 0.5)
|
|
122
|
+
has_range = sum(1 for r in liquid if r.get("high_52w") and r.get("low_52w")) >= len(liquid) / 2
|
|
123
|
+
no_range = None if has_range else "This market's listing feed has no 52-week range, so this board is empty here."
|
|
124
|
+
bounces, breakdowns = [], []
|
|
125
|
+
for r in liquid:
|
|
126
|
+
_, from_low = _range(r)
|
|
127
|
+
if from_low is None:
|
|
128
|
+
continue
|
|
129
|
+
if from_low <= 8 and chg(r) >= 2:
|
|
130
|
+
bounces.append(r)
|
|
131
|
+
if from_low <= 1 and chg(r) <= -1:
|
|
132
|
+
breakdowns.append(r)
|
|
133
|
+
bounces.sort(key=lambda r: -chg(r))
|
|
134
|
+
breakdowns.sort(key=chg)
|
|
135
|
+
|
|
136
|
+
boards = [
|
|
137
|
+
_board("momentum", "Momentum", "Up 2% or more today, strongest moves on heavy trading first",
|
|
138
|
+
[row(r, f"{chg(r):+.1f}% today{act_text(r)}") for r in momentum]),
|
|
139
|
+
_board("breakouts", "Breakouts", "Within 2% of the 52-week high and up today",
|
|
140
|
+
[row(r, f"{abs(_range(r)[0]):.1f}% from the 52-week high, {chg(r):+.1f}% today{act_text(r)}") for r in breakouts],
|
|
141
|
+
no_range),
|
|
142
|
+
_board("activity", "Unusual activity", "Trading 2.5× or more of the market's usual share of their size",
|
|
143
|
+
[row(r, f"{activity(r):.1f}× usual activity, {chg(r):+.1f}% today") for r in unusual]),
|
|
144
|
+
_board("selloffs", "Sell-offs", "Down 3% or more today, heaviest trading first",
|
|
145
|
+
[row(r, f"{chg(r):+.1f}% today{act_text(r)}") for r in selloffs]),
|
|
146
|
+
_board("bounces", "Bouncing off lows", "Within 8% of the 52-week low and up 2% or more today",
|
|
147
|
+
[row(r, f"{_range(r)[1]:.1f}% above the 52-week low, {chg(r):+.1f}% today") for r in bounces], no_range),
|
|
148
|
+
_board("breakdowns", "Breaking down", "At or under the 52-week low and falling today",
|
|
149
|
+
[row(r, f"at the 52-week low, {chg(r):+.1f}% today{act_text(r)}") for r in breakdowns], no_range),
|
|
150
|
+
]
|
|
151
|
+
if market == "crypto": # no market cap, so no activity measure
|
|
152
|
+
boards = [b for b in boards if b["key"] != "activity"]
|
|
153
|
+
if quotes:
|
|
154
|
+
as_of = datetime.fromtimestamp(live["status"]["updated"]).isoformat(timespec="seconds")
|
|
155
|
+
else:
|
|
156
|
+
fetched = [s.get("fetched_at") for s in store.status() if s["market"] == market and s.get("fetched_at")]
|
|
157
|
+
as_of = max(fetched) if fetched else None
|
|
158
|
+
return {"market": market, "area": "trader", "as_of": as_of, "live": (live or {}).get("status"),
|
|
159
|
+
"universe": f"{len(liquid)} most-traded of the largest {len(universe)}", "boards": boards}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def with_live_prices(out: dict, live: Optional[dict]) -> dict:
|
|
163
|
+
"""Investor boards (picked on fundamentals, cached for hours) with each
|
|
164
|
+
row's price, day change and distance from the 52-week high / low taken
|
|
165
|
+
from live quotes."""
|
|
166
|
+
quotes = (live or {}).get("quotes") or {}
|
|
167
|
+
if not quotes:
|
|
168
|
+
return out
|
|
169
|
+
boards = []
|
|
170
|
+
for b in out.get("boards", []):
|
|
171
|
+
rows = []
|
|
172
|
+
for r in b["rows"]:
|
|
173
|
+
q = quotes.get(r["symbol"])
|
|
174
|
+
if q:
|
|
175
|
+
r = {**r, "price": q["price"], "change_percent": q.get("change_percent", r.get("change_percent"))}
|
|
176
|
+
for key, ref in (("from_high", "high_52w"), ("from_low", "low_52w")):
|
|
177
|
+
if r.get(ref):
|
|
178
|
+
r[key] = (q["price"] / r[ref] - 1) * 100
|
|
179
|
+
rows.append(r)
|
|
180
|
+
boards.append({**b, "rows": rows})
|
|
181
|
+
return {**out, "boards": boards, "live": live["status"]}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------- investor
|
|
185
|
+
|
|
186
|
+
InfoFetch = Callable[[str], dict]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def investor(market: str, rows: Optional[list[dict]] = None, info_fetch: Optional[InfoFetch] = None,
|
|
190
|
+
use_cache: bool = True) -> dict:
|
|
191
|
+
if use_cache and rows is None and info_fetch is None:
|
|
192
|
+
with _lock:
|
|
193
|
+
hit = _cache.get(market)
|
|
194
|
+
if hit and time.time() - hit[0] < INVESTOR_TTL:
|
|
195
|
+
return hit[1]
|
|
196
|
+
if rows is None:
|
|
197
|
+
from ..data.listings import get_store
|
|
198
|
+
|
|
199
|
+
rows = get_store().rows(market)
|
|
200
|
+
out = _crypto_investor(rows) if market == "crypto" else _stock_investor(market, rows, info_fetch)
|
|
201
|
+
if use_cache and info_fetch is None:
|
|
202
|
+
with _lock:
|
|
203
|
+
_cache[market] = (time.time(), out)
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _stock_investor(market: str, rows: list[dict], info_fetch: Optional[InfoFetch]) -> dict:
|
|
208
|
+
from . import screens
|
|
209
|
+
from .analysis_run import yahoo_symbol
|
|
210
|
+
|
|
211
|
+
if info_fetch is None:
|
|
212
|
+
from .compare import _yahoo_info as info_fetch
|
|
213
|
+
stocks = sorted((r for r in rows if r.get("type") == "stock" and r.get("market_cap")),
|
|
214
|
+
key=lambda r: -r["market_cap"])[:screens.UNIVERSE]
|
|
215
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
216
|
+
infos = list(pool.map(lambda r: info_fetch(yahoo_symbol(r["symbol"], market)), stocks))
|
|
217
|
+
|
|
218
|
+
measured = []
|
|
219
|
+
seen = set()
|
|
220
|
+
for r, info in zip(stocks, infos):
|
|
221
|
+
if not info:
|
|
222
|
+
continue
|
|
223
|
+
company = (info.get("longName") or info.get("shortName") or r["symbol"]).lower()
|
|
224
|
+
if company in seen: # second share class of the same company
|
|
225
|
+
continue
|
|
226
|
+
seen.add(company)
|
|
227
|
+
m = screens.metrics(info)
|
|
228
|
+
m["sector"] = info.get("sector") or r.get("sector")
|
|
229
|
+
# Listings prices are fresher than Yahoo's info snapshot.
|
|
230
|
+
live_high, live_low = _range(r)
|
|
231
|
+
m["from_high"] = live_high if live_high is not None else m["from_high"]
|
|
232
|
+
m["from_low"] = live_low if live_low is not None else m["from_low"]
|
|
233
|
+
measured.append((r, m))
|
|
234
|
+
screens._sector_relative([m for _, m in measured])
|
|
235
|
+
|
|
236
|
+
def g(m: dict, k: str) -> float:
|
|
237
|
+
return m.get(k) if m.get(k) is not None else math.nan
|
|
238
|
+
|
|
239
|
+
def pick(test, sort_key, why) -> list[dict]:
|
|
240
|
+
hits = [(r, m) for r, m in measured if test(m)]
|
|
241
|
+
hits.sort(key=lambda t: sort_key(t[1]))
|
|
242
|
+
return [{**_base(r, market), "sector": m["sector"] or r.get("category"),
|
|
243
|
+
**{k: m.get(k) for k in ("pe", "pe_vs_sector", "roe", "debt_equity", "earnings_growth",
|
|
244
|
+
"revenue_growth", "operating_margin", "dividend_yield", "payout",
|
|
245
|
+
"from_high", "from_low")},
|
|
246
|
+
"why": why(m)} for r, m in hits]
|
|
247
|
+
|
|
248
|
+
def pct(v: Optional[float]) -> str:
|
|
249
|
+
return f"{v:.0f}%" if v is not None else "—"
|
|
250
|
+
|
|
251
|
+
boards = [
|
|
252
|
+
_board("quality_dip", "Quality on a dip",
|
|
253
|
+
"ROE over 15%, debt under equity, earnings growing - and 10-30% below the 52-week high",
|
|
254
|
+
pick(lambda m: g(m, "roe") > 15 and g(m, "debt_equity") < 1 and g(m, "earnings_growth") > 0
|
|
255
|
+
and -30 <= g(m, "from_high") <= -10,
|
|
256
|
+
lambda m: -m["roe"],
|
|
257
|
+
lambda m: f"ROE {pct(m['roe'])}, EPS growth {pct(m['earnings_growth'])}, {abs(m['from_high']):.0f}% off the high")),
|
|
258
|
+
_board("earnings", "Earnings momentum",
|
|
259
|
+
"EPS growth 20-300% and revenue growth over 10%, price within 15% of the high; fastest sales growth first",
|
|
260
|
+
# EPS growth over 300% is usually a one-off (a loss year last year), not momentum.
|
|
261
|
+
pick(lambda m: 20 < g(m, "earnings_growth") < 300 and g(m, "revenue_growth") > 10 and g(m, "from_high") >= -15,
|
|
262
|
+
lambda m: -m["revenue_growth"],
|
|
263
|
+
lambda m: f"EPS {pct(m['earnings_growth'])}, revenue {pct(m['revenue_growth'])}, {abs(m['from_high']):.0f}% off the high")),
|
|
264
|
+
_board("value_turning", "Cheap vs sector, turning up",
|
|
265
|
+
"P/E under 85% of the sector median, profitable and growing, 15%+ above the 52-week low",
|
|
266
|
+
pick(lambda m: g(m, "pe_vs_sector") < 0.85 and g(m, "pe") > 0 and g(m, "roe") > 10
|
|
267
|
+
and g(m, "earnings_growth") > 0 and g(m, "from_low") >= 15,
|
|
268
|
+
lambda m: m["pe_vs_sector"],
|
|
269
|
+
lambda m: f"P/E {m['pe']:.0f} = {m['pe_vs_sector']:.2f}× sector, {m['from_low']:.0f}% above the low")),
|
|
270
|
+
_board("compounders", "Compounders at highs",
|
|
271
|
+
"ROE over 18%, operating margin over 15%, little debt, within 10% of the 52-week high",
|
|
272
|
+
pick(lambda m: g(m, "roe") > 18 and g(m, "operating_margin") > 15 and g(m, "debt_equity") < 0.5
|
|
273
|
+
and g(m, "from_high") >= -10,
|
|
274
|
+
lambda m: -m["roe"],
|
|
275
|
+
lambda m: f"ROE {pct(m['roe'])}, margin {pct(m['operating_margin'])}, debt/eq {m['debt_equity']:.2f}")),
|
|
276
|
+
_board("income", "Dividend payers off the high",
|
|
277
|
+
"Yield over 2.5% with payout under 80%, 10% or more below the 52-week high",
|
|
278
|
+
pick(lambda m: g(m, "dividend_yield") > 2.5 and 0 < g(m, "payout") < 80 and g(m, "from_high") <= -10,
|
|
279
|
+
lambda m: -m["dividend_yield"],
|
|
280
|
+
lambda m: f"yield {m['dividend_yield']:.1f}%, payout {pct(m['payout'])}, {abs(m['from_high']):.0f}% off the high")),
|
|
281
|
+
]
|
|
282
|
+
if market == "india":
|
|
283
|
+
adding = funds_adding(rows)
|
|
284
|
+
if adding:
|
|
285
|
+
boards.append(_board("funds_adding", "Mutual funds adding",
|
|
286
|
+
"Stocks the most fund schemes bought or added to in their latest monthly portfolio",
|
|
287
|
+
adding))
|
|
288
|
+
return {"market": market, "area": "investor", "as_of": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
289
|
+
"universe": f"Largest {len(stocks)} stocks by market cap ({len(measured)} with fundamentals)",
|
|
290
|
+
"boards": boards}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _crypto_investor(rows: list[dict]) -> dict:
|
|
294
|
+
coins = sorted((r for r in rows if r.get("type") == "crypto" and _f(r.get("price")) and r.get("volume")),
|
|
295
|
+
key=lambda r: -(r["price"] * r["volume"]))[:50]
|
|
296
|
+
|
|
297
|
+
def item(r: dict, why: str) -> dict:
|
|
298
|
+
from_high, from_low = _range(r)
|
|
299
|
+
return {**_base(r, "crypto"), "from_high": from_high, "from_low": from_low, "why": why}
|
|
300
|
+
|
|
301
|
+
dips = [r for r in coins if (_range(r)[0] or 0) <= -30]
|
|
302
|
+
dips.sort(key=lambda r: _range(r)[0])
|
|
303
|
+
strong = [r for r in coins if (_range(r)[0] or -100) >= -10]
|
|
304
|
+
strong.sort(key=lambda r: -(_range(r)[0] or -100))
|
|
305
|
+
return {"market": "crypto", "area": "investor", "as_of": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
306
|
+
"universe": "50 most-traded coins",
|
|
307
|
+
"boards": [
|
|
308
|
+
_board("crypto_dips", "Large coins far off the high", "Most-traded coins 30% or more below their 52-week high",
|
|
309
|
+
[item(r, f"{abs(_range(r)[0]):.0f}% below the 52-week high") for r in dips]),
|
|
310
|
+
_board("crypto_strong", "Large coins near highs", "Most-traded coins within 10% of their 52-week high",
|
|
311
|
+
[item(r, f"{abs(_range(r)[0]):.0f}% below the 52-week high") for r in strong]),
|
|
312
|
+
]}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def funds_adding(rows: list[dict], limit: int = TOP) -> list[dict]:
|
|
316
|
+
"""India: count fund schemes that newly bought or raised a stock between
|
|
317
|
+
their last two monthly portfolio files (marketmyna/services/fund_holdings.py)."""
|
|
318
|
+
try:
|
|
319
|
+
from . import fund_holdings as fh
|
|
320
|
+
|
|
321
|
+
root = fh._root()
|
|
322
|
+
folders = [p for p in root.iterdir() if p.is_dir()] if root.exists() else []
|
|
323
|
+
except Exception:
|
|
324
|
+
return []
|
|
325
|
+
by_isin = {r["isin"]: r for r in rows if r.get("type") == "stock" and r.get("isin")}
|
|
326
|
+
tally: dict[str, dict] = {}
|
|
327
|
+
for folder in folders:
|
|
328
|
+
docs = fh._load(folder.name)
|
|
329
|
+
if len(docs) < 2:
|
|
330
|
+
continue
|
|
331
|
+
prev = {s["scheme"]: s for s in docs[1]["schemes"]}
|
|
332
|
+
for s in docs[0]["schemes"]:
|
|
333
|
+
p = prev.get(s["scheme"])
|
|
334
|
+
if not p:
|
|
335
|
+
continue
|
|
336
|
+
before: dict[str, float] = {}
|
|
337
|
+
for h in p["holdings"]:
|
|
338
|
+
if h["kind"] in ("equity", "arbitrage"):
|
|
339
|
+
before[h["isin"]] = before.get(h["isin"], 0) + h["weight"]
|
|
340
|
+
now: dict[str, float] = {}
|
|
341
|
+
for h in s["holdings"]:
|
|
342
|
+
if h["kind"] in ("equity", "arbitrage"):
|
|
343
|
+
now[h["isin"]] = now.get(h["isin"], 0) + h["weight"]
|
|
344
|
+
for isin, w in now.items():
|
|
345
|
+
was = before.get(isin)
|
|
346
|
+
if isin not in by_isin or (was is not None and w - was < 0.25):
|
|
347
|
+
continue
|
|
348
|
+
t = tally.setdefault(isin, {"new": 0, "raised": 0})
|
|
349
|
+
t["new" if was is None else "raised"] += 1
|
|
350
|
+
ranked = sorted(tally.items(), key=lambda kv: -(kv[1]["new"] * 2 + kv[1]["raised"]))
|
|
351
|
+
out = []
|
|
352
|
+
for isin, t in ranked[:limit]:
|
|
353
|
+
r = by_isin[isin]
|
|
354
|
+
from_high, from_low = _range(r)
|
|
355
|
+
parts = [f"{t['new']} new" if t["new"] else "", f"{t['raised']} added more" if t["raised"] else ""]
|
|
356
|
+
out.append({**_base(r, "india"), "from_high": from_high, "from_low": from_low,
|
|
357
|
+
"why": "fund schemes: " + ", ".join(p for p in parts if p)})
|
|
358
|
+
return out
|