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,109 @@
|
|
|
1
|
+
"""Analyst estimates and how they are moving (OpenBB equity.estimates).
|
|
2
|
+
|
|
3
|
+
From Yahoo: consensus EPS and revenue for the current / next quarter and year,
|
|
4
|
+
the EPS consensus 7-90 days ago (so you see revisions), the count of analysts
|
|
5
|
+
revising up vs down, and the last four quarters of actual vs estimated EPS.
|
|
6
|
+
Rising estimates often lead a stock; repeated beats show a company that guides
|
|
7
|
+
conservatively.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from ..core.numbers import finite as _f
|
|
17
|
+
|
|
18
|
+
PERIOD_LABELS = {"0q": "Current quarter", "+1q": "Next quarter", "0y": "Current year", "+1y": "Next year"}
|
|
19
|
+
TABLES = ("earnings_estimate", "revenue_estimate", "eps_trend", "eps_revisions", "earnings_history")
|
|
20
|
+
|
|
21
|
+
TickerFetch = Callable[[str], dict]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _yahoo(yf_symbol: str) -> dict:
|
|
25
|
+
import yfinance as yf
|
|
26
|
+
|
|
27
|
+
t = yf.Ticker(yf_symbol)
|
|
28
|
+
out = {}
|
|
29
|
+
for name in TABLES:
|
|
30
|
+
try:
|
|
31
|
+
df = getattr(t, name)
|
|
32
|
+
out[name] = df if isinstance(df, pd.DataFrame) else None
|
|
33
|
+
except Exception:
|
|
34
|
+
out[name] = None
|
|
35
|
+
try:
|
|
36
|
+
info = t.info or {}
|
|
37
|
+
out["currency"] = info.get("financialCurrency") or info.get("currency")
|
|
38
|
+
except Exception:
|
|
39
|
+
out["currency"] = None
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _row(df: Optional[pd.DataFrame], period: str) -> dict:
|
|
44
|
+
if df is None or df.empty or period not in df.index:
|
|
45
|
+
return {}
|
|
46
|
+
return {k: _f(v) for k, v in df.loc[period].items()}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _chg(now: Optional[float], then: Optional[float]) -> Optional[float]:
|
|
50
|
+
return (now / then - 1) * 100 if now is not None and then not in (None, 0) else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build(yf_symbol: str, fetch: TickerFetch = _yahoo) -> dict:
|
|
54
|
+
raw = fetch(yf_symbol)
|
|
55
|
+
eps_df, rev_df = raw.get("earnings_estimate"), raw.get("revenue_estimate")
|
|
56
|
+
trend_df, rev_cnt_df = raw.get("eps_trend"), raw.get("eps_revisions")
|
|
57
|
+
|
|
58
|
+
periods = []
|
|
59
|
+
for key, label in PERIOD_LABELS.items():
|
|
60
|
+
e, r, tr, rv = _row(eps_df, key), _row(rev_df, key), _row(trend_df, key), _row(rev_cnt_df, key)
|
|
61
|
+
if not (e or r):
|
|
62
|
+
continue
|
|
63
|
+
cur = tr.get("current", e.get("avg"))
|
|
64
|
+
periods.append({
|
|
65
|
+
"key": key, "label": label,
|
|
66
|
+
"eps": {"avg": e.get("avg"), "low": e.get("low"), "high": e.get("high"),
|
|
67
|
+
"year_ago": e.get("yearAgoEps"), "growth": _pct(e.get("growth")),
|
|
68
|
+
"analysts": e.get("numberOfAnalysts")},
|
|
69
|
+
"revenue": {"avg": r.get("avg"), "low": r.get("low"), "high": r.get("high"),
|
|
70
|
+
"year_ago": r.get("yearAgoRevenue"), "growth": _pct(r.get("growth")),
|
|
71
|
+
"analysts": r.get("numberOfAnalysts")},
|
|
72
|
+
"trend": {"current": cur, "d7": tr.get("7daysAgo"), "d30": tr.get("30daysAgo"),
|
|
73
|
+
"d60": tr.get("60daysAgo"), "d90": tr.get("90daysAgo"),
|
|
74
|
+
"change_30d": _chg(cur, tr.get("30daysAgo")), "change_90d": _chg(cur, tr.get("90daysAgo"))},
|
|
75
|
+
"revisions": {"up_7d": rv.get("upLast7days"), "up_30d": rv.get("upLast30days"),
|
|
76
|
+
"down_7d": rv.get("downLast7Days"), "down_30d": rv.get("downLast30days")},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
history = []
|
|
80
|
+
hist_df = raw.get("earnings_history")
|
|
81
|
+
if hist_df is not None and not hist_df.empty:
|
|
82
|
+
for q, row in hist_df.iterrows():
|
|
83
|
+
actual, est = _f(row.get("epsActual")), _f(row.get("epsEstimate"))
|
|
84
|
+
if actual is None and est is None:
|
|
85
|
+
continue
|
|
86
|
+
history.append({"quarter": str(pd.Timestamp(q).date()) if not isinstance(q, str) else q,
|
|
87
|
+
"actual": actual, "estimate": est,
|
|
88
|
+
"surprise": _pct(_f(row.get("surprisePercent")))})
|
|
89
|
+
|
|
90
|
+
# Revision momentum on the yearly EPS consensus over 90 days
|
|
91
|
+
yearly = [p["trend"]["change_90d"] for p in periods if p["key"] in ("0y", "+1y") and p["trend"]["change_90d"] is not None]
|
|
92
|
+
momentum = None
|
|
93
|
+
if yearly:
|
|
94
|
+
avg = sum(yearly) / len(yearly)
|
|
95
|
+
momentum = {"change_90d": avg,
|
|
96
|
+
"label": "rising" if avg >= 1 else "falling" if avg <= -1 else "steady"}
|
|
97
|
+
beats = [h for h in history if h["surprise"] is not None]
|
|
98
|
+
return {
|
|
99
|
+
"currency": raw.get("currency"),
|
|
100
|
+
"periods": periods,
|
|
101
|
+
"history": history,
|
|
102
|
+
"momentum": momentum,
|
|
103
|
+
"beats": {"beat": sum(1 for h in beats if h["surprise"] > 0), "total": len(beats)} if beats else None,
|
|
104
|
+
"available": bool(periods or history),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pct(v: Optional[float]) -> Optional[float]:
|
|
109
|
+
return v * 100 if v is not None else None
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""ETF look-through: what a fund holds, its sector mix, and overlap between two
|
|
2
|
+
funds (OpenBB etf.holdings / etf.sectors / etf.info).
|
|
3
|
+
|
|
4
|
+
- US and other Yahoo-covered ETFs: Yahoo fund data (top 10 holdings, sector
|
|
5
|
+
weights, asset classes, expense ratio, category).
|
|
6
|
+
- Indian index ETFs: Yahoo has no fund data, so the tracked Nifty index is
|
|
7
|
+
inferred from the ETF's name and its full constituent list is read from
|
|
8
|
+
niftyindices.com. Weights are estimated from market cap (the real index is
|
|
9
|
+
free-float weighted, so promoter-heavy names come out overweight).
|
|
10
|
+
- Gold, silver, liquid and G-sec ETFs hold no stocks and say so.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import csv
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from typing import Callable, Optional
|
|
23
|
+
|
|
24
|
+
from marketmyna.core.home import app_home
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
NIFTY_CSV = "https://www.niftyindices.com/IndexConstituent/{file}.csv"
|
|
29
|
+
CACHE_SECONDS = 24 * 3600
|
|
30
|
+
TOP_N = 25
|
|
31
|
+
|
|
32
|
+
# (pattern on lower-cased ETF name, index file, index name); first match wins, specific before general
|
|
33
|
+
NIFTY_INDICES = [
|
|
34
|
+
(r"private bank", "ind_nifty_privatebanklist", "Nifty Private Bank"),
|
|
35
|
+
(r"psu bank", "ind_niftypsubanklist", "Nifty PSU Bank"),
|
|
36
|
+
(r"nifty bank|bank (?:nifty|bees|etf)\b", "ind_niftybanklist", "Nifty Bank"),
|
|
37
|
+
(r"nifty it\b", "ind_niftyitlist", "Nifty IT"),
|
|
38
|
+
(r"pharma", "ind_niftypharmalist", "Nifty Pharma"),
|
|
39
|
+
(r"healthcare", "ind_niftyhealthcarelist", "Nifty Healthcare"),
|
|
40
|
+
(r"fmcg", "ind_niftyfmcglist", "Nifty FMCG"),
|
|
41
|
+
(r"nifty auto", "ind_niftyautolist", "Nifty Auto"),
|
|
42
|
+
(r"nifty metal", "ind_niftymetallist", "Nifty Metal"),
|
|
43
|
+
(r"realty", "ind_niftyrealtylist", "Nifty Realty"),
|
|
44
|
+
(r"nifty energy", "ind_niftyenergylist", "Nifty Energy"),
|
|
45
|
+
(r"nifty infra", "ind_niftyinfralist", "Nifty Infrastructure"),
|
|
46
|
+
(r"cpse", "ind_niftycpselist", "Nifty CPSE"),
|
|
47
|
+
(r"nifty pse\b", "ind_niftypselist", "Nifty PSE"),
|
|
48
|
+
(r"commodities", "ind_niftycommoditieslist", "Nifty Commodities"),
|
|
49
|
+
(r"largemidcap 250|large ?mid ?cap 250", "ind_niftylargemidcap250list", "Nifty LargeMidcap 250"),
|
|
50
|
+
(r"total market", "ind_niftytotalmarket_list", "Nifty Total Market"),
|
|
51
|
+
(r"financial services", "ind_niftyfinancelist", "Nifty Financial Services"),
|
|
52
|
+
(r"next 50|junior", "ind_niftynext50list", "Nifty Next 50"),
|
|
53
|
+
(r"midcap 150", "ind_niftymidcap150list", "Nifty Midcap 150"),
|
|
54
|
+
(r"midcap 100", "ind_niftymidcap100list", "Nifty Midcap 100"),
|
|
55
|
+
(r"midcap 50", "ind_niftymidcap50list", "Nifty Midcap 50"),
|
|
56
|
+
(r"smallcap 250", "ind_niftysmallcap250list", "Nifty Smallcap 250"),
|
|
57
|
+
(r"smallcap 100", "ind_niftysmallcap100list", "Nifty Smallcap 100"),
|
|
58
|
+
(r"nifty 500", "ind_nifty500list", "Nifty 500"),
|
|
59
|
+
(r"nifty 200", "ind_nifty200list", "Nifty 200"),
|
|
60
|
+
(r"nifty 100", "ind_nifty100list", "Nifty 100"),
|
|
61
|
+
(r"nifty (?:index )?50\b|niftybees|nifty etf", "ind_nifty50list", "Nifty 50"),
|
|
62
|
+
]
|
|
63
|
+
# Factor / strategy / BSE indices we have no constituent list for
|
|
64
|
+
UNSUPPORTED = r"momentum|quality|low vol|alpha|value|equal weight|dividend|bse|sensex|nasdaq|s&p|hang seng|growth sectors"
|
|
65
|
+
NO_STOCKS = r"gold|silver|liquid|overnight|g-sec|gilt|government securities|bond|1d rate|money market|bharat bond|sdl"
|
|
66
|
+
|
|
67
|
+
SECTOR_LABELS = {
|
|
68
|
+
"realestate": "Real estate", "consumer_cyclical": "Consumer cyclical", "basic_materials": "Basic materials",
|
|
69
|
+
"consumer_defensive": "Consumer defensive", "technology": "Technology", "communication_services": "Communication",
|
|
70
|
+
"financial_services": "Financial services", "utilities": "Utilities", "industrials": "Industrials",
|
|
71
|
+
"energy": "Energy", "healthcare": "Healthcare",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Fetch = Callable[[str], str]
|
|
75
|
+
_cache: dict[str, tuple[float, object]] = {}
|
|
76
|
+
_lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _http(url: str) -> str:
|
|
80
|
+
import requests
|
|
81
|
+
|
|
82
|
+
r = requests.get(url, headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/126"}, timeout=20)
|
|
83
|
+
r.raise_for_status()
|
|
84
|
+
return r.text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def match_index(name: str) -> tuple[Optional[str], Optional[str], str]:
|
|
88
|
+
"""(file, index name, status) where status is ok | no_stocks | unsupported | unknown."""
|
|
89
|
+
n = (name or "").lower()
|
|
90
|
+
if re.search(NO_STOCKS, n):
|
|
91
|
+
return None, None, "no_stocks"
|
|
92
|
+
if re.search(UNSUPPORTED, n):
|
|
93
|
+
return None, None, "unsupported"
|
|
94
|
+
for pattern, file, label in NIFTY_INDICES:
|
|
95
|
+
if re.search(pattern, n):
|
|
96
|
+
return file, label, "ok"
|
|
97
|
+
return None, None, "unknown"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_constituents(text: str) -> list[dict]:
|
|
101
|
+
rows = list(csv.DictReader(io.StringIO(text.lstrip(""))))
|
|
102
|
+
out = [{"symbol": r.get("Symbol", "").strip(), "name": r.get("Company Name", "").strip(),
|
|
103
|
+
"industry": r.get("Industry", "").strip()} for r in rows if r.get("Symbol")]
|
|
104
|
+
if not out:
|
|
105
|
+
raise ValueError("no constituents in index file")
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _constituents(file: str, fetch: Fetch) -> list[dict]:
|
|
110
|
+
key = f"nifty:{file}"
|
|
111
|
+
with _lock:
|
|
112
|
+
hit = _cache.get(key)
|
|
113
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
114
|
+
return hit[1] # type: ignore[return-value]
|
|
115
|
+
path = app_home() / "etf"
|
|
116
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
snap = path / f"{file}.json"
|
|
118
|
+
try:
|
|
119
|
+
rows = parse_constituents(fetch(NIFTY_CSV.format(file=file)))
|
|
120
|
+
snap.write_text(json.dumps(rows), encoding="utf-8")
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.warning("Index list %s failed: %s", file, exc)
|
|
123
|
+
if not snap.exists():
|
|
124
|
+
raise
|
|
125
|
+
rows = json.loads(snap.read_text(encoding="utf-8"))
|
|
126
|
+
with _lock:
|
|
127
|
+
_cache[key] = (time.time(), rows)
|
|
128
|
+
return rows
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _india(symbol: str, row: Optional[dict], caps: dict[str, float], fetch: Fetch) -> dict:
|
|
132
|
+
name = (row or {}).get("name") or symbol
|
|
133
|
+
file, index, status = match_index(name)
|
|
134
|
+
base = {"symbol": symbol, "market": "india", "name": name, "is_etf": True, "source": "niftyindices.com"}
|
|
135
|
+
if status == "no_stocks":
|
|
136
|
+
return {**base, "status": status, "note": "This ETF holds gold, silver, bonds or cash, not stocks."}
|
|
137
|
+
if status != "ok":
|
|
138
|
+
return {**base, "status": status,
|
|
139
|
+
"note": "Holdings are only available for ETFs tracking a standard Nifty index."}
|
|
140
|
+
cons = _constituents(file, fetch)
|
|
141
|
+
total = sum(caps.get(c["symbol"], 0) for c in cons)
|
|
142
|
+
holdings = []
|
|
143
|
+
for c in cons:
|
|
144
|
+
w = caps.get(c["symbol"], 0) / total if total else 1 / len(cons)
|
|
145
|
+
holdings.append({"symbol": c["symbol"], "name": c["name"], "weight": w, "sector": c["industry"]})
|
|
146
|
+
holdings.sort(key=lambda h: -h["weight"])
|
|
147
|
+
sectors: dict[str, float] = {}
|
|
148
|
+
for h in holdings:
|
|
149
|
+
sectors[h["sector"] or "Other"] = sectors.get(h["sector"] or "Other", 0) + h["weight"]
|
|
150
|
+
return {**base, "status": "ok", "index": index, "count": len(holdings), "weights": "estimated",
|
|
151
|
+
"holdings": holdings, "top": holdings[:TOP_N],
|
|
152
|
+
"sectors": [{"name": k, "weight": v} for k, v in sorted(sectors.items(), key=lambda kv: -kv[1])],
|
|
153
|
+
"top10_weight": sum(h["weight"] for h in holdings[:10]),
|
|
154
|
+
"note": f"Tracks the {index}. Weights estimated from market cap; the index itself uses free-float weights."}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _yahoo(yf_symbol: str) -> dict:
|
|
158
|
+
import yfinance as yf
|
|
159
|
+
|
|
160
|
+
fd = yf.Ticker(yf_symbol).funds_data
|
|
161
|
+
out: dict = {}
|
|
162
|
+
top = fd.top_holdings
|
|
163
|
+
out["top"] = [{"symbol": str(sym), "name": str(r.get("Name") or sym), "weight": float(r.get("Holding Percent") or 0)}
|
|
164
|
+
for sym, r in top.iterrows()] if top is not None else []
|
|
165
|
+
out["sectors"] = dict(fd.sector_weightings or {})
|
|
166
|
+
out["assets"] = dict(fd.asset_classes or {})
|
|
167
|
+
overview = dict(fd.fund_overview or {})
|
|
168
|
+
out["category"], out["family"] = overview.get("categoryName"), overview.get("family")
|
|
169
|
+
ops = fd.fund_operations
|
|
170
|
+
try:
|
|
171
|
+
col = ops.columns[0]
|
|
172
|
+
out["expense_ratio"] = float(ops.loc["Annual Report Expense Ratio", col])
|
|
173
|
+
out["category_expense_ratio"] = float(ops.loc["Annual Report Expense Ratio", "Category Average"])
|
|
174
|
+
out["turnover"] = float(ops.loc["Annual Holdings Turnover", col])
|
|
175
|
+
out["total_assets"] = float(ops.loc["Total Net Assets", col])
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
try:
|
|
179
|
+
out["description"] = fd.description
|
|
180
|
+
except Exception:
|
|
181
|
+
out["description"] = None
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def lookthrough(symbol: str, market: str, fetch: Fetch = _http, yahoo: Callable[[str], dict] = _yahoo,
|
|
186
|
+
rows: Optional[list[dict]] = None) -> dict:
|
|
187
|
+
from .analysis_run import yahoo_symbol
|
|
188
|
+
|
|
189
|
+
symbol, market = symbol.upper(), market.lower()
|
|
190
|
+
if market == "india":
|
|
191
|
+
if rows is None:
|
|
192
|
+
from ..data.listings import get_store
|
|
193
|
+
|
|
194
|
+
rows = get_store().rows("india")
|
|
195
|
+
me = next((r for r in rows if r["symbol"].upper() == symbol), None)
|
|
196
|
+
looks_etf = bool(me and re.search(r"\betf\b|bees\b", f"{me.get('name', '')} {symbol}".lower()))
|
|
197
|
+
if me is not None and me.get("type") != "etf" and not looks_etf:
|
|
198
|
+
return {"symbol": symbol, "market": market, "is_etf": False}
|
|
199
|
+
caps = {r["symbol"]: r["market_cap"] for r in rows if r.get("type") == "stock" and r.get("market_cap")}
|
|
200
|
+
return _india(symbol, me, caps, fetch)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
d = yahoo(yahoo_symbol(symbol, market))
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
return {"symbol": symbol, "market": market, "is_etf": False, "note": str(exc)[:200]}
|
|
206
|
+
if not d.get("top") and not d.get("sectors"):
|
|
207
|
+
return {"symbol": symbol, "market": market, "is_etf": False}
|
|
208
|
+
sectors = [{"name": SECTOR_LABELS.get(k, k.replace("_", " ").title()), "weight": float(v)}
|
|
209
|
+
for k, v in d.get("sectors", {}).items() if v]
|
|
210
|
+
return {
|
|
211
|
+
"symbol": symbol, "market": market, "is_etf": True, "status": "ok", "source": "Market data feed",
|
|
212
|
+
"name": d.get("family"), "category": d.get("category"), "family": d.get("family"),
|
|
213
|
+
"expense_ratio": d.get("expense_ratio"), "category_expense_ratio": d.get("category_expense_ratio"),
|
|
214
|
+
"turnover": d.get("turnover"), "total_assets": d.get("total_assets"), "description": d.get("description"),
|
|
215
|
+
"assets": {k: float(v) for k, v in d.get("assets", {}).items() if v},
|
|
216
|
+
"top": d["top"], "holdings": d["top"], "count": None, "weights": "reported",
|
|
217
|
+
"top10_weight": sum(h["weight"] for h in d["top"][:10]),
|
|
218
|
+
"sectors": sorted(sectors, key=lambda s: -s["weight"]),
|
|
219
|
+
"note": "Only the top 10 holdings are published.",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def overlap(a: dict, b: dict) -> dict:
|
|
224
|
+
"""Weight overlap = sum over shared holdings of min(weight in A, weight in B)."""
|
|
225
|
+
wa = {h["symbol"]: h["weight"] for h in a.get("holdings") or []}
|
|
226
|
+
wb = {h["symbol"]: h["weight"] for h in b.get("holdings") or []}
|
|
227
|
+
shared = sorted(set(wa) & set(wb), key=lambda s: -min(wa[s], wb[s]))
|
|
228
|
+
partial = a.get("weights") == "reported" or b.get("weights") == "reported"
|
|
229
|
+
return {
|
|
230
|
+
"a": a["symbol"], "b": b["symbol"],
|
|
231
|
+
"overlap": sum(min(wa[s], wb[s]) for s in shared),
|
|
232
|
+
"shared_count": len(shared),
|
|
233
|
+
"shared": [{"symbol": s, "weight_a": wa[s], "weight_b": wb[s]} for s in shared[:15]],
|
|
234
|
+
"partial": partial,
|
|
235
|
+
"note": "Based on top-10 holdings only, so the true overlap is likely higher." if partial else None,
|
|
236
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Relative rotation for ETFs - the US / Hong Kong / China A counterpart of
|
|
2
|
+
India's mutual-fund rotation (services/fund_rotation.py). Same RS-Ratio /
|
|
3
|
+
RS-Momentum maths (services/rotation.py) on weekly closes, each ETF against
|
|
4
|
+
its home market:
|
|
5
|
+
|
|
6
|
+
- us: S&P 500 (SPY); sector SPDRs, themes, styles & size, countries & assets
|
|
7
|
+
- hong_kong: Hang Seng Index; HK / China index ETFs and HK-listed global ETFs
|
|
8
|
+
- china_a: Shanghai Composite; broad-index and sector ETFs
|
|
9
|
+
|
|
10
|
+
Views group the ETFs; every ETF is its own point (no baskets needed - an
|
|
11
|
+
ETF already is one). Cached a few hours.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from .rotation import TAIL, WINDOW, _weekly, quadrant, rrg
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
CACHE_SECONDS = 4 * 3600
|
|
28
|
+
MIN_WEEKS = 30
|
|
29
|
+
|
|
30
|
+
UNIVERSES: dict[str, dict] = {
|
|
31
|
+
"us": {
|
|
32
|
+
"benchmark": ("SPY", "S&P 500"),
|
|
33
|
+
"views": {
|
|
34
|
+
"Sectors": [
|
|
35
|
+
("XLK", "Technology"), ("XLF", "Financials"), ("XLV", "Health care"), ("XLY", "Consumer discretionary"),
|
|
36
|
+
("XLP", "Consumer staples"), ("XLE", "Energy"), ("XLI", "Industrials"), ("XLB", "Materials"),
|
|
37
|
+
("XLU", "Utilities"), ("XLRE", "Real estate"), ("XLC", "Communication services"),
|
|
38
|
+
],
|
|
39
|
+
"Themes": [
|
|
40
|
+
("SMH", "Semiconductors"), ("IGV", "Software"), ("CIBR", "Cybersecurity"), ("XBI", "Biotech"),
|
|
41
|
+
("ITA", "Aerospace & defense"), ("XHB", "Homebuilders"), ("KRE", "Regional banks"), ("JETS", "Airlines"),
|
|
42
|
+
("TAN", "Solar"), ("URA", "Uranium"), ("GDX", "Gold miners"), ("BOTZ", "Robotics & AI"), ("ARKK", "Disruptive innovation"),
|
|
43
|
+
],
|
|
44
|
+
"Styles & size": [
|
|
45
|
+
("QQQ", "Nasdaq 100"), ("RSP", "S&P 500 equal weight"), ("MDY", "Mid caps"), ("IWM", "Small caps"),
|
|
46
|
+
("IWF", "Large growth"), ("IWD", "Large value"), ("MTUM", "Momentum"), ("QUAL", "Quality"),
|
|
47
|
+
("USMV", "Minimum volatility"), ("VYM", "High dividend"),
|
|
48
|
+
],
|
|
49
|
+
"Countries & assets": [
|
|
50
|
+
("EFA", "Developed ex-US"), ("EEM", "Emerging markets"), ("EWJ", "Japan"), ("EWG", "Germany"),
|
|
51
|
+
("EWU", "United Kingdom"), ("INDA", "India"), ("MCHI", "China"), ("EWT", "Taiwan"), ("EWY", "South Korea"),
|
|
52
|
+
("EWZ", "Brazil"), ("GLD", "Gold"), ("TLT", "Long Treasuries"), ("HYG", "High-yield bonds"), ("USO", "Crude oil"),
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"hong_kong": {
|
|
57
|
+
"benchmark": ("^HSI", "Hang Seng Index"),
|
|
58
|
+
"views": {
|
|
59
|
+
"Hong Kong & China": [
|
|
60
|
+
("2800.HK", "Tracker Fund (Hang Seng)"), ("2828.HK", "Hang Seng China Enterprises"),
|
|
61
|
+
("3033.HK", "CSOP Hang Seng TECH"), ("3067.HK", "iShares Hang Seng TECH"),
|
|
62
|
+
("3069.HK", "ChinaAMC Hang Seng Biotech"), ("3110.HK", "Global X HS High Dividend"),
|
|
63
|
+
("2801.HK", "iShares Core MSCI China"), ("2823.HK", "iShares FTSE China A50"), ("3188.HK", "ChinaAMC CSI 300"),
|
|
64
|
+
],
|
|
65
|
+
"Global": [
|
|
66
|
+
("2834.HK", "iShares Nasdaq 100"), ("2836.HK", "iShares Core S&P BSE Sensex India"),
|
|
67
|
+
("3010.HK", "iShares Core MSCI Asia ex Japan"), ("2840.HK", "SPDR Gold"),
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
"china_a": {
|
|
72
|
+
"benchmark": ("000001.SS", "Shanghai Composite"),
|
|
73
|
+
"views": {
|
|
74
|
+
"Broad indices": [
|
|
75
|
+
("510300.SS", "CSI 300"), ("510500.SS", "CSI 500 (mid caps)"), ("159915.SZ", "ChiNext (growth)"),
|
|
76
|
+
("588000.SS", "STAR 50 (tech)"),
|
|
77
|
+
],
|
|
78
|
+
"Sectors": [
|
|
79
|
+
("512800.SS", "Banks"), ("512880.SS", "Brokers"), ("512690.SS", "Liquor (baijiu)"), ("159928.SZ", "Consumer staples"),
|
|
80
|
+
("512170.SS", "Healthcare"), ("512010.SS", "Pharma"), ("512480.SS", "Semiconductors"), ("159819.SZ", "Artificial intelligence"),
|
|
81
|
+
("515050.SS", "5G & telecom"), ("515030.SS", "New-energy vehicles"), ("515790.SS", "Solar (photovoltaic)"),
|
|
82
|
+
("516160.SS", "New energy"), ("512660.SS", "Defense"), ("512400.SS", "Non-ferrous metals"), ("515220.SS", "Coal"),
|
|
83
|
+
("512200.SS", "Real estate"), ("518880.SS", "Gold"),
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Fetch = Callable[[list[str]], dict[str, pd.Series]]
|
|
90
|
+
_cache: dict[tuple, tuple[float, dict]] = {}
|
|
91
|
+
_closes_cache: dict[tuple[str, str], tuple[float, dict[str, pd.Series]]] = {}
|
|
92
|
+
_lock = threading.Lock()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _yahoo(tickers: list[str]) -> dict[str, pd.Series]:
|
|
96
|
+
import yfinance as yf
|
|
97
|
+
|
|
98
|
+
from ..utils.yf_lock import YF_DOWNLOAD_LOCK
|
|
99
|
+
|
|
100
|
+
with YF_DOWNLOAD_LOCK:
|
|
101
|
+
df = yf.download(tickers, period="2y", interval="1d", progress=False, auto_adjust=True, threads=True)
|
|
102
|
+
closes = df["Close"]
|
|
103
|
+
return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def app_symbol(ticker: str, market: str) -> str:
|
|
107
|
+
"""Yahoo ticker -> the app's symbol for /analyze links (2800.HK -> 02800)."""
|
|
108
|
+
if market == "hong_kong":
|
|
109
|
+
return f"{int(ticker.split('.')[0]):05d}"
|
|
110
|
+
if market == "china_a":
|
|
111
|
+
return ticker.split(".")[0]
|
|
112
|
+
return ticker
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def views(market: str) -> list[str]:
|
|
116
|
+
return list(UNIVERSES[market]["views"]) if market in UNIVERSES else []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def tickers(market: str, view: Optional[str] = None) -> list[str]:
|
|
120
|
+
"""Benchmark + the view's ETFs (Yahoo tickers), for live prices."""
|
|
121
|
+
cfg = UNIVERSES[market]
|
|
122
|
+
members = cfg["views"].get(view or next(iter(cfg["views"])), [])
|
|
123
|
+
return [cfg["benchmark"][0]] + [t for t, _ in members]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build(market: str, view: Optional[str] = None, fetch: Fetch = _yahoo, use_cache: bool = True,
|
|
127
|
+
live: Optional[dict] = None) -> dict:
|
|
128
|
+
"""`live`: ticker -> {price, day}; today's close moves with it, so the
|
|
129
|
+
current week's point and quadrant follow the session."""
|
|
130
|
+
from .live_market import extend_all
|
|
131
|
+
|
|
132
|
+
if market not in UNIVERSES:
|
|
133
|
+
raise ValueError("ETF rotation covers US, Hong Kong and China A")
|
|
134
|
+
cfg = UNIVERSES[market]
|
|
135
|
+
view = view or next(iter(cfg["views"]))
|
|
136
|
+
if view not in cfg["views"]:
|
|
137
|
+
raise ValueError(f"Unknown view '{view}'")
|
|
138
|
+
key = (market, view)
|
|
139
|
+
if use_cache and not live:
|
|
140
|
+
with _lock:
|
|
141
|
+
hit = _cache.get(key)
|
|
142
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
143
|
+
return hit[1]
|
|
144
|
+
|
|
145
|
+
bench_t, bench_name = cfg["benchmark"]
|
|
146
|
+
members = cfg["views"][view]
|
|
147
|
+
closes = None
|
|
148
|
+
if use_cache:
|
|
149
|
+
with _lock:
|
|
150
|
+
hit = _closes_cache.get(key)
|
|
151
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
152
|
+
closes = hit[1]
|
|
153
|
+
if closes is None:
|
|
154
|
+
closes = fetch([bench_t] + [t for t, _ in members])
|
|
155
|
+
if use_cache and closes:
|
|
156
|
+
with _lock:
|
|
157
|
+
_closes_cache[key] = (time.time(), closes)
|
|
158
|
+
if live:
|
|
159
|
+
closes = extend_all(closes, live)
|
|
160
|
+
if bench_t not in closes:
|
|
161
|
+
raise ValueError(f"Benchmark {bench_name} unavailable")
|
|
162
|
+
bench = _weekly(closes[bench_t])
|
|
163
|
+
|
|
164
|
+
items = []
|
|
165
|
+
for ticker, name in members:
|
|
166
|
+
s = closes.get(ticker)
|
|
167
|
+
if s is None:
|
|
168
|
+
continue
|
|
169
|
+
w = _weekly(s)
|
|
170
|
+
if len(w) < MIN_WEEKS:
|
|
171
|
+
continue
|
|
172
|
+
path = rrg(w, bench)
|
|
173
|
+
if path.empty:
|
|
174
|
+
continue
|
|
175
|
+
tail = path.tail(TAIL)
|
|
176
|
+
last = tail.iloc[-1]
|
|
177
|
+
items.append({
|
|
178
|
+
"name": name, "code": ticker, "symbol": app_symbol(ticker, market), "category": view,
|
|
179
|
+
"ratio": float(last["ratio"]), "momentum": float(last["momentum"]),
|
|
180
|
+
"quadrant": quadrant(float(last["ratio"]), float(last["momentum"])),
|
|
181
|
+
"return_4w": float(w.iloc[-1] / w.iloc[-5] - 1) * 100 if len(w) > 5 else None,
|
|
182
|
+
"tail": [{"week": str(i.date()), "ratio": float(v["ratio"]), "momentum": float(v["momentum"])}
|
|
183
|
+
for i, v in tail.iterrows()],
|
|
184
|
+
})
|
|
185
|
+
order = {"leading": 0, "improving": 1, "weakening": 2, "lagging": 3}
|
|
186
|
+
items.sort(key=lambda s: (order[s["quadrant"]], -s["ratio"]))
|
|
187
|
+
out = {"by": "view", "group": view, "market": market,
|
|
188
|
+
"benchmark": {"symbol": bench_t, "name": bench_name},
|
|
189
|
+
"as_of": str(bench.index[-1].date()), "window_weeks": WINDOW, "items": items, "groups": views(market)}
|
|
190
|
+
if use_cache and items and not live:
|
|
191
|
+
with _lock:
|
|
192
|
+
_cache[key] = (time.time(), out)
|
|
193
|
+
return out
|