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,1314 @@
1
+ """Full-market instrument universe (every listed stock, ETF and mutual fund).
2
+
3
+ Each market has a provider that pulls one or two free bulk files and
4
+ normalises them into flat rows:
5
+
6
+ {symbol, name, market, type, exchange, sector, price, change,
7
+ change_percent, volume, market_cap, high_52w, low_52w, currency, ...}
8
+
9
+ Sources
10
+ -------
11
+ - India stocks/ETFs: NSE daily "PR" bhavcopy zip (prices, 52w range, market
12
+ cap for every NSE security) + EQUITY_L / SME_EQUITY_L for full names +
13
+ the Nifty Total Market list for industry. REITs (series RR) and InvITs
14
+ (series IV) come from the same zip, as units with sector REIT / InvIT.
15
+ - India mutual funds: AMFI NAVAll.txt (every scheme, NAV, AMC, category).
16
+ - US stocks/ETFs: NASDAQ screener API (NYSE/NASDAQ/AMEX), falling back to
17
+ the nasdaqtrader symbol directories (names only) when it is down.
18
+ - China A: Eastmoney quote list API (akshare's own source), falling back
19
+ to Sina Finance's full A-share list.
20
+ - Hong Kong: HKEX's official securities list, priced via Eastmoney, then
21
+ Tencent batch quotes, then Sina.
22
+ - Crypto: Binance public 24h tickers (USDT pairs).
23
+
24
+ Rows are cached on disk (~/.marketmyna/listings/<key>.json) and in memory.
25
+ Stale caches are served immediately while a background thread refreshes
26
+ them, so only the very first load of a market waits on the network.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import csv
32
+ import io
33
+ import json
34
+ import logging
35
+ import random
36
+ import re
37
+ import threading
38
+ import time
39
+ import zipfile
40
+ from datetime import datetime, timedelta
41
+ from pathlib import Path
42
+ from typing import Callable, Iterable, Optional
43
+
44
+ import requests
45
+
46
+ from marketmyna.core.home import app_home
47
+
48
+ from .categories import enrich
49
+ from .shariah import stamp as stamp_shariah
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ _UA = {
54
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
55
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
56
+ "Accept": "*/*",
57
+ }
58
+ _TIMEOUT = 40
59
+
60
+ # Listing types
61
+ STOCK = "stock"
62
+ ETF = "etf"
63
+ MUTUAL_FUND = "mf"
64
+ CRYPTO = "crypto"
65
+
66
+
67
+ def _get(url: str, **kwargs) -> requests.Response:
68
+ resp = requests.get(url, headers=_UA, timeout=_TIMEOUT, **kwargs)
69
+ resp.raise_for_status()
70
+ return resp
71
+
72
+
73
+ def _float(value) -> Optional[float]:
74
+ if value is None:
75
+ return None
76
+ if isinstance(value, (int, float)):
77
+ return float(value)
78
+ text = str(value).strip().replace(",", "").replace("$", "").replace("%", "")
79
+ if text in ("", "-", "NA", "N/A", "nan"):
80
+ return None
81
+ try:
82
+ return float(text)
83
+ except ValueError:
84
+ return None
85
+
86
+
87
+ def _row(symbol: str, name: str, market: str, type_: str, **fields) -> dict:
88
+ row = {
89
+ "symbol": symbol,
90
+ "name": name or symbol,
91
+ "market": market,
92
+ "type": type_,
93
+ "exchange": None,
94
+ "sector": None,
95
+ "price": None,
96
+ "change": None,
97
+ "change_percent": None,
98
+ "volume": None,
99
+ "market_cap": None,
100
+ "high_52w": None,
101
+ "low_52w": None,
102
+ "currency": None,
103
+ }
104
+ row.update(fields)
105
+ return row
106
+
107
+
108
+ def _with_change(row: dict, prev: Optional[float]) -> dict:
109
+ price = row.get("price")
110
+ if price is not None and prev:
111
+ row["change"] = round(price - prev, 4)
112
+ row["change_percent"] = round((price - prev) / prev * 100, 2)
113
+ return row
114
+
115
+
116
+ # ------------------------------------------------------------------ India
117
+
118
+
119
+ def _read_csv(text: str) -> list[dict]:
120
+ reader = csv.DictReader(io.StringIO(text))
121
+ reader.fieldnames = [f.strip() for f in reader.fieldnames or []]
122
+ return [{k: (v or "").strip() for k, v in r.items() if k} for r in reader]
123
+
124
+
125
+ def _nse_pr_zip() -> tuple[datetime, zipfile.ZipFile]:
126
+ """Latest NSE PR bhavcopy zip. Walks back from today to skip weekends,
127
+ holidays and the pre-close hours before today's file is published."""
128
+ day = datetime.now()
129
+ last_error: Exception | None = None
130
+ for _ in range(10):
131
+ url = f"https://nsearchives.nseindia.com/archives/equities/bhavcopy/pr/PR{day:%d%m%y}.zip"
132
+ try:
133
+ return day, zipfile.ZipFile(io.BytesIO(_get(url).content))
134
+ except Exception as exc: # 404 on non-trading days
135
+ last_error = exc
136
+ day -= timedelta(days=1)
137
+ raise RuntimeError(f"No NSE bhavcopy found in the last 10 days: {last_error}")
138
+
139
+
140
+ def _zip_csv(zf: zipfile.ZipFile, prefix: str) -> list[dict]:
141
+ for name in zf.namelist():
142
+ if name.lower().startswith(prefix):
143
+ return _read_csv(zf.read(name).decode("latin-1"))
144
+ return []
145
+
146
+
147
+ # Listed trusts: NSE series -> sector. They trade like shares (units).
148
+ TRUST_SERIES = {"RR": "REIT", "IV": "InvIT"}
149
+ # NSE abbreviates trust names ("BROOKFIELD INDIA RET"); the well-known ones
150
+ # get their full names, the rest are title-cased.
151
+ TRUST_NAMES = {
152
+ "EMBASSY": "Embassy Office Parks REIT",
153
+ "MINDSPACE": "Mindspace Business Parks REIT",
154
+ "BIRET": "Brookfield India Real Estate Trust",
155
+ "NXST": "Nexus Select Trust",
156
+ "KRT": "Knowledge Realty Trust",
157
+ "BAGMANE": "Bagmane Prime Office REIT",
158
+ "INDIGRID": "IndiGrid Infrastructure Trust",
159
+ "PGINVIT": "POWERGRID Infrastructure Investment Trust",
160
+ "IRBINVIT": "IRB InvIT Fund",
161
+ "NHIT": "National Highways Infra Trust",
162
+ "ANZEN": "Anzen India Energy Yield Plus Trust",
163
+ }
164
+
165
+
166
+ def _trust_name(symbol: str, raw: str) -> str:
167
+ if symbol in TRUST_NAMES:
168
+ return TRUST_NAMES[symbol]
169
+ name = re.sub(r"\s+", " ", raw or symbol).strip().title()
170
+ return re.sub(r"\b(Reit|Invits?)\b", lambda m: "REIT" if m.group(1) == "Reit" else "InvIT", name)
171
+
172
+
173
+ def _india_trusts(zf: zipfile.ZipFile, day: datetime) -> list[dict]:
174
+ """Every listed REIT and InvIT: the market-cap file lists all of them,
175
+ the price file has the day's trades (missing when a unit didn't trade)."""
176
+ traded = {r["SYMBOL"]: r for r in _zip_csv(zf, "pd") if r.get("SERIES") in TRUST_SERIES and r.get("SYMBOL")}
177
+ rows = []
178
+ for m in _zip_csv(zf, "mcap"):
179
+ sym, series = m.get("Symbol", ""), m.get("Series", "")
180
+ if not sym or series not in TRUST_SERIES:
181
+ continue
182
+ t = traded.get(sym, {})
183
+ vol = _float(t.get("NET_TRDQTY"))
184
+ row = _row(
185
+ sym,
186
+ _trust_name(sym, m.get("Security Name", "")),
187
+ "india",
188
+ STOCK,
189
+ exchange="NSE",
190
+ sector=TRUST_SERIES[series],
191
+ price=_float(t.get("CLOSE_PRICE")) or _float(m.get("Close Price/Paid up value(Rs.)")),
192
+ volume=int(vol) if vol is not None else None,
193
+ market_cap=_float(m.get("Market Cap(Rs.)")),
194
+ high_52w=_float(t.get("HI_52_WK")) or None,
195
+ low_52w=_float(t.get("LO_52_WK")) or None,
196
+ currency="INR",
197
+ series=series,
198
+ close_date=day.date().isoformat(),
199
+ )
200
+ rows.append(_with_change(row, _float(t.get("PREV_CL_PR"))))
201
+ return rows
202
+
203
+
204
+ def fetch_india_stocks() -> list[dict]:
205
+ day, zf = _nse_pr_zip()
206
+ mcap = {r["Symbol"]: _float(r.get("Market Cap(Rs.)")) for r in _zip_csv(zf, "mcap") if r.get("Symbol")}
207
+ mcap_names = {r["Symbol"]: r.get("Security Name", "") for r in _zip_csv(zf, "mcap") if r.get("Symbol")}
208
+
209
+ names: dict[str, str] = {}
210
+ isins: dict[str, str] = {} # links fund portfolio files (by ISIN) to symbols
211
+ for url in (
212
+ "https://nsearchives.nseindia.com/content/equities/EQUITY_L.csv",
213
+ "https://nsearchives.nseindia.com/emerge/corporates/content/SME_EQUITY_L.csv",
214
+ ):
215
+ try:
216
+ for r in _read_csv(_get(url).text):
217
+ if r.get("SYMBOL"):
218
+ names[r["SYMBOL"]] = r.get("NAME OF COMPANY") or r.get("NAME_OF_COMPANY", "")
219
+ if r.get("ISIN NUMBER"):
220
+ isins[r["SYMBOL"]] = r["ISIN NUMBER"]
221
+ except Exception as exc:
222
+ logger.warning("NSE name list %s failed: %s", url, exc)
223
+
224
+ # NSE only publishes industry for index constituents; together these
225
+ # two lists cover the ~1,000 largest names. The rest get a category
226
+ # inferred from their company name (marketmyna/data/categories.py).
227
+ industry: dict[str, str] = {}
228
+ for index in ("ind_niftytotalmarket_list", "ind_niftymicrocap250_list"):
229
+ try:
230
+ text = _get(f"https://nsearchives.nseindia.com/content/indices/{index}.csv").text
231
+ for r in _read_csv(text):
232
+ if r.get("Symbol") and r.get("Industry"):
233
+ industry.setdefault(r["Symbol"], r["Industry"])
234
+ except Exception as exc:
235
+ logger.warning("NSE industry list %s failed: %s", index, exc)
236
+
237
+ rows: dict[str, dict] = {}
238
+ # Main board (pd = all securities incl. indices; keep equity series).
239
+ for r in _zip_csv(zf, "pd") + _zip_csv(zf, "sme"):
240
+ sym = r.get("SYMBOL", "")
241
+ series = r.get("SERIES", "")
242
+ if not sym or series not in ("EQ", "BE", "BZ", "SM", "ST", "SZ"):
243
+ continue
244
+ sme = series in ("SM", "ST", "SZ")
245
+ vol = _float(r.get("NET_TRDQTY"))
246
+ row = _row(
247
+ sym,
248
+ names.get(sym) or mcap_names.get(sym) or r.get("SECURITY", sym),
249
+ "india",
250
+ STOCK,
251
+ exchange="NSE SME" if sme else "NSE",
252
+ sector=industry.get(sym) or ("SME" if sme else None),
253
+ price=_float(r.get("CLOSE_PRICE")),
254
+ volume=int(vol) if vol is not None else None,
255
+ market_cap=mcap.get(sym),
256
+ high_52w=_float(r.get("HI_52_WK")) or None,
257
+ low_52w=_float(r.get("LO_52_WK")) or None,
258
+ currency="INR",
259
+ series=series,
260
+ isin=isins.get(sym),
261
+ close_date=day.date().isoformat(), # session of the official close (bhavcopy)
262
+ )
263
+ rows[sym] = _with_change(row, _float(r.get("PREV_CL_PR")))
264
+
265
+ for r in _zip_csv(zf, "etf"):
266
+ sym = r.get("SYMBOL", "")
267
+ if not sym:
268
+ continue
269
+ vol = _float(r.get("NET TRADED QTY"))
270
+ row = _row(
271
+ sym,
272
+ f"{r['UNDERLYING'].title()} ETF" if r.get("UNDERLYING") else r.get("SECURITY", sym),
273
+ "india",
274
+ ETF,
275
+ exchange="NSE",
276
+ sector="ETF",
277
+ price=_float(r.get("CLOSE PRICE")),
278
+ volume=int(vol) if vol is not None else None,
279
+ high_52w=_float(r.get("52 WEEK HIGH")) or None,
280
+ low_52w=_float(r.get("52 WEEK LOW")) or None,
281
+ currency="INR",
282
+ underlying=r.get("UNDERLYING") or None,
283
+ close_date=day.date().isoformat(),
284
+ )
285
+ rows[sym] = _with_change(row, _float(r.get("PREVIOUS CLOSE PRICE")))
286
+
287
+ for row in _india_trusts(zf, day):
288
+ rows[row["symbol"]] = row
289
+
290
+ # Listed names with no trade today still belong in the universe.
291
+ for sym, name in names.items():
292
+ rows.setdefault(sym, _row(sym, name, "india", STOCK, exchange="NSE", sector=industry.get(sym),
293
+ market_cap=mcap.get(sym), currency="INR"))
294
+
295
+ logger.info("India stocks: %d rows (bhavcopy %s)", len(rows), day.date())
296
+ return list(rows.values())
297
+
298
+
299
+ def _amfi_category(header: str) -> tuple[str, str]:
300
+ """'Open Ended Schemes(Equity Scheme - Large Cap Fund)' ->
301
+ ('Equity', 'Large Cap Fund')."""
302
+ inner = header[header.find("(") + 1 : header.rfind(")")] if "(" in header else header
303
+ structure = header.split("(")[0].replace("Schemes", "").strip()
304
+ if " - " in inner:
305
+ group, sub = inner.split(" - ", 1)
306
+ else:
307
+ group, sub = inner, inner
308
+ group = re.sub(r"\s*Schemes?$", "", group.strip()) or "Other"
309
+ if structure and not structure.lower().startswith("open"):
310
+ sub = f"{sub} ({structure})"
311
+ return group, sub.strip()
312
+
313
+
314
+ def fetch_india_mutual_funds() -> list[dict]:
315
+ text = _get("https://portal.amfiindia.com/spages/NAVAll.txt").content.decode("utf-8", "replace")
316
+ rows: list[dict] = []
317
+ group, category, amc = "Other", "Other", ""
318
+ for raw in text.splitlines():
319
+ line = raw.strip()
320
+ if not line or line.startswith("Scheme Code"):
321
+ continue
322
+ if ";" not in line:
323
+ if "Schemes(" in line or line.endswith("Schemes"):
324
+ group, category = _amfi_category(line)
325
+ else:
326
+ amc = line
327
+ continue
328
+ parts = [p.strip() for p in line.split(";")]
329
+ if len(parts) >= 8:
330
+ code, isin, isin_re, name, plan, option, nav, date = parts[:8]
331
+ display = " - ".join(p for p in (name, plan, option) if p and p != "-")
332
+ elif len(parts) >= 6:
333
+ code, isin, isin_re, display, nav, date = parts[:6]
334
+ plan = "Direct" if "direct" in display.lower() else "Regular"
335
+ option = "Growth" if "growth" in display.lower() else ("IDCW" if "idcw" in display.lower() else "")
336
+ else:
337
+ continue
338
+ rows.append(
339
+ _row(
340
+ code,
341
+ display,
342
+ "india",
343
+ MUTUAL_FUND,
344
+ exchange=amc.replace(" Mutual Fund", "").replace(" MUTUAL FUND", "") or None,
345
+ sector=category,
346
+ price=_float(nav),
347
+ currency="INR",
348
+ amc=amc,
349
+ fund_group=group,
350
+ plan="Direct" if "direct" in (plan or "").lower() else "Regular",
351
+ option=option or None,
352
+ isin=isin if isin not in ("-", "") else None,
353
+ nav_date=date,
354
+ )
355
+ )
356
+ logger.info("India mutual funds: %d rows", len(rows))
357
+ return rows
358
+
359
+
360
+ # --------------------------------------------------------------------- US
361
+
362
+
363
+ def _nasdaq_screener(kind: str) -> list[dict]:
364
+ url = f"https://api.nasdaq.com/api/screener/{kind}?tableonly=true&download=true&limit=0"
365
+ data = _get(url).json().get("data") or {}
366
+ table = data.get("data") if kind == "etf" else data
367
+ return (table or {}).get("rows") or []
368
+
369
+
370
+ def _fetch_us_symbol_directory() -> list[dict]:
371
+ """Names-only fallback when the NASDAQ screener API is unavailable."""
372
+ rows: list[dict] = []
373
+ exchanges = {"A": "NYSE American", "N": "NYSE", "P": "NYSE Arca", "Z": "BATS", "V": "IEX"}
374
+ for url, sym_col, exch in (
375
+ ("https://www.nasdaqtrader.com/dynamic/SymDir/nasdaqlisted.txt", "Symbol", None),
376
+ ("https://www.nasdaqtrader.com/dynamic/SymDir/otherlisted.txt", "ACT Symbol", "Exchange"),
377
+ ):
378
+ lines = _get(url).text.splitlines()
379
+ reader = csv.DictReader(lines[:-1], delimiter="|") # last line = file timestamp
380
+ for r in reader:
381
+ if r.get("Test Issue") == "Y" or not r.get(sym_col):
382
+ continue
383
+ rows.append(
384
+ _row(
385
+ r[sym_col].replace(".", "-"),
386
+ r.get("Security Name", ""),
387
+ "us",
388
+ ETF if r.get("ETF") == "Y" else STOCK,
389
+ exchange=exchanges.get(r.get(exch, ""), "NASDAQ") if exch else "NASDAQ",
390
+ currency="USD",
391
+ )
392
+ )
393
+ return rows
394
+
395
+
396
+ def fetch_us() -> list[dict]:
397
+ try:
398
+ stocks = _nasdaq_screener("stocks")
399
+ etfs = _nasdaq_screener("etf")
400
+ except Exception as exc:
401
+ logger.warning("NASDAQ screener failed (%s); using symbol directory", exc)
402
+ return _fetch_us_symbol_directory()
403
+
404
+ rows: dict[str, dict] = {}
405
+ for r in stocks:
406
+ sym = (r.get("symbol") or "").strip().replace("/", "-").replace("^", "-P")
407
+ if not sym:
408
+ continue
409
+ vol = _float(r.get("volume"))
410
+ rows[sym] = _row(
411
+ sym,
412
+ r.get("name", "").strip(),
413
+ "us",
414
+ STOCK,
415
+ sector=r.get("sector") or None,
416
+ industry=r.get("industry") or None,
417
+ country=r.get("country") or None,
418
+ price=_float(r.get("lastsale")),
419
+ change=_float(r.get("netchange")),
420
+ change_percent=_float(r.get("pctchange")),
421
+ volume=int(vol) if vol is not None else None,
422
+ market_cap=_float(r.get("marketCap")) or None,
423
+ currency="USD",
424
+ )
425
+ for r in etfs:
426
+ sym = (r.get("symbol") or "").strip()
427
+ if not sym:
428
+ continue
429
+ rows[sym] = _row(
430
+ sym,
431
+ r.get("companyName", "").strip(),
432
+ "us",
433
+ ETF,
434
+ sector="ETF",
435
+ price=_float(r.get("lastSalePrice")),
436
+ change=_float(r.get("netChange")),
437
+ change_percent=round(_float(r.get("percentageChange")) or 0, 2)
438
+ if r.get("percentageChange") else None,
439
+ return_1y=_float(r.get("oneYearPercentage")),
440
+ currency="USD",
441
+ )
442
+ logger.info("US: %d rows", len(rows))
443
+ return list(rows.values())
444
+
445
+
446
+ # --------------------------------------------------------- China / HK
447
+
448
+
449
+ # Eastmoney's quote list API (the same source akshare's *_spot_em functions
450
+ # wrap) called directly, so China/HK listings need no extra package.
451
+ # It caps pages at 100 rows, so pages are fetched in parallel, and its
452
+ # mirror hosts are individually flaky (timeouts, redirect loops), so each
453
+ # page rotates through several before giving up.
454
+ _EASTMONEY_HOSTS = [
455
+ "https://82.push2.eastmoney.com",
456
+ "https://push2delay.eastmoney.com",
457
+ "http://1.push2.eastmoney.com",
458
+ "http://29.push2.eastmoney.com",
459
+ "http://33.push2.eastmoney.com",
460
+ "http://7.push2.eastmoney.com",
461
+ "http://push2delay.eastmoney.com",
462
+ ]
463
+ _EASTMONEY_FIELDS = "f2,f3,f4,f5,f9,f12,f13,f14,f20,f23,f100"
464
+ _EASTMONEY_MARKETS = {
465
+ # SSE main + STAR, SZSE main + ChiNext, BSE
466
+ "china_a": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048",
467
+ "hong_kong": "m:128+t:3,m:128+t:4,m:128+t:1,m:128+t:2",
468
+ }
469
+
470
+
471
+ def _eastmoney_page(fs: str, page: int, quick: bool = False) -> dict:
472
+ params = {
473
+ "pn": page, "pz": 100, "po": 1, "np": 1, "fltt": 2, "invt": 2,
474
+ "fid": "f20", "fs": fs, "fields": _EASTMONEY_FIELDS,
475
+ }
476
+ last_error: Exception | None = None
477
+ # Start each page on a different host to spread the load; a second
478
+ # pass after a pause rides out short throttling bursts.
479
+ n = len(_EASTMONEY_HOSTS)
480
+ # A first probe tries each host once with a short timeout; later pages
481
+ # (Eastmoney known reachable) get a second, paused pass.
482
+ for i in range(n if quick else 2 * n):
483
+ if i == n:
484
+ time.sleep(2)
485
+ host = _EASTMONEY_HOSTS[(page + i) % n]
486
+ try:
487
+ resp = requests.get(
488
+ f"{host}/api/qt/clist/get", params=params, headers=_UA, timeout=5 if quick else 10,
489
+ allow_redirects=False,
490
+ )
491
+ resp.raise_for_status()
492
+ if resp.status_code != 200:
493
+ raise RuntimeError(f"HTTP {resp.status_code}")
494
+ return resp.json().get("data") or {}
495
+ except Exception as exc:
496
+ last_error = exc
497
+ raise RuntimeError(f"Eastmoney page {page} failed on every host: {last_error}")
498
+
499
+
500
+ # When Eastmoney blocks this IP it stays blocked for a while; after one
501
+ # failed first page, go straight to the backups instead of spending minutes
502
+ # on timeouts across every mirror.
503
+ _EASTMONEY_COOLDOWN = 30 * 60
504
+ _eastmoney_blocked_until = 0.0
505
+
506
+
507
+ def _eastmoney_rows(market: str) -> list[dict]:
508
+ from concurrent.futures import ThreadPoolExecutor
509
+
510
+ global _eastmoney_blocked_until
511
+ if time.monotonic() < _eastmoney_blocked_until:
512
+ raise RuntimeError("Eastmoney skipped: it refused connections recently")
513
+ fs = _EASTMONEY_MARKETS[market]
514
+ try:
515
+ first = _eastmoney_page(fs, 1, quick=True)
516
+ except Exception:
517
+ _eastmoney_blocked_until = time.monotonic() + _EASTMONEY_COOLDOWN
518
+ raise
519
+ items = list(first.get("diff") or [])
520
+ pages = -(-int(first.get("total") or 0) // 100)
521
+ if pages > 1:
522
+ def paced(page: int) -> dict:
523
+ # Eastmoney bans IPs that burst; keep a trickle, not a flood.
524
+ time.sleep(random.uniform(0.1, 0.4))
525
+ return _eastmoney_page(fs, page)
526
+
527
+ with ThreadPoolExecutor(max_workers=3) as pool:
528
+ for data in pool.map(paced, range(2, pages + 1)):
529
+ items.extend(data.get("diff") or [])
530
+ if not items:
531
+ raise RuntimeError("Eastmoney returned no rows")
532
+ return items
533
+
534
+
535
+ def _china_exchange(code: str) -> str:
536
+ if code[0] in "569": # 5xxxxx = Shanghai-listed funds (ETFs / LOFs)
537
+ return "SSE"
538
+ if code[0] in "48":
539
+ return "BSE"
540
+ return "SZSE"
541
+
542
+
543
+ def _eastmoney_listing(market: str, currency: str) -> dict[str, dict]:
544
+ rows: dict[str, dict] = {}
545
+ for r in _eastmoney_rows(market):
546
+ sym = str(r.get("f12") or "")
547
+ name = str(r.get("f14") or sym)
548
+ # HK RMB-traded duplicate counters ("腾讯控股-R") mirror the HKD line.
549
+ if not sym or name.endswith("-R"):
550
+ continue
551
+ vol = _float(r.get("f5"))
552
+ pe = _float(r.get("f9"))
553
+ rows[sym] = _row(
554
+ sym,
555
+ name,
556
+ market,
557
+ STOCK,
558
+ exchange=_china_exchange(sym) if market == "china_a" else "HKEX",
559
+ sector=(r.get("f100") if r.get("f100") not in (None, "-") else None),
560
+ price=_float(r.get("f2")),
561
+ change=_float(r.get("f4")),
562
+ change_percent=_float(r.get("f3")),
563
+ # A-share volume is quoted in lots of 100 shares.
564
+ volume=int(vol * 100 if market == "china_a" else vol) if vol is not None else None,
565
+ market_cap=_float(r.get("f20")) or None,
566
+ pe=pe if pe and pe > 0 else None,
567
+ pb=_float(r.get("f23")),
568
+ currency=currency,
569
+ )
570
+ logger.info("%s (Eastmoney): %d rows", market, len(rows))
571
+ return rows
572
+
573
+
574
+ # Sina Finance and Tencent: the same fallbacks daily_stock_analysis uses
575
+ # when Eastmoney is throttling (akshare's stock_zh_a_spot / stock_hk_spot
576
+ # read the Sina endpoints below).
577
+ _SINA_API = "https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php"
578
+ _SINA_HEADERS = {**_UA, "Referer": "https://finance.sina.com.cn"}
579
+ _TENCENT_HEADERS = {**_UA, "Referer": "https://finance.qq.com"}
580
+
581
+
582
+ def _sina_pages(method: str, node: str, max_pages: int = 80) -> list[dict]:
583
+ """All rows of a Sina market-centre list, 100 per page, gently paced."""
584
+ out: list[dict] = []
585
+ for page in range(1, max_pages + 1):
586
+ batch = None
587
+ for attempt in range(3): # Sina has short slow spells; retry a page before giving up
588
+ try:
589
+ resp = requests.get(
590
+ f"{_SINA_API}/Market_Center.{method}",
591
+ params={"page": page, "num": 100, "sort": "symbol", "asc": 1, "node": node, "_s_r_a": "page"},
592
+ headers=_SINA_HEADERS,
593
+ timeout=20,
594
+ )
595
+ resp.raise_for_status()
596
+ batch = resp.json() or []
597
+ break
598
+ except Exception:
599
+ if attempt == 2:
600
+ raise
601
+ time.sleep(2 * (attempt + 1))
602
+ if not batch:
603
+ break
604
+ out.extend(batch)
605
+ if len(batch) < 100:
606
+ break
607
+ time.sleep(random.uniform(0.1, 0.3))
608
+ if not out:
609
+ raise RuntimeError(f"Sina returned no rows for {node}")
610
+ return out
611
+
612
+
613
+ def _sina_china_a() -> list[dict]:
614
+ rows = []
615
+ for r in _sina_pages("getHQNodeData", "hs_a"):
616
+ code = str(r.get("code") or "")
617
+ if not code:
618
+ continue
619
+ mcap = _float(r.get("mktcap")) # in 10,000 CNY
620
+ pe = _float(r.get("per"))
621
+ rows.append(_row(
622
+ code, str(r.get("name") or code), "china_a", STOCK,
623
+ exchange=_china_exchange(code),
624
+ price=_float(r.get("trade")) or None, # 0 = suspended
625
+ change=_float(r.get("pricechange")),
626
+ change_percent=_float(r.get("changepercent")),
627
+ volume=int(_float(r.get("volume")) or 0) or None,
628
+ market_cap=mcap * 1e4 if mcap else None,
629
+ pe=pe if pe and pe > 0 else None,
630
+ pb=_float(r.get("pb")),
631
+ currency="CNY",
632
+ ))
633
+ logger.info("china_a (Sina): %d rows", len(rows))
634
+ return rows
635
+
636
+
637
+ def _tencent_quote_lines(symbols: list[str], batch_size: int = 60):
638
+ """Yield the "~"-split fields of each Tencent quote, in batches."""
639
+ for start in range(0, len(symbols), batch_size):
640
+ resp = requests.get("https://qt.gtimg.cn/q=" + ",".join(symbols[start:start + batch_size]),
641
+ headers=_TENCENT_HEADERS, timeout=_TIMEOUT)
642
+ resp.raise_for_status()
643
+ for line in resp.content.decode("gbk", errors="replace").split(";"):
644
+ if '"' in line:
645
+ yield line[line.index('"') + 1:line.rindex('"')].split("~")
646
+ if start + batch_size < len(symbols):
647
+ time.sleep(random.uniform(0.05, 0.2))
648
+
649
+
650
+ def _cached_rows(key: str) -> list[dict]:
651
+ """Rows from the on-disk listing cache (last successful fetch), if any."""
652
+ path = app_home() / "listings" / f"{key}.json"
653
+ try:
654
+ return json.loads(path.read_text(encoding="utf-8")).get("rows") or []
655
+ except (OSError, ValueError):
656
+ return []
657
+
658
+
659
+ def _tencent_china_a(codes: list[str], names: dict[str, str]) -> list[dict]:
660
+ from .extra_sources import tencent_symbol
661
+
662
+ rows = []
663
+ for f in _tencent_quote_lines([tencent_symbol(c, "china_a") for c in codes]):
664
+ if len(f) < 47 or not f[2]:
665
+ continue
666
+ mcap, pe, vol = _float(f[45]), _float(f[39]), _float(f[6]) # mcap in 100M CNY, volume in lots
667
+ rows.append(_row(
668
+ f[2], f[1] or names.get(f[2], f[2]), "china_a", STOCK,
669
+ exchange=_china_exchange(f[2]), price=_float(f[3]) or None, change=_float(f[31]),
670
+ change_percent=_float(f[32]), volume=int(vol * 100) if vol else None,
671
+ market_cap=mcap * 1e8 if mcap else None, pe=pe if pe and pe > 0 else None, pb=_float(f[46]),
672
+ currency="CNY",
673
+ ))
674
+ if not rows:
675
+ raise RuntimeError("Tencent returned no China A quotes")
676
+ logger.info("china_a (Tencent, cached code list): %d rows", len(rows))
677
+ return rows
678
+
679
+
680
+ def _sina_china_funds() -> list[dict]:
681
+ """Exchange-traded funds on Shanghai / Shenzhen: ETFs and LOFs (listed
682
+ open-end funds), from Sina's market centre. Both trade like shares, so
683
+ they sit in the ETF tab; `sector` tells them apart."""
684
+ rows = []
685
+ for node, label in (("etf_hq_fund", "ETF"), ("lof_hq_fund", "LOF")):
686
+ for r in _sina_pages("getHQNodeData", node, max_pages=30):
687
+ code = str(r.get("code") or "")
688
+ if not code:
689
+ continue
690
+ mcap = _float(r.get("mktcap")) # in 10,000 CNY
691
+ rows.append(_row(
692
+ code, str(r.get("name") or code), "china_a", ETF,
693
+ exchange=_china_exchange(code),
694
+ sector=label,
695
+ price=_float(r.get("trade")) or None,
696
+ change=_float(r.get("pricechange")),
697
+ change_percent=_float(r.get("changepercent")),
698
+ volume=int(_float(r.get("volume")) or 0) or None,
699
+ market_cap=mcap * 1e4 if mcap else None,
700
+ currency="CNY",
701
+ ))
702
+ logger.info("china_a funds (Sina): %d rows", len(rows))
703
+ return rows
704
+
705
+
706
+ def fetch_china_a() -> list[dict]:
707
+ """Stocks plus exchange-traded funds (best effort: a fund-list failure
708
+ never costs the stock list)."""
709
+ stocks = _fetch_china_a_stocks()
710
+ try:
711
+ funds = _sina_china_funds()
712
+ except Exception as exc:
713
+ logger.warning("China A funds via Sina failed: %s", exc)
714
+ funds = [r for r in _cached_rows("china_a") if r.get("type") == ETF]
715
+ have = {r["symbol"] for r in stocks}
716
+ return stocks + [f for f in funds if f["symbol"] not in have]
717
+
718
+
719
+ def _fetch_china_a_stocks() -> list[dict]:
720
+ """Eastmoney, then Sina's full A-share list, then fresh Tencent prices
721
+ for the last known code list (new listings appear once a full source
722
+ is back)."""
723
+ try:
724
+ return list(_eastmoney_listing("china_a", "CNY").values())
725
+ except Exception as exc:
726
+ logger.warning("China A via Eastmoney failed (%s); falling back to Sina", exc)
727
+ try:
728
+ return _sina_china_a()
729
+ except Exception as exc:
730
+ logger.warning("China A via Sina failed (%s); refreshing cached codes via Tencent", exc)
731
+ cached = _cached_rows("china_a")
732
+ if not cached:
733
+ raise RuntimeError("Eastmoney and Sina are unavailable and there is no cached China A list yet")
734
+ stocks = [r for r in cached if r.get("type") != ETF]
735
+ return _tencent_china_a([r["symbol"] for r in stocks], {r["symbol"]: r.get("name", "") for r in stocks})
736
+
737
+
738
+ def _tencent_hk_quotes(codes: list[str], batch_size: int = 60) -> dict[str, dict]:
739
+ """Batch HK quotes from Tencent: price, change, volume, market cap,
740
+ P/E and 52-week range for up to `batch_size` codes per request."""
741
+ quotes: dict[str, dict] = {}
742
+ for f in _tencent_quote_lines([f"hk{c}" for c in codes], batch_size):
743
+ if len(f) < 50 or not f[2] or not _float(f[3]):
744
+ continue
745
+ mcap = _float(f[45]) # in 100 million HKD
746
+ pe = _float(f[39])
747
+ quotes[f[2]] = {
748
+ "name": f[1], "price": _float(f[3]), "change": _float(f[31]),
749
+ "change_percent": _float(f[32]), "volume": _float(f[6]),
750
+ "market_cap": mcap * 1e8 if mcap else None, "pe": pe if pe and pe > 0 else None,
751
+ "high_52w": _float(f[48]) or None, "low_52w": _float(f[49]) or None,
752
+ }
753
+ if not quotes:
754
+ raise RuntimeError("Tencent returned no Hong Kong quotes")
755
+ return quotes
756
+
757
+
758
+ def _sina_hk_quotes() -> dict[str, dict]:
759
+ quotes = {}
760
+ for r in _sina_pages("getHKStockData", "qbgg_hk"):
761
+ code = str(r.get("symbol") or "")
762
+ price = _float(r.get("lasttrade"))
763
+ if not code or not price:
764
+ continue
765
+ quotes[code] = {
766
+ "name": r.get("name"), "price": price, "change": _float(r.get("pricechange")),
767
+ "change_percent": _float(r.get("changepercent")), "volume": _float(r.get("volume")),
768
+ "high_52w": _float(r.get("high_52week")) or None, "low_52w": _float(r.get("low_52week")) or None,
769
+ }
770
+ return quotes
771
+
772
+
773
+ def _hong_kong_quotes(codes: list[str]) -> tuple[dict[str, dict], str]:
774
+ """Prices for HK codes: Eastmoney, then Tencent, then Sina."""
775
+ for name, fetch in (
776
+ ("Eastmoney", lambda: _eastmoney_listing("hong_kong", "HKD")),
777
+ ("Tencent", lambda: _tencent_hk_quotes(codes)),
778
+ ("Sina", _sina_hk_quotes),
779
+ ):
780
+ try:
781
+ quotes = fetch()
782
+ if quotes:
783
+ return quotes, name
784
+ except Exception as exc:
785
+ logger.warning("Hong Kong prices via %s failed: %s", name, exc)
786
+ return {}, "none"
787
+
788
+
789
+ def _read_xlsx_rows(data: bytes) -> list[dict[str, str]]:
790
+ """First sheet of an .xlsx as [{column letter: text}] (stdlib only, so
791
+ no openpyxl dependency just for one exchange file)."""
792
+ from xml.etree import ElementTree as ET
793
+
794
+ ns = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
795
+ zf = zipfile.ZipFile(io.BytesIO(data))
796
+ shared: list[str] = []
797
+ if "xl/sharedStrings.xml" in zf.namelist():
798
+ for si in ET.fromstring(zf.read("xl/sharedStrings.xml")).iter(f"{ns}si"):
799
+ shared.append("".join(t.text or "" for t in si.iter(f"{ns}t")))
800
+ out = []
801
+ for row in ET.fromstring(zf.read("xl/worksheets/sheet1.xml")).iter(f"{ns}row"):
802
+ cells: dict[str, str] = {}
803
+ for c in row.iter(f"{ns}c"):
804
+ col = re.match(r"[A-Z]+", c.get("r", "")).group()
805
+ v = c.find(f"{ns}v")
806
+ if v is None:
807
+ t = c.find(f"{ns}is/{ns}t")
808
+ cells[col] = (t.text or "") if t is not None else ""
809
+ else:
810
+ cells[col] = shared[int(v.text)] if c.get("t") == "s" else (v.text or "")
811
+ out.append(cells)
812
+ return out
813
+
814
+
815
+ _HKEX_TYPES = {
816
+ "Equity": STOCK,
817
+ "Real Estate Investment Trusts": STOCK,
818
+ "Exchange Traded Products": ETF,
819
+ }
820
+
821
+
822
+ def fetch_hong_kong() -> list[dict]:
823
+ """HKEX's official list (every stock/ETF/REIT, English names) as the
824
+ base, enriched with Eastmoney prices/industry when it is reachable."""
825
+ data = _get(
826
+ "https://www.hkex.com.hk/eng/services/trading/securities/securitieslists/ListOfSecurities.xlsx"
827
+ ).content
828
+ rows: dict[str, dict] = {}
829
+ for r in _read_xlsx_rows(data):
830
+ code, name, cat, sub = r.get("A", ""), r.get("B", ""), r.get("C", ""), r.get("D", "")
831
+ if not code.isdigit() or cat not in _HKEX_TYPES or sub == "Trading Only Securities":
832
+ continue
833
+ # RMB counters ("Tencent-R", "Baba-WR" for weighted-voting shares)
834
+ # duplicate the HKD line.
835
+ if re.search(r"-W?R$", name.strip().upper()):
836
+ continue
837
+ rows[code] = _row(
838
+ code,
839
+ name.title(),
840
+ "hong_kong",
841
+ _HKEX_TYPES[cat],
842
+ exchange="HKEX GEM" if "GEM" in sub else "HKEX",
843
+ sector="REIT" if cat.startswith("Real Estate") else ("ETF" if cat.startswith("Exchange") else None),
844
+ currency=r.get("Q") or "HKD",
845
+ isin=r.get("F") or None,
846
+ )
847
+ if not rows:
848
+ raise RuntimeError("HKEX list returned no securities")
849
+
850
+ quotes, source = _hong_kong_quotes(list(rows))
851
+ for code, row in rows.items():
852
+ q = quotes.get(code)
853
+ if not q:
854
+ continue
855
+ for key in ("price", "change", "change_percent", "volume", "market_cap", "pe", "pb", "high_52w", "low_52w"):
856
+ if q.get(key) is not None:
857
+ row[key] = q[key]
858
+ if q.get("sector") and not row.get("sector"):
859
+ row["sector"] = q["sector"]
860
+ if q.get("name"):
861
+ row["name_local"] = q["name"]
862
+ priced = sum(1 for r in rows.values() if r.get("price") is not None)
863
+ logger.info("Hong Kong: %d rows (%d priced via %s)", len(rows), priced, source)
864
+ return list(rows.values())
865
+
866
+
867
+ # ---------------------------------------------------------------- Crypto
868
+
869
+
870
+ def fetch_crypto() -> list[dict]:
871
+ # Binance's public 24h ticker endpoint returns every pair in one call,
872
+ # so no ccxt (or API key) is needed just to list the market.
873
+ tickers = _get("https://api.binance.com/api/v3/ticker/24hr").json()
874
+ rows = []
875
+ for t in tickers:
876
+ pair = t.get("symbol", "")
877
+ if not pair.endswith("USDT"):
878
+ continue
879
+ base = pair[: -len("USDT")]
880
+ price = _float(t.get("lastPrice"))
881
+ if not base or base.endswith(("UP", "DOWN", "BULL", "BEAR")) or not price:
882
+ continue
883
+ rows.append(
884
+ _row(
885
+ f"{base}-USD",
886
+ base,
887
+ "crypto",
888
+ CRYPTO,
889
+ exchange="Spot (USDT)",
890
+ price=price,
891
+ change=_float(t.get("priceChange")),
892
+ change_percent=_float(t.get("priceChangePercent")),
893
+ volume=_float(t.get("quoteVolume")),
894
+ currency="USD",
895
+ )
896
+ )
897
+ return rows
898
+
899
+
900
+ # ------------------------------------------------------------- Registry
901
+
902
+ # key -> (market, fetcher, ttl)
903
+ PROVIDERS: dict[str, tuple[str, Callable[[], list[dict]], timedelta]] = {
904
+ "india_stocks": ("india", fetch_india_stocks, timedelta(hours=6)),
905
+ "india_mf": ("india", fetch_india_mutual_funds, timedelta(hours=6)),
906
+ "us": ("us", fetch_us, timedelta(hours=1)),
907
+ # Eastmoney rate-limits hard, so China/HK refresh less often.
908
+ "china_a": ("china_a", fetch_china_a, timedelta(hours=4)),
909
+ "hong_kong": ("hong_kong", fetch_hong_kong, timedelta(hours=4)),
910
+ "crypto": ("crypto", fetch_crypto, timedelta(minutes=10)),
911
+ }
912
+
913
+ MARKET_LABELS = {
914
+ "india": "India",
915
+ "us": "US",
916
+ "china_a": "China A",
917
+ "hong_kong": "Hong Kong",
918
+ "crypto": "Crypto",
919
+ }
920
+
921
+
922
+ _RETRY_AFTER = 60.0
923
+ # An explicit refresh (any signed-in user can press it) re-fetches a source at
924
+ # most this often, so repeated clicks can't hammer NSE / Yahoo.
925
+ _FORCE_GAP = 300.0
926
+
927
+
928
+ class _Entry:
929
+ __slots__ = ("rows", "fetched_at", "attempted_at", "error", "lock", "refreshing")
930
+
931
+ def __init__(self):
932
+ self.rows: list[dict] = []
933
+ self.fetched_at: Optional[float] = None
934
+ self.attempted_at: float = 0.0
935
+ self.error: Optional[str] = None
936
+ self.lock = threading.Lock()
937
+ self.refreshing = False
938
+
939
+
940
+ class ListingStore:
941
+ """Process-wide cache of provider rows with on-disk persistence."""
942
+
943
+ def __init__(self, cache_dir: Optional[Path] = None, providers=None):
944
+ self.cache_dir = cache_dir or app_home() / "listings"
945
+ self.providers = providers or PROVIDERS
946
+ self._entries = {key: _Entry() for key in self.providers}
947
+
948
+ # ---- persistence
949
+ def _path(self, key: str) -> Path:
950
+ return self.cache_dir / f"{key}.json"
951
+
952
+ def _load_disk(self, key: str, entry: _Entry) -> None:
953
+ path = self._path(key)
954
+ if entry.fetched_at is not None or not path.exists():
955
+ return
956
+ try:
957
+ payload = json.loads(path.read_text(encoding="utf-8"))
958
+ entry.rows = stamp_shariah(enrich(payload["rows"]))
959
+ entry.fetched_at = payload["fetched_at"]
960
+ self._retry_if_unpriced(key, entry)
961
+ except Exception as exc:
962
+ logger.warning("Listing cache %s unreadable: %s", path, exc)
963
+
964
+ def _retry_if_unpriced(self, key: str, entry: _Entry) -> None:
965
+ """Names came through but prices didn't (e.g. Hong Kong while
966
+ Eastmoney is throttling): keep the rows but retry in ~10 minutes
967
+ instead of waiting out the full TTL."""
968
+ if not entry.rows or any(r.get("price") is not None for r in entry.rows):
969
+ return
970
+ ttl = self.providers[key][2].total_seconds()
971
+ entry.fetched_at = min(entry.fetched_at or 0.0, time.time() - max(0.0, ttl - 600))
972
+ entry.error = "Prices temporarily unavailable; showing names only"
973
+
974
+ def _save_disk(self, key: str, entry: _Entry) -> None:
975
+ try:
976
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
977
+ tmp = self._path(key).with_suffix(".tmp")
978
+ tmp.write_text(json.dumps({"fetched_at": entry.fetched_at, "rows": entry.rows}), encoding="utf-8")
979
+ tmp.replace(self._path(key))
980
+ except Exception as exc:
981
+ logger.warning("Listing cache %s not saved: %s", key, exc)
982
+
983
+ # ---- fetching
984
+ def _refresh(self, key: str, entry: _Entry) -> None:
985
+ fetcher = self.providers[key][1]
986
+ entry.attempted_at = time.time()
987
+ try:
988
+ rows = fetcher()
989
+ if not rows:
990
+ raise RuntimeError("source returned no rows")
991
+ entry.rows = stamp_shariah(enrich(rows))
992
+ entry.fetched_at = time.time()
993
+ entry.error = None
994
+ self._save_disk(key, entry)
995
+ self._retry_if_unpriced(key, entry)
996
+ except Exception as exc:
997
+ logger.warning("Listing provider %s failed: %s", key, exc)
998
+ entry.error = str(exc)
999
+ finally:
1000
+ entry.refreshing = False
1001
+
1002
+ def _is_stale(self, key: str, entry: _Entry) -> bool:
1003
+ ttl = self.providers[key][2]
1004
+ return entry.fetched_at is None or time.time() - entry.fetched_at > ttl.total_seconds()
1005
+
1006
+ def get(self, key: str, force: bool = False) -> list[dict]:
1007
+ entry = self._entries[key]
1008
+ with entry.lock:
1009
+ self._load_disk(key, entry)
1010
+ # Failed sources back off for a minute instead of re-hitting
1011
+ # the network (and its timeouts) on every request.
1012
+ backing_off = entry.error is not None and time.time() - entry.attempted_at < _RETRY_AFTER
1013
+ force = force and not backing_off and time.time() - entry.attempted_at >= _FORCE_GAP
1014
+ if force or (entry.fetched_at is None and not backing_off):
1015
+ # Nothing to serve yet (or explicit refresh): fetch inline.
1016
+ entry.refreshing = True
1017
+ self._refresh(key, entry)
1018
+ elif self._is_stale(key, entry) and not entry.refreshing and not backing_off:
1019
+ entry.refreshing = True
1020
+ threading.Thread(target=self._refresh, args=(key, entry), daemon=True).start()
1021
+ return entry.rows
1022
+
1023
+ def keys_for(self, market: str) -> list[str]:
1024
+ if market in ("all", "global"):
1025
+ return list(self.providers)
1026
+ return [k for k, (m, _, _) in self.providers.items() if m == market]
1027
+
1028
+ def rows(self, market: str, force: bool = False, wait: float | None = None) -> list[dict]:
1029
+ """All rows for a market. With `wait` (seconds), providers still loading
1030
+ after that are left out and keep loading in the background: a first
1031
+ "all" search on a new install does not wait minutes for China and Hong Kong."""
1032
+ keys = self.keys_for(market)
1033
+ if len(keys) == 1 and wait is None:
1034
+ return self.get(keys[0], force)
1035
+ # Several providers: fetch in parallel so "all" costs the slowest one.
1036
+ results: dict[str, list[dict]] = {}
1037
+ threads = [
1038
+ threading.Thread(target=lambda k=k: results.__setitem__(k, self.get(k, force)), daemon=True)
1039
+ for k in keys
1040
+ ]
1041
+ for t in threads:
1042
+ t.start()
1043
+ deadline = None if wait is None else time.monotonic() + wait
1044
+ for t in threads:
1045
+ t.join(None if deadline is None else max(0.0, deadline - time.monotonic()))
1046
+ return [r for k in keys for r in results.get(k, [])]
1047
+
1048
+ def lookup(self, keys: Iterable[tuple[str, str, str]]) -> dict[tuple[str, str, str], dict]:
1049
+ """(market, type, symbol) -> current listing row, for the keys
1050
+ that exist. Only loads the providers those markets need."""
1051
+ wanted = set(keys)
1052
+ found: dict[tuple[str, str, str], dict] = {}
1053
+ for market in {m for m, _, _ in wanted}:
1054
+ for row in self.rows(market):
1055
+ key = (row["market"], row["type"], row["symbol"])
1056
+ if key in wanted:
1057
+ found[key] = row
1058
+ return found
1059
+
1060
+ def status(self) -> list[dict]:
1061
+ out = []
1062
+ for key, (market, _, ttl) in self.providers.items():
1063
+ entry = self._entries[key]
1064
+ self._load_disk(key, entry)
1065
+ out.append(
1066
+ {
1067
+ "source": key,
1068
+ "market": market,
1069
+ "count": len(entry.rows),
1070
+ "fetched_at": datetime.fromtimestamp(entry.fetched_at).isoformat() if entry.fetched_at else None,
1071
+ "stale": self._is_stale(key, entry),
1072
+ "error": entry.error,
1073
+ }
1074
+ )
1075
+ return out
1076
+
1077
+
1078
+ # --------------------------------------------------------------- Querying
1079
+
1080
+ # Rough USD rates, only used to rank market caps across currencies when
1081
+ # several markets are listed together (never shown to the user).
1082
+ _USD_PER = {"USD": 1.0, "INR": 1 / 88.0, "CNY": 1 / 7.1, "HKD": 1 / 7.8}
1083
+
1084
+ SORTABLE = {"symbol", "name", "price", "change_percent", "volume", "market_cap", "high_52w", "low_52w", "sector", "category"}
1085
+
1086
+ # Market-cap size bands in USD (CNY/HKD caps are converted first).
1087
+ # Mega (≥ $200B) is checked in USD for every currency, so a ₹ company that
1088
+ # big counts too. India's other bands follow the usual ₹ crore cut-offs:
1089
+ # large ≥ 20,000 Cr, mid 5,000–20,000 Cr, small 500–5,000 Cr, micro < 500 Cr.
1090
+ _MEGA_USD = 200e9
1091
+ _CAP_BANDS_USD = {
1092
+ "mega": (_MEGA_USD, None),
1093
+ "large": (10e9, _MEGA_USD),
1094
+ "mid": (2e9, 10e9),
1095
+ "small": (300e6, 2e9),
1096
+ "micro": (None, 300e6),
1097
+ }
1098
+ _CAP_BANDS_INR = {
1099
+ "large": (2e11, None),
1100
+ "mid": (5e10, 2e11),
1101
+ "small": (5e9, 5e10),
1102
+ "micro": (None, 5e9),
1103
+ }
1104
+
1105
+
1106
+ def cap_band(row: dict) -> Optional[str]:
1107
+ cap = row.get("market_cap")
1108
+ if cap is None:
1109
+ return None
1110
+ currency = row.get("currency") or "USD"
1111
+ if cap * _USD_PER.get(currency, 1.0) >= _MEGA_USD:
1112
+ return "mega"
1113
+ if currency == "INR":
1114
+ bands = _CAP_BANDS_INR
1115
+ else:
1116
+ bands = _CAP_BANDS_USD
1117
+ cap = cap * _USD_PER.get(currency, 1.0)
1118
+ for name, (lo, hi) in bands.items():
1119
+ if (lo is None or cap >= lo) and (hi is None or cap < hi):
1120
+ return name
1121
+ return None
1122
+
1123
+
1124
+ def _pct_from(value: Optional[float], ref: Optional[float]) -> Optional[float]:
1125
+ if value is None or not ref:
1126
+ return None
1127
+ return abs(value - ref) / ref * 100
1128
+
1129
+
1130
+ def _search_rank(row: dict, q: str) -> Optional[int]:
1131
+ """Lower is better; None = no match. Symbol hits beat name hits."""
1132
+ sym = row["symbol"].lower()
1133
+ name = (row.get("name") or "").lower()
1134
+ local = row.get("name_local")
1135
+ if local and q in local.lower():
1136
+ return 2 if local.lower().startswith(q) else 6
1137
+ if sym == q:
1138
+ return 0
1139
+ if sym.startswith(q):
1140
+ return 1
1141
+ if name.startswith(q):
1142
+ return 2
1143
+ if f" {q}" in f" {name}":
1144
+ return 3
1145
+ if q in sym:
1146
+ return 4
1147
+ # Multi-word: every token must appear ("hdfc flexi direct").
1148
+ tokens = q.split()
1149
+ if len(tokens) > 1 and all(t in name or t in sym for t in tokens):
1150
+ return 5
1151
+ if q in name:
1152
+ return 6
1153
+ return None
1154
+
1155
+
1156
+ def query(
1157
+ rows: Iterable[dict],
1158
+ q: str = "",
1159
+ type_: str = "all",
1160
+ sector: str = "",
1161
+ exchange: str = "",
1162
+ plan: str = "",
1163
+ option: str = "",
1164
+ min_cap: Optional[float] = None,
1165
+ max_cap: Optional[float] = None,
1166
+ categories: Iterable[str] = (),
1167
+ cap_sizes: Iterable[str] = (),
1168
+ shariah: Iterable[str] = (),
1169
+ price_min: Optional[float] = None,
1170
+ price_max: Optional[float] = None,
1171
+ change_min: Optional[float] = None,
1172
+ change_max: Optional[float] = None,
1173
+ near_high: Optional[float] = None,
1174
+ near_low: Optional[float] = None,
1175
+ min_volume: Optional[float] = None,
1176
+ max_volume: Optional[float] = None,
1177
+ markets: Iterable[str] = (),
1178
+ sort: str = "market_cap",
1179
+ order: str = "desc",
1180
+ offset: int = 0,
1181
+ limit: int = 50,
1182
+ ) -> dict:
1183
+ """Filter, facet, sort and page listing rows.
1184
+
1185
+ shariah keeps rows whose Shariah screen status (halal / doubtful /
1186
+ haram / unknown, see marketmyna/data/shariah.py) is in the set.
1187
+ near_high / near_low keep rows within that many percent of their
1188
+ 52-week high / low. Price bounds are in each row's own currency.
1189
+ """
1190
+ q = q.strip().lower()
1191
+ wanted_categories = {c for c in categories if c}
1192
+ wanted_sizes = {c for c in cap_sizes if c}
1193
+ wanted_shariah = {c for c in shariah if c}
1194
+ wanted_markets = {m for m in markets if m}
1195
+ market_counts: dict[str, int] = {}
1196
+ matched: list[tuple[int, dict]] = []
1197
+ sectors: dict[str, int] = {}
1198
+ types: dict[str, int] = {}
1199
+ category_counts: dict[str, int] = {}
1200
+ shariah_counts: dict[str, int] = {}
1201
+ for row in rows:
1202
+ if exchange and row.get("exchange") != exchange:
1203
+ continue
1204
+ if plan and row.get("plan") != plan:
1205
+ continue
1206
+ if option and option.lower() not in (row.get("option") or "").lower():
1207
+ continue
1208
+ cap = row.get("market_cap")
1209
+ if min_cap is not None and (cap is None or cap < min_cap):
1210
+ continue
1211
+ if max_cap is not None and (cap is None or cap > max_cap):
1212
+ continue
1213
+ if wanted_sizes and cap_band(row) not in wanted_sizes:
1214
+ continue
1215
+ price = row.get("price")
1216
+ if price_min is not None and (price is None or price < price_min):
1217
+ continue
1218
+ if price_max is not None and (price is None or price > price_max):
1219
+ continue
1220
+ change = row.get("change_percent")
1221
+ if change_min is not None and (change is None or change < change_min):
1222
+ continue
1223
+ if change_max is not None and (change is None or change > change_max):
1224
+ continue
1225
+ if min_volume is not None and (row.get("volume") or 0) < min_volume:
1226
+ continue
1227
+ if max_volume is not None and (row.get("volume") is None or row["volume"] > max_volume):
1228
+ continue
1229
+ if near_high is not None:
1230
+ gap = _pct_from(price, row.get("high_52w"))
1231
+ if gap is None or gap > near_high:
1232
+ continue
1233
+ if near_low is not None:
1234
+ gap = _pct_from(price, row.get("low_52w"))
1235
+ if gap is None or gap > near_low:
1236
+ continue
1237
+ rank = 0
1238
+ if q:
1239
+ r = _search_rank(row, q)
1240
+ if r is None:
1241
+ continue
1242
+ rank = r
1243
+
1244
+ # Each facet ignores its own filter so the type tabs, category
1245
+ # chips and industry dropdown keep showing every choice + count.
1246
+ type_ok = type_ == "all" or row["type"] == type_
1247
+ market_ok = not wanted_markets or row.get("market") in wanted_markets
1248
+ category_ok = not wanted_categories or row.get("category") in wanted_categories
1249
+ sector_ok = not sector or row.get("sector") == sector
1250
+ shariah_ok = not wanted_shariah or row.get("shariah") in wanted_shariah
1251
+ if market_ok and category_ok and sector_ok and shariah_ok:
1252
+ types[row["type"]] = types.get(row["type"], 0) + 1
1253
+ if market_ok and type_ok and sector_ok and shariah_ok and row.get("category"):
1254
+ category_counts[row["category"]] = category_counts.get(row["category"], 0) + 1
1255
+ if market_ok and type_ok and category_ok and shariah_ok and row.get("sector"):
1256
+ sectors[row["sector"]] = sectors.get(row["sector"], 0) + 1
1257
+ if market_ok and type_ok and category_ok and sector_ok and row.get("shariah"):
1258
+ shariah_counts[row["shariah"]] = shariah_counts.get(row["shariah"], 0) + 1
1259
+ if type_ok and category_ok and sector_ok and shariah_ok:
1260
+ market_counts[row.get("market") or ""] = market_counts.get(row.get("market") or "", 0) + 1
1261
+ if market_ok:
1262
+ matched.append((rank, row))
1263
+
1264
+ sort = sort if sort in SORTABLE else "market_cap"
1265
+ reverse = order != "asc"
1266
+ is_text = sort in ("symbol", "name", "sector", "category")
1267
+
1268
+ mixed = sort == "market_cap" and len({m[1].get("currency") for m in matched}) > 1
1269
+
1270
+ def value(item: tuple[int, dict]):
1271
+ v = item[1].get(sort)
1272
+ if is_text:
1273
+ return (v or "").lower()
1274
+ if mixed and v is not None:
1275
+ return v * _USD_PER.get(item[1].get("currency") or "USD", 1.0)
1276
+ return v
1277
+
1278
+ if q and sort == "market_cap" and order == "desc":
1279
+ # Default sort while searching: relevance first, then size.
1280
+ ordered = sorted(matched, key=lambda m: (m[0], -(value(m) or 0)))
1281
+ else:
1282
+ present = [m for m in matched if value(m) not in (None, "")]
1283
+ missing = [m for m in matched if value(m) in (None, "")]
1284
+ present.sort(key=value, reverse=reverse)
1285
+ ordered = present + missing # blanks always last
1286
+
1287
+ page = [m[1] for m in ordered[offset : offset + limit]]
1288
+ return {
1289
+ "total": len(ordered),
1290
+ "offset": offset,
1291
+ "limit": limit,
1292
+ "rows": page,
1293
+ "facets": {
1294
+ "sectors": sorted(({"value": k, "count": v} for k, v in sectors.items()), key=lambda x: -x["count"]),
1295
+ "types": types,
1296
+ "categories": sorted(
1297
+ ({"value": k, "count": v} for k, v in category_counts.items()), key=lambda x: -x["count"]
1298
+ ),
1299
+ "shariah": shariah_counts,
1300
+ "markets": market_counts,
1301
+ },
1302
+ }
1303
+
1304
+
1305
+ _store: Optional[ListingStore] = None
1306
+ _store_lock = threading.Lock()
1307
+
1308
+
1309
+ def get_store() -> ListingStore:
1310
+ global _store
1311
+ with _store_lock:
1312
+ if _store is None:
1313
+ _store = ListingStore()
1314
+ return _store