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.
Files changed (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. 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