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,399 @@
1
+ """Market calendar: the days the exchanges we cover are shut, trade short or
2
+ hold a special session, plus the market-wide dates that move everything
3
+ (derivatives expiry, index reviews, US clock changes).
4
+
5
+ - India: NSE's trading holiday list (the official one, for the current
6
+ year); a "*" on Diwali Laxmi Pujan marks the Muhurat trading session.
7
+ Monthly expiries follow the exchange rules (Nifty: last Tuesday, Sensex:
8
+ last Thursday, a day earlier if that is a holiday).
9
+ - US: NYSE holiday and early-close rules, computed.
10
+ - Hong Kong: the government's general holiday list (HKEX shuts on every one),
11
+ half-day sessions on the eves of Christmas, New Year and Lunar New Year.
12
+ - China A: the State Council holiday schedule; Shanghai / Shenzhen shut on
13
+ every weekday off day (make-up working weekends don't trade).
14
+
15
+ Consecutive closed days become one event ("closed Thu 1 - Wed 7 Oct,
16
+ reopens Thu 8 Oct"). Each source goes through econ_calendar's cache with a
17
+ saved copy on disk, so a failed fetch serves the last good list.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ from datetime import date, datetime, timedelta
25
+ from typing import Callable, Optional
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ NSE_HOLIDAYS_URL = "https://www.nseindia.com/api/holiday-master?type=trading"
30
+ HK_HOLIDAYS_URL = "https://www.1823.gov.hk/common/ical/en.json"
31
+ CN_HOLIDAYS_URL = "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/{year}.json"
32
+ NSE_HEADERS = {
33
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36",
34
+ "Referer": "https://www.nseindia.com/",
35
+ "Accept": "application/json, text/plain, */*",
36
+ }
37
+
38
+ # region -> its exchanges
39
+ MARKETS = {
40
+ "India": "NSE / BSE",
41
+ "US": "NYSE / Nasdaq",
42
+ "Hong Kong": "HKEX",
43
+ "China": "Shanghai / Shenzhen",
44
+ }
45
+
46
+ CLOSED_WHY = {
47
+ "India": "NSE and BSE are shut: no trading or settlement, and anything due that day (F&O expiry, SIP "
48
+ "purchases) moves to a nearby working day.",
49
+ "US": "NYSE and Nasdaq are shut, so US prices don't move; global markets often trade thin and quiet.",
50
+ "Hong Kong": "HKEX is shut, and Stock Connect trading between Hong Kong and the mainland is off too.",
51
+ "China": "Shanghai and Shenzhen are shut and Stock Connect pauses. Hong Kong often moves for the mainland "
52
+ "while it is away, and the mainland catches up when it reopens.",
53
+ }
54
+
55
+ # State Council holiday names -> English
56
+ CN_NAMES = {"元旦": "New Year", "春节": "Spring Festival", "清明": "Qingming", "劳动": "Labour Day",
57
+ "端午": "Dragon Boat Festival", "中秋": "Mid-Autumn Festival", "国庆": "National Day (Golden Week)"}
58
+
59
+ FetchJson = Callable[[str], object]
60
+
61
+
62
+ def _get_json(url: str) -> object:
63
+ import requests
64
+
65
+ r = requests.get(url, headers=NSE_HEADERS if "nseindia" in url else {"User-Agent": NSE_HEADERS["User-Agent"]},
66
+ timeout=25)
67
+ r.raise_for_status()
68
+ return json.loads(r.content.decode("utf-8-sig"))
69
+
70
+
71
+ # ------------------------------------------------------------------ sources
72
+
73
+
74
+ def parse_nse(data: dict) -> list[dict]:
75
+ """[{date, name, muhurat}] from NSE's holiday master (cash market list)."""
76
+ out = []
77
+ for row in (data or {}).get("CM", []):
78
+ try:
79
+ day = datetime.strptime(row["tradingDate"], "%d-%b-%Y").date()
80
+ except (KeyError, ValueError):
81
+ continue
82
+ name = (row.get("description") or "").strip()
83
+ out.append({"date": day.isoformat(), "name": name.rstrip("*").strip(),
84
+ "muhurat": name.endswith("*") or "laxmi pujan" in name.lower()})
85
+ return out
86
+
87
+
88
+ def parse_hk(data: dict) -> list[dict]:
89
+ out = []
90
+ for cal in (data or {}).get("vcalendar", []):
91
+ for e in cal.get("vevent", []):
92
+ try:
93
+ day = datetime.strptime(str(e["dtstart"][0]), "%Y%m%d").date()
94
+ except (KeyError, IndexError, ValueError):
95
+ continue
96
+ out.append({"date": day.isoformat(), "name": e.get("summary") or "Public holiday"})
97
+ return out
98
+
99
+
100
+ def parse_cn(data: dict) -> list[dict]:
101
+ out = []
102
+ for row in (data or {}).get("days", []):
103
+ if row.get("isOffDay"):
104
+ raw = row.get("name") or ""
105
+ name = next((en for cn, en in CN_NAMES.items() if cn in raw), raw or "Public holiday")
106
+ if "国庆" in raw and "中秋" in raw:
107
+ name = "National Day / Mid-Autumn (Golden Week)"
108
+ out.append({"date": row["date"], "name": name})
109
+ return out
110
+
111
+
112
+ def _easter(year: int) -> date:
113
+ """Western Easter Sunday (anonymous Gregorian algorithm)."""
114
+ a, b, c = year % 19, year // 100, year % 100
115
+ d, e = b // 4, b % 4
116
+ g = (8 * b + 13) // 25
117
+ h = (19 * a + b - d - g + 15) % 30
118
+ i, k = c // 4, c % 4
119
+ l_ = (32 + 2 * e + 2 * i - h - k) % 7
120
+ m = (a + 11 * h + 22 * l_) // 451
121
+ month = (h + l_ - 7 * m + 114) // 31
122
+ return date(year, month, (h + l_ - 7 * m + 114) % 31 + 1)
123
+
124
+
125
+ def _nth_weekday(year: int, month: int, weekday: int, n: int) -> date:
126
+ """n-th weekday (0 = Monday) of the month; n = -1 for the last one."""
127
+ if n > 0:
128
+ d = date(year, month, 1)
129
+ d += timedelta(days=(weekday - d.weekday()) % 7)
130
+ return d + timedelta(weeks=n - 1)
131
+ d = _month_end(year, month)
132
+ return d - timedelta(days=(d.weekday() - weekday) % 7)
133
+
134
+
135
+ def _month_end(year: int, month: int) -> date:
136
+ return (date(year + (month == 12), month % 12 + 1, 1)) - timedelta(days=1)
137
+
138
+
139
+ def _observed(d: date) -> date:
140
+ return d - timedelta(days=1) if d.weekday() == 5 else d + timedelta(days=1) if d.weekday() == 6 else d
141
+
142
+
143
+ def nyse_holidays(year: int) -> list[dict]:
144
+ """NYSE full-day closures (Rule 7.2: a Saturday holiday moves to Friday,
145
+ a Sunday one to Monday; New Year's Day on a Saturday isn't made up)."""
146
+ days = [
147
+ (date(year, 1, 1), "New Year's Day"),
148
+ (_nth_weekday(year, 1, 0, 3), "Martin Luther King Jr. Day"),
149
+ (_nth_weekday(year, 2, 0, 3), "Washington's Birthday"),
150
+ (_easter(year) - timedelta(days=2), "Good Friday"),
151
+ (_nth_weekday(year, 5, 0, -1), "Memorial Day"),
152
+ (date(year, 6, 19), "Juneteenth"),
153
+ (date(year, 7, 4), "Independence Day"),
154
+ (_nth_weekday(year, 9, 0, 1), "Labor Day"),
155
+ (_nth_weekday(year, 11, 3, 4), "Thanksgiving"),
156
+ (date(year, 12, 25), "Christmas"),
157
+ ]
158
+ out = []
159
+ for d, name in days:
160
+ if d.month == 1 and d.day == 1 and d.weekday() == 5:
161
+ continue
162
+ out.append({"date": _observed(d).isoformat(), "name": name})
163
+ return out
164
+
165
+
166
+ def nyse_early_closes(year: int) -> list[dict]:
167
+ """1 pm closes: 3 July (when the 4th is a Tuesday-Friday), the day after
168
+ Thanksgiving and Christmas Eve (Monday-Thursday)."""
169
+ out = []
170
+ jul3 = date(year, 7, 3)
171
+ if jul3.weekday() <= 3:
172
+ out.append({"date": jul3.isoformat(), "name": "Independence Day eve"})
173
+ out.append({"date": (_nth_weekday(year, 11, 3, 4) + timedelta(days=1)).isoformat(), "name": "Day after Thanksgiving"})
174
+ eve = date(year, 12, 24)
175
+ if eve.weekday() <= 3:
176
+ out.append({"date": eve.isoformat(), "name": "Christmas Eve"})
177
+ return out
178
+
179
+
180
+ # ------------------------------------------------------------------ building events
181
+
182
+
183
+ class Closed:
184
+ """Which weekdays a market is shut, with the holiday names."""
185
+
186
+ def __init__(self, days: dict[date, str]):
187
+ self.days = days
188
+
189
+ def trading(self, d: date) -> bool:
190
+ return d.weekday() < 5 and d not in self.days
191
+
192
+ def next_open(self, d: date) -> date:
193
+ d += timedelta(days=1)
194
+ while not self.trading(d):
195
+ d += timedelta(days=1)
196
+ return d
197
+
198
+ def on_or_before(self, d: date) -> date:
199
+ while not self.trading(d):
200
+ d -= timedelta(days=1)
201
+ return d
202
+
203
+ def on_or_after(self, d: date) -> date:
204
+ while not self.trading(d):
205
+ d += timedelta(days=1)
206
+ return d
207
+
208
+ def runs(self) -> list[tuple[date, date, list[str]]]:
209
+ """Consecutive closures (weekends in between don't break a run)."""
210
+ out: list[tuple[date, date, list[str]]] = []
211
+ for d in sorted(self.days):
212
+ if out and self.next_open(out[-1][1]) > d:
213
+ first, _, names = out[-1]
214
+ out[-1] = (first, d, names + ([self.days[d]] if self.days[d] not in names else []))
215
+ else:
216
+ out.append((d, d, [self.days[d]]))
217
+ return out
218
+
219
+
220
+ def _fmt(d: date) -> str:
221
+ return f"{d:%a} {d.day} {d:%b}"
222
+
223
+
224
+ def _event(key: str, day: date, title: str, region: str, note: str, why: str, source: str,
225
+ importance: str = "high", time_str: Optional[str] = None, tz: Optional[str] = None,
226
+ estimated: bool = False) -> dict:
227
+ from .econ_calendar import _event as base
228
+
229
+ return {**base(key, day, title, region, "market", importance, note, source, time_str, tz, estimated), "why": why}
230
+
231
+
232
+ def closure_events(region: str, closed: Closed, start: date, end: date, source: str) -> list[dict]:
233
+ out = []
234
+ for first, last, names in closed.runs():
235
+ if last < start or first > end:
236
+ continue
237
+ span = f"Closed {_fmt(first)}" + (f" - {_fmt(last)}" if last != first else "")
238
+ out.append(_event("market_holiday", max(first, start), f"{region} market closed: {' / '.join(names)}", region,
239
+ f"{span} ({MARKETS[region]}); reopens {_fmt(closed.next_open(last))}.",
240
+ CLOSED_WHY[region], source))
241
+ return out
242
+
243
+
244
+ def india_events(rows: list[dict], start: date, end: date, years: list[int]) -> tuple[Closed, list[dict]]:
245
+ closed = Closed({date.fromisoformat(r["date"]): r["name"] for r in rows
246
+ if date.fromisoformat(r["date"]).weekday() < 5})
247
+ out = closure_events("India", closed, start, end, "NSE holiday list")
248
+ for r in rows:
249
+ d = date.fromisoformat(r["date"])
250
+ if r.get("muhurat") and start <= d <= end:
251
+ out.append(_event("special_session", d, "Muhurat trading (Diwali)", "India",
252
+ "A one-hour evening session for the new Samvat year; NSE and BSE give the exact time "
253
+ "in a circular a few weeks before.",
254
+ "A token, usually upbeat session: many investors buy a few shares for luck. Volumes are "
255
+ "thin, so moves are small and rarely say much about the year ahead.",
256
+ "NSE holiday list"))
257
+ for y in years:
258
+ budget = date(y, 2, 1)
259
+ if start <= budget <= end:
260
+ out.append(_event("special_session", budget, "Union Budget", "India",
261
+ "The Finance Minister presents the budget around 11:00 IST; when it falls on a weekend "
262
+ "the exchanges usually hold a special session.",
263
+ "Tax, spending and borrowing plans for the year: the biggest scheduled day for "
264
+ "Indian stocks, with big swings in PSU, infra, defence, rail and consumer names.",
265
+ "Usual schedule (1 February)", time_str="11:00", tz="Asia/Kolkata", estimated=True))
266
+ for m in range(1, 13):
267
+ nifty = closed.on_or_before(_nth_weekday(y, m, 1, -1))
268
+ sensex = closed.on_or_before(_nth_weekday(y, m, 3, -1))
269
+ for d, title, note in ((nifty, "Nifty F&O monthly expiry", "NSE monthly futures and options (Nifty, Bank "
270
+ "Nifty, stock F&O) expire at 15:30 IST."),
271
+ (sensex, "Sensex F&O monthly expiry", "BSE monthly Sensex / Bankex contracts expire "
272
+ "at 15:30 IST.")):
273
+ if start <= d <= end:
274
+ out.append(_event("expiry", d, title, "India", note,
275
+ "Traders roll or close positions, so the last hour can swing hard, especially "
276
+ "in stocks with big F&O open interest.", "Exchange expiry rules",
277
+ importance="medium", time_str="15:30", tz="Asia/Kolkata"))
278
+ return closed, out
279
+
280
+
281
+ def us_events(start: date, end: date, years: list[int]) -> tuple[Closed, list[dict]]:
282
+ rows = [r for y in years for r in nyse_holidays(y)]
283
+ closed = Closed({date.fromisoformat(r["date"]): r["name"] for r in rows})
284
+ out = closure_events("US", closed, start, end, "NYSE holiday rules")
285
+ for y in years:
286
+ for r in nyse_early_closes(y):
287
+ d = date.fromisoformat(r["date"])
288
+ if start <= d <= end and closed.trading(d):
289
+ out.append(_event("short_day", d, f"US early close: {r['name']}", "US",
290
+ "NYSE and Nasdaq close at 13:00 New York time (bond market at 14:00).",
291
+ "Half a session with light volume; the time shown is the close in your timezone.",
292
+ "NYSE holiday rules", importance="medium", time_str="13:00", tz="America/New_York"))
293
+ for m in range(1, 13):
294
+ d = closed.on_or_before(_nth_weekday(y, m, 4, 3))
295
+ if not (start <= d <= end):
296
+ continue
297
+ if m in (3, 6, 9, 12):
298
+ out.append(_event("expiry", d, "US quad witching + S&P rebalance", "US",
299
+ "Quarterly stock and index futures and options expire; S&P 500 and Nasdaq-100 "
300
+ "index changes take effect at the close.",
301
+ "One of the heaviest volume days of the year: index funds buy added stocks and "
302
+ "sell removed ones at the close, and prices can jump around the expiry.",
303
+ "Exchange expiry rules", time_str="16:00", tz="America/New_York"))
304
+ else:
305
+ out.append(_event("expiry", d, "US monthly options expiry", "US",
306
+ "Monthly stock and index options expire (third Friday).",
307
+ "Big option positions can pin prices near popular strikes and lift volume.",
308
+ "Exchange expiry rules", importance="medium", time_str="16:00", tz="America/New_York"))
309
+ for m in (2, 5, 8, 11):
310
+ d = closed.on_or_before(_month_end(y, m))
311
+ if start <= d <= end:
312
+ out.append(_event("index_rebalance", d, "MSCI index review takes effect", "Global",
313
+ "MSCI's quarterly index changes take effect at the close of each market today.",
314
+ "Passive funds tracking MSCI buy added stocks and sell removed ones at the close, "
315
+ "often heavy foreign flows in India, China and Hong Kong in the last minutes.",
316
+ "MSCI review schedule", importance="medium", estimated=True))
317
+ for m, n, word in ((3, 2, "start"), (11, 1, "end")):
318
+ switch = _nth_weekday(y, m, 6, n)
319
+ d = closed.on_or_after(switch + timedelta(days=1))
320
+ if start <= d <= end:
321
+ earlier = word == "start"
322
+ out.append(_event("clock_change", d, f"US clocks {'forward' if earlier else 'back'} (daylight saving "
323
+ f"{word}s)", "US",
324
+ f"US clocks changed on Sunday {switch.day} {switch:%b}. The US session still opens at "
325
+ f"09:30 New York time, which is an hour {'earlier' if earlier else 'later'} for India "
326
+ "and most of Asia; the time shown is the new open in your timezone.",
327
+ "Plan alerts, orders and evening routines around the new US hours.",
328
+ "US daylight saving rules", importance="medium", time_str="09:30", tz="America/New_York"))
329
+ return closed, out
330
+
331
+
332
+ def hk_events(rows: list[dict], start: date, end: date, years: list[int]) -> tuple[Closed, list[dict]]:
333
+ closed = Closed({date.fromisoformat(r["date"]): r["name"] for r in rows
334
+ if date.fromisoformat(r["date"]).weekday() < 5})
335
+ out = closure_events("Hong Kong", closed, start, end, "Hong Kong government holiday list")
336
+ eves = [(date(y, 12, 24), "Christmas Eve") for y in years] + [(date(y, 12, 31), "New Year's Eve") for y in years]
337
+ eves += [(date.fromisoformat(r["date"]) - timedelta(days=1), "Lunar New Year's Eve")
338
+ for r in rows if r["name"].lower() == "lunar new year's day"]
339
+ for d, name in eves: # an eve on a weekend has no session to shorten
340
+ if start <= d <= end and closed.trading(d):
341
+ out.append(_event("short_day", d, f"Hong Kong half day: {name}", "Hong Kong",
342
+ "HKEX trades the morning session only, closing at 12:00 Hong Kong time.",
343
+ "Light, holiday-thinned trade; Stock Connect also stops at midday.",
344
+ "HKEX half-day rules", importance="medium", time_str="12:00", tz="Asia/Hong_Kong"))
345
+ for y in years:
346
+ for m in range(1, 13):
347
+ d = closed.on_or_before(closed.on_or_before(_month_end(y, m)) - timedelta(days=1)) # second-last business day
348
+ if start <= d <= end:
349
+ out.append(_event("expiry", d, "Hang Seng futures & options expiry", "Hong Kong",
350
+ "Monthly Hang Seng Index futures and options expire (second-last business day).",
351
+ "Settlement is on the average price through the day, which can add volatility.",
352
+ "HKEX expiry rules", importance="medium"))
353
+ return closed, out
354
+
355
+
356
+ def cn_events(rows: list[dict], start: date, end: date, years: list[int]) -> tuple[Closed, list[dict]]:
357
+ closed = Closed({date.fromisoformat(r["date"]): r["name"] for r in rows
358
+ if date.fromisoformat(r["date"]).weekday() < 5})
359
+ out = closure_events("China", closed, start, end, "China State Council holiday schedule")
360
+ for y in years:
361
+ for m in range(1, 13):
362
+ d = closed.on_or_after(_nth_weekday(y, m, 4, 3))
363
+ if start <= d <= end:
364
+ out.append(_event("expiry", d, "CSI 300 index futures expiry", "China",
365
+ "China's stock index futures (CSI 300, SSE 50, CSI 500) expire (third Friday).",
366
+ "Settlement on the last two hours' average price; large-cap A-shares can swing.",
367
+ "CFFEX expiry rules", importance="medium"))
368
+ return closed, out
369
+
370
+
371
+ def collect(start: date, days: int, fetch: FetchJson = _get_json) -> dict:
372
+ """{"events": market events dated start..start+days, soonest first, "warnings": [...]}."""
373
+ from .econ_calendar import _source
374
+
375
+ end = start + timedelta(days=days)
376
+ # a little past the window, so "reopens" and expiries shifted off a holiday are right
377
+ years = sorted({start.year, end.year, (end + timedelta(days=20)).year})
378
+ warnings: list[str] = []
379
+
380
+ def source(name: str, loader: Callable[[], list[dict]], future: bool = False) -> list[dict]:
381
+ rows, w = _source(name, loader)
382
+ if w and not future: # next year's lists are often not published yet
383
+ warnings.append(w)
384
+ return rows
385
+
386
+ nse = source("nse_holidays", lambda: parse_nse(fetch(NSE_HOLIDAYS_URL)))
387
+ hk = source("hk_holidays", lambda: parse_hk(fetch(HK_HOLIDAYS_URL)))
388
+ cn = [r for y in years for r in source(f"cn_holidays_{y}", lambda y=y: parse_cn(fetch(CN_HOLIDAYS_URL.format(year=y))),
389
+ future=y > start.year)]
390
+ events: list[dict] = []
391
+ for build, args in ((india_events, (nse,)), (us_events, ()), (hk_events, (hk,)), (cn_events, (cn,))):
392
+ try:
393
+ events += build(*args, start, end, years)[1]
394
+ except Exception as exc: # one bad list must not hide the others
395
+ logger.warning("Market calendar %s failed: %s", build.__name__, exc)
396
+ for e in events:
397
+ e["days_away"] = (date.fromisoformat(e["date"]) - start).days
398
+ events.sort(key=lambda e: (e["date"], e["time"] or "", e["title"]))
399
+ return {"events": events, "warnings": warnings}
@@ -0,0 +1,168 @@
1
+ """Market mood gauge: six breadth and volatility readings for one market,
2
+ each mapped to 0 (fear) - 100 (greed), and their average.
3
+
4
+ Universe: the market's 200 largest stocks by market cap (as the screens),
5
+ with a year of daily closes. Readings:
6
+
7
+ - Advancing vs declining today (share of movers that rose)
8
+ - Share above the 50-day average
9
+ - Share above the 200-day average
10
+ - New 52-week highs vs lows (within 2% of either; share that are highs)
11
+ - Index vs its 125-day average (-10% -> 0, +10% -> 100)
12
+ - Volatility index vs its own past year (low = calm = high score), where the
13
+ market has one (India VIX, CBOE VIX)
14
+
15
+ The scale follows the familiar fear & greed style (0-25 extreme fear, 25-45
16
+ fear, 45-55 neutral, 55-75 greed, 75-100 extreme greed). It describes how
17
+ the market is behaving now; it is not a forecast or advice.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import threading
24
+ import time
25
+ from typing import Callable, Optional
26
+
27
+ import pandas as pd
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ UNIVERSE = 200
32
+ CACHE_SECONDS = 3600
33
+ VOL_INDEX = {"india": ("^INDIAVIX", "India VIX"), "us": ("^VIX", "VIX")}
34
+
35
+ Fetch = Callable[[list[str]], dict[str, pd.Series]]
36
+
37
+ _cache: dict[str, tuple[float, dict]] = {}
38
+ _closes_cache: dict[str, tuple[float, dict[str, pd.Series], set[str]]] = {}
39
+ _lock = threading.Lock()
40
+
41
+
42
+ def _closes(tickers: list[str]) -> dict[str, pd.Series]:
43
+ from ..trading.scanner import _cache as bars
44
+
45
+ return {t: df["close"].astype(float).dropna() for t, df in bars.get(tickers, "1d", "1y").items()}
46
+
47
+
48
+ def label(score: Optional[float]) -> Optional[str]:
49
+ if score is None:
50
+ return None
51
+ if score < 25:
52
+ return "Extreme fear"
53
+ if score < 45:
54
+ return "Fear"
55
+ if score <= 55:
56
+ return "Neutral"
57
+ if score <= 75:
58
+ return "Greed"
59
+ return "Extreme greed"
60
+
61
+
62
+ def _clip(x: float) -> float:
63
+ return max(0.0, min(100.0, x))
64
+
65
+
66
+ def readings(stocks: dict[str, pd.Series], changes: list[float], index: Optional[pd.Series],
67
+ vol: Optional[pd.Series], vol_name: Optional[str]) -> list[dict]:
68
+ out = []
69
+ adv = sum(1 for c in changes if c > 0)
70
+ dec = sum(1 for c in changes if c < 0)
71
+ if adv + dec:
72
+ out.append({"key": "breadth", "label": "Advancing vs declining today", "score": _clip(adv / (adv + dec) * 100),
73
+ "value": f"{adv} up, {dec} down"})
74
+
75
+ def share_above(days: int) -> Optional[tuple[int, int]]:
76
+ ok = [s for s in stocks.values() if len(s) >= days]
77
+ if len(ok) < 20:
78
+ return None
79
+ return sum(1 for s in ok if s.iloc[-1] > s.iloc[-days:].mean()), len(ok)
80
+
81
+ for days in (50, 200):
82
+ got = share_above(days)
83
+ if got:
84
+ above, n = got
85
+ out.append({"key": f"above_{days}", "label": f"Above the {days}-day average", "score": _clip(above / n * 100),
86
+ "value": f"{above} of {n} ({above / n:.0%})"})
87
+
88
+ year = [s for s in stocks.values() if len(s) >= 200]
89
+ if len(year) >= 20:
90
+ highs = sum(1 for s in year if s.iloc[-1] >= s.max() * 0.98)
91
+ lows = sum(1 for s in year if s.iloc[-1] <= s.min() * 1.02)
92
+ if highs + lows:
93
+ out.append({"key": "highs_lows", "label": "New 52-week highs vs lows", "score": _clip(highs / (highs + lows) * 100),
94
+ "value": f"{highs} near highs, {lows} near lows"})
95
+ else:
96
+ out.append({"key": "highs_lows", "label": "New 52-week highs vs lows", "score": 50.0,
97
+ "value": "none near a high or a low"})
98
+
99
+ if index is not None and len(index) >= 125:
100
+ gap = float(index.iloc[-1] / index.iloc[-125:].mean() - 1)
101
+ out.append({"key": "momentum", "label": "Index vs its 125-day average", "score": _clip(50 + gap * 500),
102
+ "value": f"{gap:+.1%}"})
103
+
104
+ if vol is not None and len(vol) >= 100:
105
+ now = float(vol.iloc[-1])
106
+ rank = float((vol < now).mean()) # share of the past year below today's level
107
+ out.append({"key": "volatility", "label": f"{vol_name} vs its past year", "score": _clip((1 - rank) * 100),
108
+ "value": f"{now:.1f}, higher than {rank:.0%} of the year"})
109
+ return out
110
+
111
+
112
+ def build(market: str, fetch: Fetch = _closes, rows: Optional[list[dict]] = None, use_cache: bool = True,
113
+ live: Optional[dict] = None) -> dict:
114
+ """`rows` may carry live prices (live_market.merge); `live` = Yahoo ticker ->
115
+ {price, day} moves today's close of each stock, the index and the VIX, so
116
+ every reading follows the session. A year of closes is cached either way."""
117
+ from .analysis_run import MARKET_INDICES, yahoo_symbol
118
+ from .live_market import extend_all
119
+
120
+ if market not in ("india", "us", "hong_kong", "china_a"):
121
+ raise ValueError("Market mood covers India, US, Hong Kong and China A shares")
122
+ if use_cache and not live:
123
+ with _lock:
124
+ hit = _cache.get(market)
125
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
126
+ return hit[1]
127
+ if rows is None:
128
+ from ..data.listings import get_store
129
+
130
+ rows = get_store().rows(market)
131
+ stocks = sorted((r for r in rows if r.get("type") == "stock" and r.get("market_cap")),
132
+ key=lambda r: -r["market_cap"])[:UNIVERSE]
133
+ tick = {yahoo_symbol(r["symbol"], market): r for r in stocks}
134
+ index_ticker, index_name = MARKET_INDICES[market][0]
135
+ vol_ticker, vol_name = VOL_INDEX.get(market, (None, None))
136
+ wanted = list(tick) + [index_ticker] + ([vol_ticker] if vol_ticker else [])
137
+ closes = None
138
+ if use_cache:
139
+ with _lock:
140
+ hit = _closes_cache.get(market)
141
+ if hit and time.time() - hit[0] < CACHE_SECONDS and set(wanted) <= hit[2]:
142
+ closes = hit[1]
143
+ if closes is None:
144
+ closes = fetch(wanted)
145
+ if use_cache and closes:
146
+ with _lock:
147
+ _closes_cache[market] = (time.time(), closes, set(wanted))
148
+ if live:
149
+ closes = extend_all(closes, live)
150
+ series = {t: closes[t] for t in tick if t in closes}
151
+ changes = [float(r["change_percent"]) for r in stocks if r.get("change_percent") is not None]
152
+
153
+ parts = readings(series, changes, closes.get(index_ticker), closes.get(vol_ticker) if vol_ticker else None, vol_name)
154
+ score = round(sum(p["score"] for p in parts) / len(parts), 1) if parts else None
155
+ for p in parts:
156
+ p["score"] = round(p["score"], 1)
157
+ p["label_text"] = label(p["score"])
158
+ out = {
159
+ "market": market, "score": score, "label": label(score), "readings": parts,
160
+ "universe": f"the {len(series)} largest stocks with a year of prices",
161
+ "index": index_name,
162
+ "source": ("Daily closes from the market data feed, with today's live prices" if live
163
+ else "Daily closes from the market data feed; today's moves from the listings feed"),
164
+ }
165
+ if use_cache and parts and not live:
166
+ with _lock:
167
+ _cache[market] = (time.time(), out)
168
+ return out