From b9d8711fa2c6a275e7d0a6708179975da8b27df8 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Fri, 3 Apr 2026 12:17:43 +0800 Subject: [PATCH 1/7] refactor(market): extract source modules from akshare_provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move existing direct-API implementations into dedicated source modules (sina.py, tencent.py, eastmoney.py) under market/sources/. Shared utilities (parse_float, market_prefix, bypass_proxy, etc.) go into _common.py. No callers changed yet — akshare_provider.py still works. Co-Authored-By: Claude Opus 4.6 --- haoinvest/market/sources/__init__.py | 1 + haoinvest/market/sources/_common.py | 65 +++++++++++++ haoinvest/market/sources/eastmoney.py | 32 +++++++ haoinvest/market/sources/sina.py | 130 ++++++++++++++++++++++++++ haoinvest/market/sources/tencent.py | 117 +++++++++++++++++++++++ 5 files changed, 345 insertions(+) create mode 100644 haoinvest/market/sources/__init__.py create mode 100644 haoinvest/market/sources/_common.py create mode 100644 haoinvest/market/sources/eastmoney.py create mode 100644 haoinvest/market/sources/sina.py create mode 100644 haoinvest/market/sources/tencent.py diff --git a/haoinvest/market/sources/__init__.py b/haoinvest/market/sources/__init__.py new file mode 100644 index 0000000..78800ab --- /dev/null +++ b/haoinvest/market/sources/__init__.py @@ -0,0 +1 @@ +"""Direct API data sources for A-share market data.""" diff --git a/haoinvest/market/sources/_common.py b/haoinvest/market/sources/_common.py new file mode 100644 index 0000000..54a24ae --- /dev/null +++ b/haoinvest/market/sources/_common.py @@ -0,0 +1,65 @@ +"""Shared utilities for market data sources.""" + +import os +from contextlib import contextmanager +from typing import Any + +_PROXY_VARS = ( + "http_proxy", + "https_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", +) + + +@contextmanager +def bypass_proxy(): + """Temporarily remove proxy env vars so requests to domestic APIs go direct.""" + saved = {} + for var in _PROXY_VARS: + if var in os.environ: + saved[var] = os.environ.pop(var) + try: + yield + finally: + os.environ.update(saved) + + +def market_prefix(symbol: str) -> str: + """Return 'sh' or 'sz' based on A-share stock code convention.""" + if symbol.startswith(("6", "9")): + return "sh" + return "sz" + + +def secid(symbol: str) -> str: + """Return eastmoney secid like '1.603618' (1=SH, 0=SZ).""" + return f"1.{symbol}" if symbol.startswith(("6", "9")) else f"0.{symbol}" + + +def exchange_prefix(symbol: str) -> str: + """Return 'SH' or 'SZ' for eastmoney web API code parameter.""" + return "SH" if symbol.startswith(("6", "9")) else "SZ" + + +def parse_float(value: Any) -> float | None: + """Parse a value to float, returning None for empty/invalid values.""" + if value is None or value == "": + return None + try: + result = float(value) + return result if result != 0 else None + except (ValueError, TypeError): + return None + + +def parse_int(value: Any) -> int | None: + """Parse a value to int, returning None for empty/invalid values.""" + if value is None or value == "": + return None + try: + return int(float(value)) + except (ValueError, TypeError): + return None diff --git a/haoinvest/market/sources/eastmoney.py b/haoinvest/market/sources/eastmoney.py new file mode 100644 index 0000000..dce5bbd --- /dev/null +++ b/haoinvest/market/sources/eastmoney.py @@ -0,0 +1,32 @@ +"""Eastmoney web API data source for A-share market data. + +Endpoints: +- emweb.securities.eastmoney.com — company info (F10 page backend) +- datacenter-web.eastmoney.com — financial reports and indicators +""" + +import logging + +import requests + +from ...models import BasicInfo +from ._common import exchange_prefix, parse_float, parse_int + +logger = logging.getLogger(__name__) + + +def get_basic_info(symbol: str) -> BasicInfo: + """Get basic company info from eastmoney emweb CompanySurvey API.""" + code = f"{exchange_prefix(symbol)}{symbol}" + url = "https://emweb.securities.eastmoney.com/pc_hsf10/CompanySurvey/CompanySurveyAjax" + r = requests.get(url, params={"code": code}, timeout=10) + r.raise_for_status() + data = r.json() + jbzl = data.get("jbzl", {}) + + return BasicInfo( + name=jbzl.get("agjc", ""), + sector=jbzl.get("sshy", ""), + currency="CNY", + market_type="a_share", + ) diff --git a/haoinvest/market/sources/sina.py b/haoinvest/market/sources/sina.py new file mode 100644 index 0000000..4cfdb79 --- /dev/null +++ b/haoinvest/market/sources/sina.py @@ -0,0 +1,130 @@ +"""Sina Finance API data source for A-share market data. + +Endpoints: +- hq.sinajs.cn — real-time quotes +- vip.stock.finance.sina.com.cn — sector/industry data +""" + +import json +import re + +import requests + +from ._common import market_prefix, parse_float, parse_int + + +def get_current_price(symbol: str) -> float: + """Get current price from Sina Finance API.""" + prefix = market_prefix(symbol) + url = f"https://hq.sinajs.cn/list={prefix}{symbol}" + r = requests.get( + url, + headers={"Referer": "https://finance.sina.com.cn"}, + timeout=10, + ) + r.raise_for_status() + # Format: var hq_str_sh603618="name,open,prev_close,current,high,low,..."; + text = r.text.strip() + if '=""' in text or not text: + raise ValueError(f"Symbol {symbol} not found in A-share market") + fields = text.split('"')[1].split(",") + if len(fields) < 4: + raise RuntimeError(f"Unexpected Sina response format for {symbol}") + price = float(fields[3]) # current price + if price <= 0: + raise RuntimeError(f"Invalid price for {symbol} from Sina API") + return price + + +def get_sector_list() -> list[dict]: + """List all A-share industry boards with performance ranking. + + Returns list of dicts with keys: name, change_pct, total_market_cap, + turnover_rate, rise_count, fall_count. + """ + data = _fetch_sector_data() + rows = [] + for fields in data.values(): + if len(fields) < 6: + continue + rows.append( + { + "name": fields[1], + "change_pct": parse_float(fields[5]), + "total_market_cap": None, + "turnover_rate": None, + "rise_count": None, + "fall_count": None, + } + ) + # Sort by change_pct descending (match eastmoney default sort) + rows.sort(key=lambda r: r["change_pct"] or 0, reverse=True) + return rows + + +def get_sector_constituents(sector_name: str) -> list[dict]: + """Get stocks in a specific industry board. + + Returns list of dicts with keys: code, name, price, change_pct, + pe_ratio, pb_ratio, total_market_cap. + """ + # Find the Sina node code matching the sector name + data = _fetch_sector_data() + node_code = None + for code, fields in data.items(): + if len(fields) >= 2 and fields[1] == sector_name: + node_code = code + break + if node_code is None: + raise ValueError( + f"Sector '{sector_name}' not found. " + "Use 'market sector-list' to see available sectors." + ) + + r = requests.get( + "https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php" + "/Market_Center.getHQNodeData", + params={ + "page": 1, + "num": 200, + "sort": "changepercent", + "asc": 0, + "node": node_code, + }, + headers={"Referer": "https://finance.sina.com.cn"}, + timeout=10, + ) + r.encoding = "gbk" + stocks = r.json() + + rows = [] + for s in stocks: + rows.append( + { + "code": s.get("code", ""), + "name": s.get("name", ""), + "price": parse_float(s.get("trade")), + "change_pct": parse_float(s.get("changepercent")), + "pe_ratio": parse_float(s.get("per")), + "pb_ratio": parse_float(s.get("pb")), + "total_market_cap": ( + int(s["mktcap"] * 10000) if s.get("mktcap") else None + ), + } + ) + return rows + + +def _fetch_sector_data() -> dict[str, list[str]]: + """Fetch Sina industry board data. Returns {node_code: [fields...]}.""" + r = requests.get( + "https://vip.stock.finance.sina.com.cn/q/view/newSinaHy.php", + headers={"Referer": "https://finance.sina.com.cn"}, + timeout=10, + ) + r.encoding = "gbk" + m = re.search(r"=\s*(\{.*\})", r.text) + if not m: + raise RuntimeError("Failed to parse Sina sector response") + data = json.loads(m.group(1)) + return {k: v.split(",") for k, v in data.items()} diff --git a/haoinvest/market/sources/tencent.py b/haoinvest/market/sources/tencent.py new file mode 100644 index 0000000..9113101 --- /dev/null +++ b/haoinvest/market/sources/tencent.py @@ -0,0 +1,117 @@ +"""Tencent Finance API data source for A-share market data. + +Endpoints: +- qt.gtimg.cn — real-time quotes and valuation +- web.ifzq.gtimg.cn — historical kline data (forward-adjusted) +""" + +import logging +from datetime import date + +import requests + +from ...models import MarketType, PriceBar +from ._common import market_prefix, parse_float + +logger = logging.getLogger(__name__) + +# Tencent quote field indices (format: v_sh600519="1~name~code~price~...") +_PE_TTM = 39 +_TOTAL_CAP_YI = 45 # total market cap in 亿元 +_PB = 46 + + +def get_current_price(symbol: str) -> float: + """Get current price from Tencent Finance quote API.""" + prefix = market_prefix(symbol) + r = requests.get( + f"https://qt.gtimg.cn/q={prefix}{symbol}", + timeout=10, + ) + r.raise_for_status() + fields = r.text.strip().split("~") + if len(fields) < 4 or not fields[3]: + raise ValueError(f"Symbol {symbol} not found in A-share market") + price = float(fields[3]) + if price <= 0: + raise RuntimeError(f"Invalid price for {symbol} from Tencent API") + return price + + +def get_price_history(symbol: str, start: date, end: date) -> list[PriceBar]: + """Get forward-adjusted daily klines from Tencent Finance API.""" + prefix = market_prefix(symbol) + start_str = start.strftime("%Y-%m-%d") + end_str = end.strftime("%Y-%m-%d") + days = (end - start).days + 50 + url = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get" + r = requests.get( + url, + params={"param": f"{prefix}{symbol},day,{start_str},{end_str},{days},qfq"}, + timeout=15, + ) + r.raise_for_status() + data = r.json() + stock_data = data.get("data", {}).get(f"{prefix}{symbol}", {}) + klines = stock_data.get("qfqday") or stock_data.get("day", []) + + bars = [] + for k in klines: + # Tencent kline format: [date, open, close, high, low, volume] + if len(k) < 6: + continue + bar_date = date.fromisoformat(k[0]) + if bar_date < start or bar_date > end: + continue + bars.append( + PriceBar( + symbol=symbol, + market_type=MarketType.A_SHARE, + trade_date=bar_date, + open=float(k[1]), + high=float(k[3]), + low=float(k[4]), + close=float(k[2]), + volume=float(k[5]), + ) + ) + return bars + + +def get_valuation(symbol: str) -> dict: + """Fetch PE/PB/market cap from Tencent quote API. + + Returns dict with keys: pe_ratio, pb_ratio, total_market_cap. + Values are typed (float/int) or None if unavailable. + """ + result: dict[str, float | int | None] = { + "pe_ratio": None, + "pb_ratio": None, + "total_market_cap": None, + } + try: + prefix = market_prefix(symbol) + r = requests.get( + f"https://qt.gtimg.cn/q={prefix}{symbol}", + timeout=10, + ) + r.raise_for_status() + fields = r.text.strip().split("~") + if len(fields) <= _PB: + logger.debug( + "Tencent response too short for %s: %d fields", symbol, len(fields) + ) + return result + + result["pe_ratio"] = parse_float(fields[_PE_TTM]) + result["pb_ratio"] = parse_float(fields[_PB]) + + cap_yi = fields[_TOTAL_CAP_YI] + if cap_yi: + cap_val = parse_float(cap_yi) + if cap_val is not None: + result["total_market_cap"] = int(cap_val * 1_0000_0000) + except Exception as e: + logger.debug("Tencent valuation failed for %s: %s", symbol, e) + + return result From f83b9eea1298ba8dc87c65aeb3ae03430a77d76f Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Fri, 3 Apr 2026 12:19:39 +0800 Subject: [PATCH 2/7] feat(market): add eastmoney datacenter financial indicators API Add get_financial_indicators() to eastmoney source module, using the datacenter-web.eastmoney.com RPT_LICO_FN_CPD report for ROE, gross margin, and computed profit margin. Gracefully returns empty dict on failure. Includes unit tests with mocked API responses. Co-Authored-By: Claude Opus 4.6 --- haoinvest/market/sources/eastmoney.py | 59 +++++++++++- tests/test_market/test_sources/__init__.py | 0 .../test_sources/test_eastmoney.py | 89 +++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/test_market/test_sources/__init__.py create mode 100644 tests/test_market/test_sources/test_eastmoney.py diff --git a/haoinvest/market/sources/eastmoney.py b/haoinvest/market/sources/eastmoney.py index dce5bbd..e64b474 100644 --- a/haoinvest/market/sources/eastmoney.py +++ b/haoinvest/market/sources/eastmoney.py @@ -10,10 +10,18 @@ import requests from ...models import BasicInfo -from ._common import exchange_prefix, parse_float, parse_int +from ._common import exchange_prefix, parse_float logger = logging.getLogger(__name__) +_DATACENTER_URL = "https://datacenter-web.eastmoney.com/api/data/v1/get" + +# Columns we request from RPT_LICO_FN_CPD (业绩报表) +_FIN_COLUMNS = ( + "SECURITY_CODE,REPORTDATE,WEIGHTAVG_ROE,XSMLL," + "TOTAL_OPERATE_INCOME,PARENT_NETPROFIT" +) + def get_basic_info(symbol: str) -> BasicInfo: """Get basic company info from eastmoney emweb CompanySurvey API.""" @@ -30,3 +38,52 @@ def get_basic_info(symbol: str) -> BasicInfo: currency="CNY", market_type="a_share", ) + + +def get_financial_indicators(symbol: str) -> dict: + """Fetch financial indicators from eastmoney datacenter API. + + Uses RPT_LICO_FN_CPD (业绩报表) for the most recent reporting period. + Returns a dict of optional fields for BasicInfo enrichment. + Gracefully returns empty dict on any failure. + """ + try: + r = requests.get( + _DATACENTER_URL, + params={ + "reportName": "RPT_LICO_FN_CPD", + "columns": _FIN_COLUMNS, + "filter": f'(SECURITY_CODE="{symbol}")', + "pageNumber": "1", + "pageSize": "1", + "sortColumns": "NOTICE_DATE", + "sortTypes": "-1", + }, + timeout=10, + ) + r.raise_for_status() + body = r.json() + + if not body.get("success") or not body.get("result"): + return {} + + data = body["result"].get("data") + if not data: + return {} + + latest = data[0] + result = { + "roe": parse_float(latest.get("WEIGHTAVG_ROE")), + "gross_margin": parse_float(latest.get("XSMLL")), + } + + # Compute profit margin from net profit / revenue + revenue = parse_float(latest.get("TOTAL_OPERATE_INCOME")) + net_profit = parse_float(latest.get("PARENT_NETPROFIT")) + if revenue and net_profit: + result["profit_margin"] = round(net_profit / revenue * 100, 2) + + return {k: v for k, v in result.items() if v is not None} + except Exception as e: + logger.debug("Eastmoney financial indicators failed for %s: %s", symbol, e) + return {} diff --git a/tests/test_market/test_sources/__init__.py b/tests/test_market/test_sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_market/test_sources/test_eastmoney.py b/tests/test_market/test_sources/test_eastmoney.py new file mode 100644 index 0000000..e109881 --- /dev/null +++ b/tests/test_market/test_sources/test_eastmoney.py @@ -0,0 +1,89 @@ +"""Tests for eastmoney data source.""" + +from unittest.mock import MagicMock, patch + +from haoinvest.market.sources.eastmoney import get_financial_indicators + + +class TestGetFinancialIndicators: + """Test get_financial_indicators with mocked datacenter API.""" + + def _mock_response(self, json_data, status_code=200): + mock_resp = MagicMock() + mock_resp.json.return_value = json_data + mock_resp.raise_for_status = MagicMock() + mock_resp.status_code = status_code + return mock_resp + + @patch("haoinvest.market.sources.eastmoney.requests.get") + def test_successful_response(self, mock_get): + mock_get.return_value = self._mock_response( + { + "success": True, + "code": 0, + "result": { + "data": [ + { + "SECURITY_CODE": "600519", + "REPORTDATE": "2025-09-30 00:00:00", + "WEIGHTAVG_ROE": 24.64, + "XSMLL": 91.29, + "TOTAL_OPERATE_INCOME": 130903889634.88, + "PARENT_NETPROFIT": 64626746712.18, + } + ], + "count": 100, + }, + } + ) + + result = get_financial_indicators("600519") + + assert result["roe"] == 24.64 + assert result["gross_margin"] == 91.29 + assert result["profit_margin"] == 49.37 # 64626746712.18 / 130903889634.88 * 100 + mock_get.assert_called_once() + + @patch("haoinvest.market.sources.eastmoney.requests.get") + def test_api_failure_returns_empty(self, mock_get): + mock_get.side_effect = Exception("Connection error") + result = get_financial_indicators("600519") + assert result == {} + + @patch("haoinvest.market.sources.eastmoney.requests.get") + def test_empty_result_returns_empty(self, mock_get): + mock_get.return_value = self._mock_response( + {"success": True, "result": {"data": [], "count": 0}} + ) + result = get_financial_indicators("600519") + assert result == {} + + @patch("haoinvest.market.sources.eastmoney.requests.get") + def test_unsuccessful_response(self, mock_get): + mock_get.return_value = self._mock_response( + {"success": False, "result": None, "code": 9501} + ) + result = get_financial_indicators("600519") + assert result == {} + + @patch("haoinvest.market.sources.eastmoney.requests.get") + def test_missing_revenue_skips_profit_margin(self, mock_get): + mock_get.return_value = self._mock_response( + { + "success": True, + "result": { + "data": [ + { + "WEIGHTAVG_ROE": 15.0, + "XSMLL": 60.0, + "TOTAL_OPERATE_INCOME": None, + "PARENT_NETPROFIT": 1000000, + } + ], + }, + } + ) + result = get_financial_indicators("600519") + assert result["roe"] == 15.0 + assert result["gross_margin"] == 60.0 + assert "profit_margin" not in result From 574c07f6c31608faf83881b5a3ee822061290277 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Fri, 3 Apr 2026 12:20:49 +0800 Subject: [PATCH 3/7] refactor(market): rewire provider to use direct API sources Replace all akshare imports with calls to source modules: - current_price: Sina (primary) + Tencent (fallback) - price_history: Tencent - basic_info: eastmoney CompanySurvey + Tencent valuation + eastmoney datacenter financial indicators - sector: Sina No more `import akshare` in the codebase. Updated contract tests to mock source modules instead of akshare. Co-Authored-By: Claude Opus 4.6 --- haoinvest/market/akshare_provider.py | 493 ++------------------ tests/test_market/test_provider_contract.py | 146 +++--- 2 files changed, 114 insertions(+), 525 deletions(-) diff --git a/haoinvest/market/akshare_provider.py b/haoinvest/market/akshare_provider.py index 915da34..43490b9 100644 --- a/haoinvest/market/akshare_provider.py +++ b/haoinvest/market/akshare_provider.py @@ -1,515 +1,86 @@ -"""A-share market data provider using AKShare with direct-API fallbacks. +"""A-share market data provider using direct Sina/Tencent/eastmoney APIs. -AKShare relies on eastmoney push2 endpoints which are frequently unreachable -(IP bans, proxy conflicts, CDN issues). When AKShare fails, we fall back to -Sina/Tencent/eastmoney-emweb APIs that use different, more stable endpoints. +Each data source is isolated in its own module under market/sources/. +The provider orchestrates source selection and fallback logic. """ -import json import logging -import os -import re -from contextlib import contextmanager from datetime import date from typing import Any, Callable -import requests - -from ..models import BasicInfo, MarketType, PriceBar +from ..models import BasicInfo, PriceBar from .provider import MarketProvider +from .sources import eastmoney, sina, tencent +from .sources._common import bypass_proxy logger = logging.getLogger(__name__) -_PROXY_VARS = ( - "http_proxy", - "https_proxy", - "HTTP_PROXY", - "HTTPS_PROXY", - "all_proxy", - "ALL_PROXY", -) - -# Tencent quote field indices (format: v_sh600519="1~name~code~price~...") -_TENCENT_PE_TTM = 39 -_TENCENT_TOTAL_CAP_YI = 45 # total market cap in 亿元 -_TENCENT_PB = 46 - - -@contextmanager -def _bypass_proxy(): - """Temporarily remove proxy env vars so requests to domestic APIs go direct.""" - saved = {} - for var in _PROXY_VARS: - if var in os.environ: - saved[var] = os.environ.pop(var) - try: - yield - finally: - os.environ.update(saved) - - -def _market_prefix(symbol: str) -> str: - """Return 'sh' or 'sz' based on A-share stock code convention.""" - if symbol.startswith(("6", "9")): - return "sh" - return "sz" - - -def _secid(symbol: str) -> str: - """Return eastmoney secid like '1.603618' (1=SH, 0=SZ).""" - return f"1.{symbol}" if symbol.startswith(("6", "9")) else f"0.{symbol}" - def _with_fallback(primary_fn: Callable, fallback_fn: Callable, label: str) -> Any: """Try primary function, fall back on any failure. Args: - primary_fn: Callable that returns the result (typically uses akshare). + primary_fn: Callable that returns the result. fallback_fn: Callable to use when primary fails. - label: Description for logging (e.g. "get_current_price(600519)"). + label: Description for logging. """ - with _bypass_proxy(): + with bypass_proxy(): try: return primary_fn() except ValueError: raise except Exception as e: - logger.debug("AKShare failed for %s: %s, using fallback", label, e) + logger.debug("Primary failed for %s: %s, using fallback", label, e) return fallback_fn() class AKShareProvider(MarketProvider): """Provider for Chinese A-share market data. - Tries AKShare first, falls back to direct Sina/Tencent/eastmoney APIs - when push2 endpoints are unreachable. + Uses Sina, Tencent, and eastmoney web APIs directly. """ def get_current_price(self, symbol: str) -> float: """Get latest price for an A-share stock.""" return _with_fallback( - primary_fn=lambda: self._akshare_current_price(symbol), - fallback_fn=lambda: self._sina_current_price(symbol), + primary_fn=lambda: sina.get_current_price(symbol), + fallback_fn=lambda: tencent.get_current_price(symbol), label=f"get_current_price({symbol})", ) def get_price_history(self, symbol: str, start: date, end: date) -> list[PriceBar]: """Get daily OHLCV bars for an A-share stock.""" - return _with_fallback( - primary_fn=lambda: self._akshare_price_history(symbol, start, end), - fallback_fn=lambda: self._tencent_price_history(symbol, start, end), - label=f"get_price_history({symbol})", - ) + with bypass_proxy(): + return tencent.get_price_history(symbol, start, end) def get_basic_info(self, symbol: str) -> BasicInfo: """Get basic info for an A-share stock.""" - return _with_fallback( - primary_fn=lambda: self._akshare_basic_info(symbol), - fallback_fn=lambda: self._emweb_basic_info(symbol), - label=f"get_basic_info({symbol})", - ) - - # -- AKShare primary methods -- - - @staticmethod - def _akshare_current_price(symbol: str) -> float: - import akshare as ak - - df = ak.stock_zh_a_spot_em() - row = df[df["代码"] == symbol] - if row.empty: - raise ValueError(f"Symbol {symbol} not found in A-share market") - return float(row.iloc[0]["最新价"]) - - @staticmethod - def _akshare_price_history(symbol: str, start: date, end: date) -> list[PriceBar]: - import akshare as ak - - df = ak.stock_zh_a_hist( - symbol=symbol, - period="daily", - start_date=start.strftime("%Y%m%d"), - end_date=end.strftime("%Y%m%d"), - adjust="qfq", - ) - bars = [] - for _, row in df.iterrows(): - bars.append( - PriceBar( - symbol=symbol, - market_type=MarketType.A_SHARE, - trade_date=date.fromisoformat(str(row["日期"])[:10]), - open=float(row["开盘"]), - high=float(row["最高"]), - low=float(row["最低"]), - close=float(row["收盘"]), - volume=float(row["成交量"]), - ) - ) - return bars - - @staticmethod - def _akshare_basic_info(symbol: str) -> BasicInfo: - import akshare as ak - - df = ak.stock_individual_info_em(symbol=symbol) - info = {} - for _, row in df.iterrows(): - info[row["item"]] = row["value"] - - pe_raw = info.get("市盈率(动态)", "") - pb_raw = info.get("市净率", "") - cap_raw = info.get("总市值", "") - - # Fetch additional financial indicators (graceful fallback) - fin = AKShareProvider._akshare_financial_indicators(symbol) + with bypass_proxy(): + info = eastmoney.get_basic_info(symbol) + valuation = tencent.get_valuation(symbol) + fin = eastmoney.get_financial_indicators(symbol) return BasicInfo( - name=info.get("股票简称", ""), - sector=info.get("行业", ""), - currency="CNY", - market_type="a_share", - total_market_cap=_parse_int(cap_raw), - pe_ratio=_parse_float(pe_raw), - pb_ratio=_parse_float(pb_raw), + name=info.name, + sector=info.sector, + currency=info.currency, + market_type=info.market_type, + total_market_cap=valuation.get("total_market_cap"), + pe_ratio=valuation.get("pe_ratio"), + pb_ratio=valuation.get("pb_ratio"), **fin, ) - @staticmethod - def _akshare_financial_indicators(symbol: str) -> dict: - """Fetch financial health metrics from AKShare. - - Returns a dict of optional fields for BasicInfo. - Gracefully returns empty dict on any failure. - """ - try: - import akshare as ak - - with _bypass_proxy(): - df = ak.stock_financial_analysis_indicator(symbol=symbol) - if df.empty: - return {} - # Take the most recent period (first row) - latest = df.iloc[0] - return { - "roe": _parse_float(latest.get("净资产收益率(%)")), - "roa": _parse_float(latest.get("总资产报酬率(%)")), - "debt_to_equity": _parse_float(latest.get("资产负债率(%)")), - "profit_margin": _parse_float(latest.get("销售净利率(%)")), - "gross_margin": _parse_float(latest.get("销售毛利率(%)")), - "operating_margin": _parse_float(latest.get("营业利润率(%)")), - "current_ratio": _parse_float(latest.get("流动比率")), - } - except Exception as e: - logger.debug("Financial indicators failed for %s: %s", symbol, e) - return {} - # -- Sector/Industry methods (A-share specific) -- @staticmethod def get_sector_list() -> list[dict]: - """List all A-share industry boards with performance ranking. - - Returns list of dicts with keys: name, change_pct, total_market_cap, - turnover_rate, rise_count, fall_count. - """ - return _with_fallback( - primary_fn=AKShareProvider._akshare_sector_list, - fallback_fn=AKShareProvider._sina_sector_list, - label="get_sector_list()", - ) + """List all A-share industry boards with performance ranking.""" + with bypass_proxy(): + return sina.get_sector_list() @staticmethod def get_sector_constituents(sector_name: str) -> list[dict]: - """Get stocks in a specific industry board. - - Returns list of dicts with keys: code, name, price, change_pct, - pe_ratio, pb_ratio, total_market_cap. - """ - return _with_fallback( - primary_fn=lambda: AKShareProvider._akshare_sector_constituents( - sector_name - ), - fallback_fn=lambda: AKShareProvider._sina_sector_constituents(sector_name), - label=f"get_sector_constituents({sector_name})", - ) - - @staticmethod - def _akshare_sector_list() -> list[dict]: - import akshare as ak - - df = ak.stock_board_industry_spot_em() - rows = [] - for _, row in df.iterrows(): - rows.append( - { - "name": row.get("板块名称", ""), - "change_pct": _parse_float(row.get("涨跌幅")), - "total_market_cap": _parse_int(row.get("总市值")), - "turnover_rate": _parse_float(row.get("换手率")), - "rise_count": _parse_int(row.get("上涨家数")), - "fall_count": _parse_int(row.get("下跌家数")), - } - ) - return rows - - @staticmethod - def _akshare_sector_constituents(sector_name: str) -> list[dict]: - import akshare as ak - - df = ak.stock_board_industry_cons_em(symbol=sector_name) - rows = [] - for _, row in df.iterrows(): - rows.append( - { - "code": row.get("代码", ""), - "name": row.get("名称", ""), - "price": _parse_float(row.get("最新价")), - "change_pct": _parse_float(row.get("涨跌幅")), - "pe_ratio": _parse_float(row.get("市盈率-动态")), - "pb_ratio": _parse_float(row.get("市净率")), - "total_market_cap": _parse_int(row.get("总市值")), - } - ) - return rows - - # -- Fallback methods -- - - @staticmethod - def _sina_current_price(symbol: str) -> float: - """Get current price from Sina Finance API.""" - prefix = _market_prefix(symbol) - url = f"https://hq.sinajs.cn/list={prefix}{symbol}" - r = requests.get( - url, - headers={"Referer": "https://finance.sina.com.cn"}, - timeout=10, - ) - r.raise_for_status() - # Format: var hq_str_sh603618="name,open,prev_close,current,high,low,..."; - text = r.text.strip() - if '=""' in text or not text: - raise ValueError(f"Symbol {symbol} not found in A-share market") - fields = text.split('"')[1].split(",") - if len(fields) < 4: - raise RuntimeError(f"Unexpected Sina response format for {symbol}") - price = float(fields[3]) # current price - if price <= 0: - raise RuntimeError(f"Invalid price for {symbol} from Sina API") - return price - - @staticmethod - def _tencent_price_history(symbol: str, start: date, end: date) -> list[PriceBar]: - """Get forward-adjusted daily klines from Tencent Finance API.""" - prefix = _market_prefix(symbol) - start_str = start.strftime("%Y-%m-%d") - end_str = end.strftime("%Y-%m-%d") - days = (end - start).days + 50 - url = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get" - r = requests.get( - url, - params={"param": f"{prefix}{symbol},day,{start_str},{end_str},{days},qfq"}, - timeout=15, - ) - r.raise_for_status() - data = r.json() - stock_data = data.get("data", {}).get(f"{prefix}{symbol}", {}) - klines = stock_data.get("qfqday") or stock_data.get("day", []) - - bars = [] - for k in klines: - # Tencent kline format: [date, open, close, high, low, volume] - if len(k) < 6: - continue - bar_date = date.fromisoformat(k[0]) - if bar_date < start or bar_date > end: - continue - bars.append( - PriceBar( - symbol=symbol, - market_type=MarketType.A_SHARE, - trade_date=bar_date, - open=float(k[1]), - high=float(k[3]), - low=float(k[4]), - close=float(k[2]), - volume=float(k[5]), - ) - ) - return bars - - def _emweb_basic_info(self, symbol: str) -> BasicInfo: - """Get basic info from eastmoney emweb + Tencent valuation APIs.""" - code = f"{'SH' if symbol.startswith(('6', '9')) else 'SZ'}{symbol}" - url = "https://emweb.securities.eastmoney.com/pc_hsf10/CompanySurvey/CompanySurveyAjax" - r = requests.get(url, params={"code": code}, timeout=10) - r.raise_for_status() - data = r.json() - jbzl = data.get("jbzl", {}) - - valuation = self._tencent_valuation(symbol) - - return BasicInfo( - name=jbzl.get("agjc", ""), - sector=jbzl.get("sshy", ""), - currency="CNY", - market_type="a_share", - total_market_cap=valuation.get("total_market_cap"), - pe_ratio=valuation.get("pe_ratio"), - pb_ratio=valuation.get("pb_ratio"), - ) - - @staticmethod - def _sina_sector_list() -> list[dict]: - """Fallback: sector list from Sina Finance API. - - Sina fields per sector: code, name, stock_count, avg_price, change, - change_pct, volume, amount, leading_code, leading_turnover, - leading_price, leading_change, leading_name - """ - data = _sina_sector_data() - rows = [] - for fields in data.values(): - if len(fields) < 6: - continue - rows.append( - { - "name": fields[1], - "change_pct": _parse_float(fields[5]), - "total_market_cap": None, - "turnover_rate": None, - "rise_count": None, - "fall_count": None, - } - ) - # Sort by change_pct descending (match eastmoney default sort) - rows.sort(key=lambda r: r["change_pct"] or 0, reverse=True) - return rows - - @staticmethod - def _sina_sector_constituents(sector_name: str) -> list[dict]: - """Fallback: sector constituents from Sina Finance API. - - Looks up the Sina node code for the given Chinese sector name, - then fetches constituent stocks from the Sina HQ API. - """ - # Find the Sina node code matching the sector name - data = _sina_sector_data() - node_code = None - for code, fields in data.items(): - if len(fields) >= 2 and fields[1] == sector_name: - node_code = code - break - if node_code is None: - raise ValueError( - f"Sector '{sector_name}' not found. " - "Use 'market sector-list' to see available sectors." - ) - - r = requests.get( - "https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php" - "/Market_Center.getHQNodeData", - params={ - "page": 1, - "num": 200, - "sort": "changepercent", - "asc": 0, - "node": node_code, - }, - headers={"Referer": "https://finance.sina.com.cn"}, - timeout=10, - ) - r.encoding = "gbk" - stocks = r.json() - - rows = [] - for s in stocks: - rows.append( - { - "code": s.get("code", ""), - "name": s.get("name", ""), - "price": _parse_float(s.get("trade")), - "change_pct": _parse_float(s.get("changepercent")), - "pe_ratio": _parse_float(s.get("per")), - "pb_ratio": _parse_float(s.get("pb")), - "total_market_cap": ( - int(s["mktcap"] * 10000) if s.get("mktcap") else None - ), - } - ) - return rows - - @staticmethod - def _tencent_valuation(symbol: str) -> dict: - """Fetch PE/PB/market cap from Tencent quote API. - - Returns dict with keys: pe_ratio, pb_ratio, total_market_cap. - Values are typed (float/int) or None if unavailable. - """ - result: dict[str, float | int | None] = { - "pe_ratio": None, - "pb_ratio": None, - "total_market_cap": None, - } - try: - prefix = _market_prefix(symbol) - tr = requests.get( - f"https://qt.gtimg.cn/q={prefix}{symbol}", - timeout=10, - ) - tr.raise_for_status() - tfields = tr.text.strip().split("~") - if len(tfields) <= _TENCENT_PB: - logger.debug( - "Tencent response too short for %s: %d fields", symbol, len(tfields) - ) - return result - - result["pe_ratio"] = _parse_float(tfields[_TENCENT_PE_TTM]) - result["pb_ratio"] = _parse_float(tfields[_TENCENT_PB]) - - cap_yi = tfields[_TENCENT_TOTAL_CAP_YI] - if cap_yi: - cap_val = _parse_float(cap_yi) - if cap_val is not None: - result["total_market_cap"] = int(cap_val * 1_0000_0000) - except Exception as e: - logger.debug("Tencent valuation failed for %s: %s", symbol, e) - - return result - - -def _sina_sector_data() -> dict[str, list[str]]: - """Fetch Sina industry board data. Returns {node_code: [fields...]}.""" - r = requests.get( - "https://vip.stock.finance.sina.com.cn/q/view/newSinaHy.php", - headers={"Referer": "https://finance.sina.com.cn"}, - timeout=10, - ) - r.encoding = "gbk" - m = re.search(r"=\s*(\{.*\})", r.text) - if not m: - raise RuntimeError("Failed to parse Sina sector response") - data = json.loads(m.group(1)) - return {k: v.split(",") for k, v in data.items()} - - -def _parse_float(value: Any) -> float | None: - """Parse a value to float, returning None for empty/invalid values.""" - if value is None or value == "": - return None - try: - result = float(value) - return result if result != 0 else None - except (ValueError, TypeError): - return None - - -def _parse_int(value: Any) -> int | None: - """Parse a value to int, returning None for empty/invalid values.""" - if value is None or value == "": - return None - try: - return int(float(value)) - except (ValueError, TypeError): - return None + """Get stocks in a specific industry board.""" + with bypass_proxy(): + return sina.get_sector_constituents(sector_name) diff --git a/tests/test_market/test_provider_contract.py b/tests/test_market/test_provider_contract.py index 4255463..7f8eb57 100644 --- a/tests/test_market/test_provider_contract.py +++ b/tests/test_market/test_provider_contract.py @@ -3,7 +3,6 @@ from datetime import date from unittest.mock import patch, MagicMock -import pandas as pd import pytest from haoinvest.market.akshare_provider import AKShareProvider @@ -12,73 +11,92 @@ _normalize_symbol, _to_coingecko_id, ) +from haoinvest.models import BasicInfo, MarketType, PriceBar class TestAKShareProviderContract: - """Test AKShareProvider with mocked AKShare responses.""" - - def test_get_current_price(self): - mock_df = pd.DataFrame( - { - "代码": ["600519", "000001"], - "最新价": [1680.0, 12.5], - } - ) - mock_ak = MagicMock() - mock_ak.stock_zh_a_spot_em.return_value = mock_df - with patch.dict("sys.modules", {"akshare": mock_ak}): - provider = AKShareProvider() - price = provider.get_current_price("600519") - assert price == 1680.0 - - def test_get_current_price_not_found(self): - mock_df = pd.DataFrame({"代码": ["000001"], "最新价": [12.5]}) - mock_ak = MagicMock() - mock_ak.stock_zh_a_spot_em.return_value = mock_df - with patch.dict("sys.modules", {"akshare": mock_ak}): - provider = AKShareProvider() - with pytest.raises(ValueError, match="not found"): - provider.get_current_price("999999") - - def test_get_price_history(self): - mock_df = pd.DataFrame( - { - "日期": ["2026-03-25", "2026-03-26", "2026-03-27"], - "开盘": [1670.0, 1675.0, 1680.0], - "最高": [1685.0, 1690.0, 1695.0], - "最低": [1665.0, 1670.0, 1675.0], - "收盘": [1680.0, 1685.0, 1690.0], - "成交量": [10000, 12000, 11000], - } - ) - mock_ak = MagicMock() - mock_ak.stock_zh_a_hist.return_value = mock_df - with patch.dict("sys.modules", {"akshare": mock_ak}): - provider = AKShareProvider() - bars = provider.get_price_history( - "600519", date(2026, 3, 25), date(2026, 3, 27) - ) - assert len(bars) == 3 - assert bars[0].close == 1680.0 - assert bars[0].trade_date == date(2026, 3, 25) - - def test_get_basic_info(self): - mock_df = pd.DataFrame( - { - "item": ["股票简称", "行业", "总市值", "市盈率(动态)", "市净率"], - "value": ["贵州茅台", "白酒", "2100000000000", "30.5", "10.2"], - } + """Test AKShareProvider with mocked source modules.""" + + @patch("haoinvest.market.akshare_provider.sina.get_current_price") + def test_get_current_price(self, mock_sina_price): + mock_sina_price.return_value = 1680.0 + provider = AKShareProvider() + price = provider.get_current_price("600519") + assert price == 1680.0 + mock_sina_price.assert_called_once_with("600519") + + @patch("haoinvest.market.akshare_provider.tencent.get_current_price") + @patch("haoinvest.market.akshare_provider.sina.get_current_price") + def test_get_current_price_fallback_to_tencent(self, mock_sina, mock_tencent): + mock_sina.side_effect = RuntimeError("Sina unavailable") + mock_tencent.return_value = 1679.0 + provider = AKShareProvider() + price = provider.get_current_price("600519") + assert price == 1679.0 + + @patch("haoinvest.market.akshare_provider.sina.get_current_price") + def test_get_current_price_not_found(self, mock_sina_price): + mock_sina_price.side_effect = ValueError("Symbol 999999 not found in A-share market") + provider = AKShareProvider() + with pytest.raises(ValueError, match="not found"): + provider.get_current_price("999999") + + @patch("haoinvest.market.akshare_provider.tencent.get_price_history") + def test_get_price_history(self, mock_tencent_history): + mock_tencent_history.return_value = [ + PriceBar( + symbol="600519", + market_type=MarketType.A_SHARE, + trade_date=date(2026, 3, 25), + open=1670.0, + high=1685.0, + low=1665.0, + close=1680.0, + volume=10000, + ), + PriceBar( + symbol="600519", + market_type=MarketType.A_SHARE, + trade_date=date(2026, 3, 26), + open=1675.0, + high=1690.0, + low=1670.0, + close=1685.0, + volume=12000, + ), + ] + provider = AKShareProvider() + bars = provider.get_price_history("600519", date(2026, 3, 25), date(2026, 3, 27)) + assert len(bars) == 2 + assert bars[0].close == 1680.0 + assert bars[0].trade_date == date(2026, 3, 25) + + @patch("haoinvest.market.akshare_provider.eastmoney.get_financial_indicators") + @patch("haoinvest.market.akshare_provider.tencent.get_valuation") + @patch("haoinvest.market.akshare_provider.eastmoney.get_basic_info") + def test_get_basic_info(self, mock_em_info, mock_tencent_val, mock_em_fin): + mock_em_info.return_value = BasicInfo( + name="贵州茅台", + sector="白酒", + currency="CNY", + market_type="a_share", ) - mock_ak = MagicMock() - mock_ak.stock_individual_info_em.return_value = mock_df - with patch.dict("sys.modules", {"akshare": mock_ak}): - provider = AKShareProvider() - info = provider.get_basic_info("600519") - assert info.name == "贵州茅台" - assert info.currency == "CNY" - assert info.pe_ratio == 30.5 - assert info.pb_ratio == 10.2 - assert info.total_market_cap == 2100000000000 + mock_tencent_val.return_value = { + "pe_ratio": 30.5, + "pb_ratio": 10.2, + "total_market_cap": 2100000000000, + } + mock_em_fin.return_value = {"roe": 24.64, "gross_margin": 91.29} + + provider = AKShareProvider() + info = provider.get_basic_info("600519") + assert info.name == "贵州茅台" + assert info.currency == "CNY" + assert info.pe_ratio == 30.5 + assert info.pb_ratio == 10.2 + assert info.total_market_cap == 2100000000000 + assert info.roe == 24.64 + assert info.gross_margin == 91.29 class TestCryptoProviderHelpers: From 1c1abe9baa3ba57ed274dfda2dcad915dbc802b9 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Fri, 3 Apr 2026 12:22:51 +0800 Subject: [PATCH 4/7] refactor(market): rename AKShareProvider to AShareProvider Rename akshare_provider.py -> ashare_provider.py and class AKShareProvider -> AShareProvider. Update all imports and mock paths across CLI, analysis, and test files. Co-Authored-By: Claude Opus 4.6 --- haoinvest/analysis/peer.py | 6 ++-- haoinvest/cli/market.py | 8 ++--- haoinvest/market/__init__.py | 4 +-- ...akshare_provider.py => ashare_provider.py} | 2 +- tests/test_analysis_peer.py | 8 ++--- tests/test_cli/test_sector.py | 12 +++---- ...egration.py => test_ashare_integration.py} | 12 +++---- tests/test_market/test_provider_contract.py | 32 +++++++++---------- 8 files changed, 42 insertions(+), 42 deletions(-) rename haoinvest/market/{akshare_provider.py => ashare_provider.py} (98%) rename tests/test_market/{test_akshare_integration.py => test_ashare_integration.py} (69%) diff --git a/haoinvest/analysis/peer.py b/haoinvest/analysis/peer.py index 0c5693a..9d201ed 100644 --- a/haoinvest/analysis/peer.py +++ b/haoinvest/analysis/peer.py @@ -15,7 +15,7 @@ def find_peers( ) -> list[dict]: """Find same-sector peers and compare fundamental metrics. - For A-shares: uses AKShareProvider.get_sector_constituents() to find + For A-shares: uses AShareProvider.get_sector_constituents() to find stocks in the same industry board, sorted by market cap. Returns list of dicts suitable for TSV output, with the target stock @@ -26,7 +26,7 @@ def find_peers( {"message": f"Peer comparison not yet supported for {market_type.value}"} ] - from ..market.akshare_provider import AKShareProvider + from ..market.ashare_provider import AShareProvider # Get target stock info to determine sector target = analyze_stock(symbol, market_type) @@ -36,7 +36,7 @@ def find_peers( # Get sector constituents try: - constituents = AKShareProvider.get_sector_constituents(sector) + constituents = AShareProvider.get_sector_constituents(sector) except Exception as e: logger.debug("Failed to get sector constituents for %s: %s", sector, e) return [{"message": f"Failed to get sector data for {sector}: {e}"}] diff --git a/haoinvest/cli/market.py b/haoinvest/cli/market.py index 17994b0..090b180 100644 --- a/haoinvest/cli/market.py +++ b/haoinvest/cli/market.py @@ -139,10 +139,10 @@ def sector_list( use_json: bool = typer.Option(False, "--json", help="Output as JSON"), ) -> None: """行业板块排行 — list all A-share industry sectors with performance.""" - from ..market.akshare_provider import AKShareProvider + from ..market.ashare_provider import AShareProvider try: - rows = AKShareProvider.get_sector_list() + rows = AShareProvider.get_sector_list() except Exception as e: error_output(str(e)) raise typer.Exit(1) @@ -169,10 +169,10 @@ def sector( use_json: bool = typer.Option(False, "--json", help="Output as JSON"), ) -> None: """行业板块成分股 — show constituents of a specific A-share sector.""" - from ..market.akshare_provider import AKShareProvider + from ..market.ashare_provider import AShareProvider try: - rows = AKShareProvider.get_sector_constituents(name) + rows = AShareProvider.get_sector_constituents(name) except Exception as e: error_output(str(e)) raise typer.Exit(1) diff --git a/haoinvest/market/__init__.py b/haoinvest/market/__init__.py index 65ab786..8d83857 100644 --- a/haoinvest/market/__init__.py +++ b/haoinvest/market/__init__.py @@ -22,9 +22,9 @@ def get_provider(market_type: MarketType) -> MarketProvider: def _auto_register() -> None: """Register built-in providers.""" try: - from .akshare_provider import AKShareProvider + from .ashare_provider import AShareProvider - register_provider(MarketType.A_SHARE, AKShareProvider) + register_provider(MarketType.A_SHARE, AShareProvider) except ImportError: pass diff --git a/haoinvest/market/akshare_provider.py b/haoinvest/market/ashare_provider.py similarity index 98% rename from haoinvest/market/akshare_provider.py rename to haoinvest/market/ashare_provider.py index 43490b9..b2f369b 100644 --- a/haoinvest/market/akshare_provider.py +++ b/haoinvest/market/ashare_provider.py @@ -34,7 +34,7 @@ def _with_fallback(primary_fn: Callable, fallback_fn: Callable, label: str) -> A return fallback_fn() -class AKShareProvider(MarketProvider): +class AShareProvider(MarketProvider): """Provider for Chinese A-share market data. Uses Sina, Tencent, and eastmoney web APIs directly. diff --git a/tests/test_analysis_peer.py b/tests/test_analysis_peer.py index 1204597..74cb0e3 100644 --- a/tests/test_analysis_peer.py +++ b/tests/test_analysis_peer.py @@ -56,7 +56,7 @@ def test_a_share_peers(self): "haoinvest.analysis.peer.analyze_stock", return_value=MOCK_FUNDAMENTAL ), patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_constituents", return_value=MOCK_CONSTITUENTS, ), ): @@ -85,7 +85,7 @@ def test_top_n_limit(self): "haoinvest.analysis.peer.analyze_stock", return_value=MOCK_FUNDAMENTAL ), patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_constituents", return_value=MOCK_CONSTITUENTS, ), ): @@ -100,7 +100,7 @@ def test_peer_command(self): "haoinvest.analysis.peer.analyze_stock", return_value=MOCK_FUNDAMENTAL ), patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_constituents", return_value=MOCK_CONSTITUENTS, ), ): @@ -115,7 +115,7 @@ def test_peer_json(self): "haoinvest.analysis.peer.analyze_stock", return_value=MOCK_FUNDAMENTAL ), patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_constituents", return_value=MOCK_CONSTITUENTS, ), ): diff --git a/tests/test_cli/test_sector.py b/tests/test_cli/test_sector.py index e334e8d..7f9f6de 100644 --- a/tests/test_cli/test_sector.py +++ b/tests/test_cli/test_sector.py @@ -52,7 +52,7 @@ class TestSectorList: def test_sector_list_tsv(self): with patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_list", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_list", return_value=MOCK_SECTOR_LIST, ): result = runner.invoke(app, ["market", "sector-list"]) @@ -62,7 +62,7 @@ def test_sector_list_tsv(self): def test_sector_list_json(self): with patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_list", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_list", return_value=MOCK_SECTOR_LIST, ): result = runner.invoke(app, ["market", "sector-list", "--json"]) @@ -71,7 +71,7 @@ def test_sector_list_json(self): def test_sector_list_error(self): with patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_list", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_list", side_effect=RuntimeError("API failed"), ): result = runner.invoke(app, ["market", "sector-list"]) @@ -81,7 +81,7 @@ def test_sector_list_error(self): class TestSector: def test_sector_constituents_tsv(self): with patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_constituents", return_value=MOCK_SECTOR_CONSTITUENTS, ): result = runner.invoke(app, ["market", "sector", "白酒"]) @@ -92,7 +92,7 @@ def test_sector_constituents_tsv(self): def test_sector_constituents_json(self): with patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_constituents", return_value=MOCK_SECTOR_CONSTITUENTS, ): result = runner.invoke(app, ["market", "sector", "白酒", "--json"]) @@ -101,7 +101,7 @@ def test_sector_constituents_json(self): def test_sector_not_found(self): with patch( - "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + "haoinvest.market.ashare_provider.AShareProvider.get_sector_constituents", side_effect=RuntimeError("Sector not found"), ): result = runner.invoke(app, ["market", "sector", "不存在的板块"]) diff --git a/tests/test_market/test_akshare_integration.py b/tests/test_market/test_ashare_integration.py similarity index 69% rename from tests/test_market/test_akshare_integration.py rename to tests/test_market/test_ashare_integration.py index 8424822..a9942c9 100644 --- a/tests/test_market/test_akshare_integration.py +++ b/tests/test_market/test_ashare_integration.py @@ -1,4 +1,4 @@ -"""Integration tests for AKShare provider — calls real APIs. +"""Integration tests for A-share provider — calls real APIs. Run with: uv run pytest -m integration """ @@ -7,24 +7,24 @@ import pytest -from haoinvest.market.akshare_provider import AKShareProvider +from haoinvest.market.ashare_provider import AShareProvider @pytest.mark.integration -class TestAKShareIntegration: +class TestAShareIntegration: def test_get_current_price_moutai(self): - provider = AKShareProvider() + provider = AShareProvider() price = provider.get_current_price("600519") assert price > 0 def test_get_price_history_moutai(self): - provider = AKShareProvider() + provider = AShareProvider() bars = provider.get_price_history("600519", date(2026, 1, 2), date(2026, 1, 10)) assert len(bars) > 0 assert all("close" in b for b in bars) def test_get_basic_info_moutai(self): - provider = AKShareProvider() + provider = AShareProvider() info = provider.get_basic_info("600519") assert info["name"] != "" assert info["currency"] == "CNY" diff --git a/tests/test_market/test_provider_contract.py b/tests/test_market/test_provider_contract.py index 7f8eb57..9c95fa7 100644 --- a/tests/test_market/test_provider_contract.py +++ b/tests/test_market/test_provider_contract.py @@ -5,7 +5,7 @@ import pytest -from haoinvest.market.akshare_provider import AKShareProvider +from haoinvest.market.ashare_provider import AShareProvider from haoinvest.market.crypto_provider import ( CryptoProvider, _normalize_symbol, @@ -14,34 +14,34 @@ from haoinvest.models import BasicInfo, MarketType, PriceBar -class TestAKShareProviderContract: - """Test AKShareProvider with mocked source modules.""" +class TestAShareProviderContract: + """Test AShareProvider with mocked source modules.""" - @patch("haoinvest.market.akshare_provider.sina.get_current_price") + @patch("haoinvest.market.ashare_provider.sina.get_current_price") def test_get_current_price(self, mock_sina_price): mock_sina_price.return_value = 1680.0 - provider = AKShareProvider() + provider = AShareProvider() price = provider.get_current_price("600519") assert price == 1680.0 mock_sina_price.assert_called_once_with("600519") - @patch("haoinvest.market.akshare_provider.tencent.get_current_price") - @patch("haoinvest.market.akshare_provider.sina.get_current_price") + @patch("haoinvest.market.ashare_provider.tencent.get_current_price") + @patch("haoinvest.market.ashare_provider.sina.get_current_price") def test_get_current_price_fallback_to_tencent(self, mock_sina, mock_tencent): mock_sina.side_effect = RuntimeError("Sina unavailable") mock_tencent.return_value = 1679.0 - provider = AKShareProvider() + provider = AShareProvider() price = provider.get_current_price("600519") assert price == 1679.0 - @patch("haoinvest.market.akshare_provider.sina.get_current_price") + @patch("haoinvest.market.ashare_provider.sina.get_current_price") def test_get_current_price_not_found(self, mock_sina_price): mock_sina_price.side_effect = ValueError("Symbol 999999 not found in A-share market") - provider = AKShareProvider() + provider = AShareProvider() with pytest.raises(ValueError, match="not found"): provider.get_current_price("999999") - @patch("haoinvest.market.akshare_provider.tencent.get_price_history") + @patch("haoinvest.market.ashare_provider.tencent.get_price_history") def test_get_price_history(self, mock_tencent_history): mock_tencent_history.return_value = [ PriceBar( @@ -65,15 +65,15 @@ def test_get_price_history(self, mock_tencent_history): volume=12000, ), ] - provider = AKShareProvider() + provider = AShareProvider() bars = provider.get_price_history("600519", date(2026, 3, 25), date(2026, 3, 27)) assert len(bars) == 2 assert bars[0].close == 1680.0 assert bars[0].trade_date == date(2026, 3, 25) - @patch("haoinvest.market.akshare_provider.eastmoney.get_financial_indicators") - @patch("haoinvest.market.akshare_provider.tencent.get_valuation") - @patch("haoinvest.market.akshare_provider.eastmoney.get_basic_info") + @patch("haoinvest.market.ashare_provider.eastmoney.get_financial_indicators") + @patch("haoinvest.market.ashare_provider.tencent.get_valuation") + @patch("haoinvest.market.ashare_provider.eastmoney.get_basic_info") def test_get_basic_info(self, mock_em_info, mock_tencent_val, mock_em_fin): mock_em_info.return_value = BasicInfo( name="贵州茅台", @@ -88,7 +88,7 @@ def test_get_basic_info(self, mock_em_info, mock_tencent_val, mock_em_fin): } mock_em_fin.return_value = {"roe": 24.64, "gross_margin": 91.29} - provider = AKShareProvider() + provider = AShareProvider() info = provider.get_basic_info("600519") assert info.name == "贵州茅台" assert info.currency == "CNY" From 7c1ff1e43583f8df231507c23c3f168b17a1499b Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Fri, 3 Apr 2026 12:26:00 +0800 Subject: [PATCH 5/7] chore: remove akshare dependency Remove akshare from pyproject.toml and update lockfile. Update all documentation (CLAUDE.md, README.md) and config to reflect the switch to direct Sina/Tencent/eastmoney APIs. Rename AKSHARE_TIMEOUT config to API_TIMEOUT. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 +- README.md | 6 +- haoinvest/config.py | 5 +- pyproject.toml | 1 - uv.lock | 240 -------------------------------------------- 5 files changed, 7 insertions(+), 249 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 84456e5..2d3448b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Python library + Claude Code skills for tracking portfolios, analyzing stocks, a - **Language**: Python 3.11+ - **Package Manager**: uv (NOT pip) - **Database**: SQLite (~/.haoinvest/haoinvest.db) -- **Data Sources**: AKShare (A-shares), yfinance (US/HK), Crypto.com MCP (crypto) +- **Data Sources**: Sina/Tencent/eastmoney direct APIs (A-shares), yfinance (US/HK), Crypto.com MCP (crypto) - **Testing**: pytest ## Quick Commands @@ -55,7 +55,7 @@ haoinvest/ │ ├── report.py # Full stock report with buy-readiness checklist │ ├── signals.py # Aggregate buy/sell signals │ └── volume.py # Volume analysis -├── market/ # Provider registry — akshare, yfinance, crypto MCP +├── market/ # Provider registry — A-share (Sina/Tencent/eastmoney), yfinance, crypto MCP └── strategy/ # Optimizer adapter + rebalance logic ``` diff --git a/README.md b/README.md index 0b6c794..611809a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Built for a beginner investor in China covering A-shares, US stocks, HK stocks, ## Features - **Portfolio Management** — Record trades, track positions, calculate time-weighted returns (TWR) -- **Market Data** — Real-time quotes from AKShare (A-shares), Yahoo Finance (US/HK), Crypto.com (crypto) +- **Market Data** — Real-time quotes from Sina/Tencent/eastmoney APIs (A-shares), Yahoo Finance (US/HK), Crypto.com (crypto) - **Fundamental Analysis** — PE/PB/ROE valuation assessment with financial health scoring; batch support for multi-symbol comparison - **Peer Comparison** — Find and compare same-sector stocks by valuation and performance - **Sector Browsing** — Browse A-share industry sectors and their constituent stocks @@ -108,7 +108,7 @@ Use the unified `/haoinvest` skill in Claude Code for natural language interacti | Environment Variable | Default | Description | |---------------------|---------|-------------| | `HAOINVEST_DATA_DIR` | `~/.haoinvest/` | Data directory path | -| `HAOINVEST_AKSHARE_TIMEOUT` | `30` | AKShare API timeout (seconds) | +| `HAOINVEST_API_TIMEOUT` | `30` | A-share API timeout (seconds) | | `HAOINVEST_CACHE_TTL` | `14400` | Analysis cache TTL (seconds) | | `HAOINVEST_PRICE_CACHE_TTL` | `3600` | Price cache TTL (seconds) | @@ -134,7 +134,7 @@ pytest tests/test_fx.py # Single module │ pandas-ta · QuantStats · PyPfOpt │ ├─────────────────────────────────────┤ │ Data (market/, portfolio/, db.py) │ ← Providers, positions, SQLite -│ AKShare · yfinance · Crypto.com │ +│ Sina/Tencent/EM · yfinance · Crypto│ └─────────────────────────────────────┘ ``` diff --git a/haoinvest/config.py b/haoinvest/config.py index d9178ea..6a32cd2 100644 --- a/haoinvest/config.py +++ b/haoinvest/config.py @@ -16,9 +16,8 @@ def get_db_path() -> Path: return get_data_dir() / "haoinvest.db" -# AKShare has unstable APIs — pin version in pyproject.toml -# and handle errors gracefully in providers. -AKSHARE_TIMEOUT = int(os.environ.get("HAOINVEST_AKSHARE_TIMEOUT", "30")) +# API timeout for A-share data sources (Sina/Tencent/eastmoney) +API_TIMEOUT = int(os.environ.get("HAOINVEST_API_TIMEOUT", "30")) # Cache expiry in seconds ANALYSIS_CACHE_TTL = int( diff --git a/pyproject.toml b/pyproject.toml index 4aac8a4..397b7db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,6 @@ version = "0.1.0" description = "Personal investment portfolio management system" requires-python = ">=3.11" dependencies = [ - "akshare==1.18.49", "pydantic==2.12.5", "httpx==0.28.1", "yfinance==1.2.0", diff --git a/uv.lock b/uv.lock index d233feb..7522183 100644 --- a/uv.lock +++ b/uv.lock @@ -13,42 +13,6 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] -[[package]] -name = "akracer" -version = "0.0.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/c6/f38feed5b961d73e1b4cb049fdb45338356e0f5b828b230c00d0e51f3137/akracer-0.0.14.tar.gz", hash = "sha256:e084c14bf6d9a02d5da375e3af1cba3d46f103aa1cf3a2010593b3e95bf1c29a", size = 10047643, upload-time = "2025-09-10T13:47:34.811Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/cb/1041355b14cd4b76ac082e8c676858f6eddb78f0ba37c59284adf36e5103/akracer-0.0.14-py3-none-any.whl", hash = "sha256:629eaccd0e1d18366804b797eb2692ed47bed0028f55b5a5af3cc277d521df04", size = 10076442, upload-time = "2025-09-10T13:47:29.061Z" }, -] - -[[package]] -name = "akshare" -version = "1.18.49" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "akracer", marker = "sys_platform == 'linux'" }, - { name = "beautifulsoup4" }, - { name = "curl-cffi" }, - { name = "decorator" }, - { name = "html5lib" }, - { name = "jsonpath" }, - { name = "lxml" }, - { name = "mini-racer", marker = "sys_platform != 'linux'" }, - { name = "openpyxl" }, - { name = "pandas" }, - { name = "py-mini-racer", marker = "sys_platform == 'linux'" }, - { name = "requests" }, - { name = "tabulate" }, - { name = "tqdm" }, - { name = "urllib3" }, - { name = "xlrd" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9a/3e/91a16671cceb6b356f1b3da3efb0ed55b6a71ef6401b4edc178c47a4886f/akshare-1.18.49.tar.gz", hash = "sha256:7d3ec53d06cb022f5f4ea5e6ecd4c9a12bbf595fe03045e65b7d98dcd1173db1", size = 864896, upload-time = "2026-03-28T14:26:22Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/d4/cbfca96d298e59b06e46f15bf65ddb10ab55dd76867b13f0c890c6a7a6dc/akshare-1.18.49-py3-none-any.whl", hash = "sha256:0e8b503fddbcdb906493ab0f8b6e564ea4ec5b438ebc5c9c1619cf3d9afb02c7", size = 1087780, upload-time = "2026-03-28T14:26:20.252Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -452,24 +416,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] -[[package]] -name = "decorator" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, -] - -[[package]] -name = "et-xmlfile" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, -] - [[package]] name = "fonttools" version = "4.62.1" @@ -542,7 +488,6 @@ name = "haoinvest" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "akshare" }, { name = "httpx" }, { name = "pandas" }, { name = "pandas-ta-classic" }, @@ -562,7 +507,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "akshare", specifier = "==1.18.49" }, { name = "httpx", specifier = "==0.28.1" }, { name = "pandas", specifier = "==2.2.3" }, { name = "pandas-ta-classic", specifier = "==0.4.47" }, @@ -631,19 +575,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/6c/7abf63f02302d64934d9decd04939334db1938917f552059570ea27b41b3/highspy-1.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:242d00f46b09c9d6f077881739d7487030a9ed2c56bcd28f3e8d5942da407df4", size = 2313528, upload-time = "2026-02-11T16:39:15.765Z" }, ] -[[package]] -name = "html5lib" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -711,12 +642,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] -[[package]] -name = "jsonpath" -version = "0.82.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/a1/693351acd0a9edca4de9153372a65e75398898ea7f8a5c722ab00f464929/jsonpath-0.82.2.tar.gz", hash = "sha256:d87ef2bcbcded68ee96bc34c1809b69457ecec9b0c4dd471658a12bd391002d1", size = 10353, upload-time = "2023-08-24T18:57:55.459Z" } - [[package]] name = "kiwisolver" version = "1.5.0" @@ -823,108 +748,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1084,18 +907,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mini-racer" -version = "0.14.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/b0/5b200bdf093433f1933f783fbc908c23349a1a575c28c81aff75b609c7c1/mini_racer-0.14.1.tar.gz", hash = "sha256:0df25889b7c4e753520324a1687d85e41f9f64984efa81963339ed400f004d49", size = 41771, upload-time = "2026-02-01T05:53:27.408Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/2c/5857ee4e1714db8956aa878dc90255557458c124f93163704e7de948be03/mini_racer-0.14.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:a401ecdf5f73d4714b76dc3a4c9b5780059cf4c59a5b64cff7497dc6c219d5a0", size = 19988474, upload-time = "2026-02-01T05:53:03.998Z" }, - { url = "https://files.pythonhosted.org/packages/09/bf/ecaad0c208b9d8bd8f2141f7fa5e520b66915945a2ed56c520524df75fcb/mini_racer-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56cc6965a1665a50d8d613bc43aa83b4cec6b1f09acc69dc4c2845dacb201463", size = 18511166, upload-time = "2026-02-01T05:53:07.059Z" }, - { url = "https://files.pythonhosted.org/packages/78/60/e0708ea8533e928f10f985be35af4c02dd2b61f4a7501dc88e8c927d85e5/mini_racer-0.14.1-py3-none-win_amd64.whl", hash = "sha256:4abd58c62c9955988dbc0cbf5a798914334fe570d2b192f8890bec20135ab6d1", size = 15515732, upload-time = "2026-02-01T05:53:22.276Z" }, - { url = "https://files.pythonhosted.org/packages/99/fd/7fb43e269c44e5d44ef02fbd164fc11833d8293b47ddcd48e4fb1649f4d2/mini_racer-0.14.1-py3-none-win_arm64.whl", hash = "sha256:440bef1269655b1da94b550612b50669de9881c3d88b805c139f7f1b5ec8ae7b", size = 14829128, upload-time = "2026-02-01T05:53:24.983Z" }, -] - [[package]] name = "multitasking" version = "0.0.12" @@ -1181,18 +992,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] -[[package]] -name = "openpyxl" -version = "3.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "et-xmlfile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, -] - [[package]] name = "osqp" version = "1.1.1" @@ -1419,15 +1218,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, ] -[[package]] -name = "py-mini-racer" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/97/a578b918b2e5923dd754cb60bb8b8aeffc85255ffb92566e3c65b148ff72/py_mini_racer-0.6.0.tar.gz", hash = "sha256:f71e36b643d947ba698c57cd9bd2232c83ca997b0802fc2f7f79582377040c11", size = 5994836, upload-time = "2021-04-22T07:58:35.993Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/a9/8ce0ca222ef04d602924a1e099be93f5435ca6f3294182a30574d4159ca2/py_mini_racer-0.6.0-py2.py3-none-manylinux1_x86_64.whl", hash = "sha256:42896c24968481dd953eeeb11de331f6870917811961c9b26ba09071e07180e2", size = 5416149, upload-time = "2021-04-22T07:58:25.615Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1941,18 +1731,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, -] - [[package]] name = "typer" version = "0.24.1" @@ -2007,15 +1785,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - [[package]] name = "websockets" version = "16.0" @@ -2075,15 +1844,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] -[[package]] -name = "xlrd" -version = "2.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, -] - [[package]] name = "yfinance" version = "1.2.0" From 4df8b070ace02f1b08fc4029fd611ee30a2ed4ac Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Fri, 3 Apr 2026 12:28:03 +0800 Subject: [PATCH 6/7] test(market): add source-level unit and integration tests Add unit tests for each source module: - test_sina.py: current price, sector list, sector constituents - test_tencent.py: current price, price history, valuation - test_eastmoney.py: basic info, financial indicators Expand integration tests with financial indicators, sector list, and sector constituents. Fix unused import and format issues. Co-Authored-By: Claude Opus 4.6 --- haoinvest/market/sources/eastmoney.py | 3 +- haoinvest/market/sources/sina.py | 2 +- tests/test_market/test_ashare_integration.py | 24 +++- tests/test_market/test_provider_contract.py | 8 +- .../test_sources/test_eastmoney.py | 42 +++++- tests/test_market/test_sources/test_sina.py | 105 +++++++++++++++ .../test_market/test_sources/test_tencent.py | 126 ++++++++++++++++++ 7 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 tests/test_market/test_sources/test_sina.py create mode 100644 tests/test_market/test_sources/test_tencent.py diff --git a/haoinvest/market/sources/eastmoney.py b/haoinvest/market/sources/eastmoney.py index e64b474..24794b9 100644 --- a/haoinvest/market/sources/eastmoney.py +++ b/haoinvest/market/sources/eastmoney.py @@ -18,8 +18,7 @@ # Columns we request from RPT_LICO_FN_CPD (业绩报表) _FIN_COLUMNS = ( - "SECURITY_CODE,REPORTDATE,WEIGHTAVG_ROE,XSMLL," - "TOTAL_OPERATE_INCOME,PARENT_NETPROFIT" + "SECURITY_CODE,REPORTDATE,WEIGHTAVG_ROE,XSMLL,TOTAL_OPERATE_INCOME,PARENT_NETPROFIT" ) diff --git a/haoinvest/market/sources/sina.py b/haoinvest/market/sources/sina.py index 4cfdb79..7425635 100644 --- a/haoinvest/market/sources/sina.py +++ b/haoinvest/market/sources/sina.py @@ -10,7 +10,7 @@ import requests -from ._common import market_prefix, parse_float, parse_int +from ._common import market_prefix, parse_float def get_current_price(symbol: str) -> float: diff --git a/tests/test_market/test_ashare_integration.py b/tests/test_market/test_ashare_integration.py index a9942c9..4e64a20 100644 --- a/tests/test_market/test_ashare_integration.py +++ b/tests/test_market/test_ashare_integration.py @@ -8,6 +8,7 @@ import pytest from haoinvest.market.ashare_provider import AShareProvider +from haoinvest.market.sources import eastmoney, sina @pytest.mark.integration @@ -21,10 +22,27 @@ def test_get_price_history_moutai(self): provider = AShareProvider() bars = provider.get_price_history("600519", date(2026, 1, 2), date(2026, 1, 10)) assert len(bars) > 0 - assert all("close" in b for b in bars) + assert bars[0].close > 0 def test_get_basic_info_moutai(self): provider = AShareProvider() info = provider.get_basic_info("600519") - assert info["name"] != "" - assert info["currency"] == "CNY" + assert info.name != "" + assert info.currency == "CNY" + + def test_financial_indicators_moutai(self): + result = eastmoney.get_financial_indicators("600519") + assert result.get("roe") is not None + assert result["roe"] > 0 + assert result.get("gross_margin") is not None + + def test_sector_list(self): + rows = sina.get_sector_list() + assert len(rows) > 0 + assert rows[0]["name"] != "" + + def test_sector_constituents(self): + rows = sina.get_sector_constituents("白酒") + assert len(rows) > 0 + codes = [r["code"] for r in rows] + assert "600519" in codes diff --git a/tests/test_market/test_provider_contract.py b/tests/test_market/test_provider_contract.py index 9c95fa7..c7c8567 100644 --- a/tests/test_market/test_provider_contract.py +++ b/tests/test_market/test_provider_contract.py @@ -36,7 +36,9 @@ def test_get_current_price_fallback_to_tencent(self, mock_sina, mock_tencent): @patch("haoinvest.market.ashare_provider.sina.get_current_price") def test_get_current_price_not_found(self, mock_sina_price): - mock_sina_price.side_effect = ValueError("Symbol 999999 not found in A-share market") + mock_sina_price.side_effect = ValueError( + "Symbol 999999 not found in A-share market" + ) provider = AShareProvider() with pytest.raises(ValueError, match="not found"): provider.get_current_price("999999") @@ -66,7 +68,9 @@ def test_get_price_history(self, mock_tencent_history): ), ] provider = AShareProvider() - bars = provider.get_price_history("600519", date(2026, 3, 25), date(2026, 3, 27)) + bars = provider.get_price_history( + "600519", date(2026, 3, 25), date(2026, 3, 27) + ) assert len(bars) == 2 assert bars[0].close == 1680.0 assert bars[0].trade_date == date(2026, 3, 25) diff --git a/tests/test_market/test_sources/test_eastmoney.py b/tests/test_market/test_sources/test_eastmoney.py index e109881..0e0eadc 100644 --- a/tests/test_market/test_sources/test_eastmoney.py +++ b/tests/test_market/test_sources/test_eastmoney.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from haoinvest.market.sources.eastmoney import get_financial_indicators +from haoinvest.market.sources.eastmoney import get_basic_info, get_financial_indicators class TestGetFinancialIndicators: @@ -41,7 +41,9 @@ def test_successful_response(self, mock_get): assert result["roe"] == 24.64 assert result["gross_margin"] == 91.29 - assert result["profit_margin"] == 49.37 # 64626746712.18 / 130903889634.88 * 100 + assert ( + result["profit_margin"] == 49.37 + ) # 64626746712.18 / 130903889634.88 * 100 mock_get.assert_called_once() @patch("haoinvest.market.sources.eastmoney.requests.get") @@ -87,3 +89,39 @@ def test_missing_revenue_skips_profit_margin(self, mock_get): assert result["roe"] == 15.0 assert result["gross_margin"] == 60.0 assert "profit_margin" not in result + + +class TestGetBasicInfo: + """Test get_basic_info with mocked CompanySurvey API.""" + + @patch("haoinvest.market.sources.eastmoney.requests.get") + def test_success(self, mock_get): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "jbzl": { + "agjc": "贵州茅台", + "sshy": "白酒", + } + } + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + info = get_basic_info("600519") + assert info.name == "贵州茅台" + assert info.sector == "白酒" + assert info.currency == "CNY" + assert info.market_type == "a_share" + # Verify SH prefix used + call_params = mock_get.call_args[1]["params"] + assert call_params["code"] == "SH600519" + + @patch("haoinvest.market.sources.eastmoney.requests.get") + def test_sz_prefix(self, mock_get): + mock_resp = MagicMock() + mock_resp.json.return_value = {"jbzl": {"agjc": "五粮液", "sshy": "白酒"}} + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + get_basic_info("000858") + call_params = mock_get.call_args[1]["params"] + assert call_params["code"] == "SZ000858" diff --git a/tests/test_market/test_sources/test_sina.py b/tests/test_market/test_sources/test_sina.py new file mode 100644 index 0000000..832fad4 --- /dev/null +++ b/tests/test_market/test_sources/test_sina.py @@ -0,0 +1,105 @@ +"""Tests for Sina Finance data source.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from haoinvest.market.sources.sina import ( + get_current_price, + get_sector_constituents, + get_sector_list, +) + + +class TestGetCurrentPrice: + @patch("haoinvest.market.sources.sina.requests.get") + def test_success(self, mock_get): + mock_resp = MagicMock() + mock_resp.text = 'var hq_str_sh600519="贵州茅台,1680.00,1675.00,1682.50,1690.00,1670.00,...";' + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + price = get_current_price("600519") + assert price == 1682.50 + + @patch("haoinvest.market.sources.sina.requests.get") + def test_not_found(self, mock_get): + mock_resp = MagicMock() + mock_resp.text = 'var hq_str_sh999999="";' + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + with pytest.raises(ValueError, match="not found"): + get_current_price("999999") + + @patch("haoinvest.market.sources.sina.requests.get") + def test_sz_prefix(self, mock_get): + mock_resp = MagicMock() + mock_resp.text = ( + 'var hq_str_sz000858="五粮液,150.00,148.00,151.20,153.00,149.00,...";' + ) + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + price = get_current_price("000858") + assert price == 151.20 + # Verify sz prefix used + call_url = mock_get.call_args[0][0] + assert "sz000858" in call_url + + +class TestGetSectorList: + @patch("haoinvest.market.sources.sina._fetch_sector_data") + def test_success(self, mock_fetch): + mock_fetch.return_value = { + "new_blhy": "code1,白酒,10,100,2.5,3.15,1000,500,600519,0.5,1680,10,茅台".split( + "," + ), + "new_yh": "code2,银行,20,50,1.0,-0.32,2000,800,601398,0.3,5.5,0.1,工行".split( + "," + ), + } + rows = get_sector_list() + assert len(rows) == 2 + # Sorted by change_pct descending + assert rows[0]["name"] == "白酒" + assert rows[0]["change_pct"] == 3.15 + assert rows[1]["name"] == "银行" + assert rows[1]["change_pct"] == -0.32 + + +class TestGetSectorConstituents: + @patch("haoinvest.market.sources.sina.requests.get") + @patch("haoinvest.market.sources.sina._fetch_sector_data") + def test_success(self, mock_fetch, mock_get): + mock_fetch.return_value = { + "new_blhy": "code1,白酒,10,100,2.5,3.15".split(","), + } + mock_resp = MagicMock() + mock_resp.json.return_value = [ + { + "code": "600519", + "name": "贵州茅台", + "trade": "1680.00", + "changepercent": "1.5", + "per": "35.2", + "pb": "12.1", + "mktcap": 210000, + }, + ] + mock_resp.encoding = "gbk" + mock_get.return_value = mock_resp + + rows = get_sector_constituents("白酒") + assert len(rows) == 1 + assert rows[0]["code"] == "600519" + assert rows[0]["price"] == 1680.0 + assert rows[0]["total_market_cap"] == 2100000000 + + @patch("haoinvest.market.sources.sina._fetch_sector_data") + def test_sector_not_found(self, mock_fetch): + mock_fetch.return_value = { + "new_blhy": "code1,白酒,10,100,2.5,3.15".split(","), + } + with pytest.raises(ValueError, match="not found"): + get_sector_constituents("不存在的板块") diff --git a/tests/test_market/test_sources/test_tencent.py b/tests/test_market/test_sources/test_tencent.py new file mode 100644 index 0000000..caf5240 --- /dev/null +++ b/tests/test_market/test_sources/test_tencent.py @@ -0,0 +1,126 @@ +"""Tests for Tencent Finance data source.""" + +from datetime import date +from unittest.mock import MagicMock, patch + +import pytest + +from haoinvest.market.sources.tencent import ( + get_current_price, + get_price_history, + get_valuation, +) + + +class TestGetCurrentPrice: + @patch("haoinvest.market.sources.tencent.requests.get") + def test_success(self, mock_get): + # Tencent format: fields separated by ~, price at index 3 + fields = [""] * 50 + fields[3] = "1682.50" + mock_resp = MagicMock() + mock_resp.text = "~".join(fields) + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + price = get_current_price("600519") + assert price == 1682.50 + + @patch("haoinvest.market.sources.tencent.requests.get") + def test_empty_response(self, mock_get): + mock_resp = MagicMock() + mock_resp.text = "~~" + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + with pytest.raises(ValueError, match="not found"): + get_current_price("999999") + + +class TestGetPriceHistory: + @patch("haoinvest.market.sources.tencent.requests.get") + def test_success(self, mock_get): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "data": { + "sh600519": { + "qfqday": [ + # [date, open, close, high, low, volume] + [ + "2026-03-25", + "1670.00", + "1680.00", + "1685.00", + "1665.00", + "10000", + ], + [ + "2026-03-26", + "1675.00", + "1685.00", + "1690.00", + "1670.00", + "12000", + ], + ] + } + } + } + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + bars = get_price_history("600519", date(2026, 3, 25), date(2026, 3, 27)) + assert len(bars) == 2 + assert bars[0].trade_date == date(2026, 3, 25) + assert bars[0].open == 1670.0 + assert bars[0].close == 1680.0 + assert bars[0].high == 1685.0 + assert bars[0].low == 1665.0 + assert bars[0].volume == 10000.0 + + @patch("haoinvest.market.sources.tencent.requests.get") + def test_filters_out_of_range_dates(self, mock_get): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "data": { + "sh600519": { + "qfqday": [ + ["2026-03-24", "1660", "1665", "1670", "1655", "9000"], + ["2026-03-25", "1670", "1680", "1685", "1665", "10000"], + ] + } + } + } + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + bars = get_price_history("600519", date(2026, 3, 25), date(2026, 3, 27)) + assert len(bars) == 1 + assert bars[0].trade_date == date(2026, 3, 25) + + +class TestGetValuation: + @patch("haoinvest.market.sources.tencent.requests.get") + def test_success(self, mock_get): + # Build Tencent quote response with PE at index 39, cap at 45, PB at 46 + fields = [""] * 50 + fields[39] = "30.5" # PE TTM + fields[45] = "21000" # total market cap in 亿元 + fields[46] = "10.2" # PB + mock_resp = MagicMock() + mock_resp.text = "~".join(fields) + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + val = get_valuation("600519") + assert val["pe_ratio"] == 30.5 + assert val["pb_ratio"] == 10.2 + assert val["total_market_cap"] == 21000 * 1_0000_0000 + + @patch("haoinvest.market.sources.tencent.requests.get") + def test_failure_returns_defaults(self, mock_get): + mock_get.side_effect = Exception("Timeout") + val = get_valuation("600519") + assert val["pe_ratio"] is None + assert val["pb_ratio"] is None + assert val["total_market_cap"] is None From b21c5668080d12b2d0fd6ca069f6bfa0067cc346 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Fri, 3 Apr 2026 13:04:45 +0800 Subject: [PATCH 7/7] fix(test): fix integration test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Sina sector name "酿酒行业" instead of eastmoney's "白酒" in sector constituents test - Fix fundamental test to match actual output key "Overall_Valuation" (pre-existing issue on main) Co-Authored-By: Claude Opus 4.6 --- tests/test_cli/test_analyze.py | 2 +- tests/test_market/test_ashare_integration.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_cli/test_analyze.py b/tests/test_cli/test_analyze.py index 7b8bd3f..1510f2f 100644 --- a/tests/test_cli/test_analyze.py +++ b/tests/test_cli/test_analyze.py @@ -225,7 +225,7 @@ def test_fundamental_600519_real(self): result = runner.invoke(app, ["analyze", "fundamental", "600519"]) assert result.exit_code == 0 assert "贵州茅台" in result.output - assert "Overall:" in result.output + assert "Overall_Valuation:" in result.output @pytest.mark.integration def test_fundamental_000001_real(self): diff --git a/tests/test_market/test_ashare_integration.py b/tests/test_market/test_ashare_integration.py index 4e64a20..49050bc 100644 --- a/tests/test_market/test_ashare_integration.py +++ b/tests/test_market/test_ashare_integration.py @@ -42,7 +42,8 @@ def test_sector_list(self): assert rows[0]["name"] != "" def test_sector_constituents(self): - rows = sina.get_sector_constituents("白酒") + # Sina uses "酿酒行业" (not eastmoney's "白酒") + rows = sina.get_sector_constituents("酿酒行业") assert len(rows) > 0 codes = [r["code"] for r in rows] assert "600519" in codes