marketmyna 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- marketmyna-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|