"""南方基金指定产品行情模块。""" # Public-source adapter retains readable request expressions; line-length checks are not useful here. # ruff: noqa: E501 from __future__ import annotations import json import logging import re import threading import time from datetime import date, datetime from datetime import time as clock_time from decimal import Decimal, InvalidOperation from html import unescape from typing import Any import httpx logger = logging.getLogger(__name__) NAV_API = "https://api.fund.eastmoney.com/f10/lsjz" RETURN_API = "https://api.fund.eastmoney.com/pinzhong/LJSYLZS" QUOTE_API = "https://push2.eastmoney.com/api/qt/ulist.np/get" EXCHANGE_HISTORY_API = "https://push2his.eastmoney.com/api/qt/stock/kline/get" TENCENT_QUOTE_API = "https://qt.gtimg.cn/q=" TENCENT_HISTORY_API = "https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get" DETAIL_API = "https://fund.eastmoney.com/pingzhongdata/{code}.js" SOUTHERN_COMPANY_API = "https://fund.eastmoney.com/company/80000220.html" REQUEST_TIMEOUT = 12.0 EXCHANGE_HISTORY_RETRIES = 2 EXCHANGE_HISTORY_LOCK = threading.Lock() EXCHANGE_HISTORY_MIN_INTERVAL_SECONDS = 1.0 MAX_FUNDS_PER_CALL = 1000 FUND_TYPE_GROUPS = { "货币型": ("202308", "020480", "511810"), # ⚠️ 此处曾含 `160129`(南方金利定开债券C),已移除:C 类份额只在场外销售、 # **不在交易所挂牌**,行情源对它永远返回空(下单因此走净值降级)。 # 它的 A 类 `160128` 是上市交易的,已在本组内。 "债券型": ("007161", "003776", "020281", "511070", "159700", "160128"), "混合型": ("018019", "014189", "018020", "160105", "160142", "160143", "501062"), "股票型": ( "020553", "016449", "008854", "008264", "008736", "010592", "160127", "588890", "020839", "589700", "159382", "159511", "002900", "021958", "159948", "009059", "001421", "510500", "515450", ), "QDII": ("501018", "159329", "159615", "159687"), } SOUTHERN_FUND_CODES = tuple( dict.fromkeys(code for codes in FUND_TYPE_GROUPS.values() for code in codes) ) FUND_TYPE_BY_CODE = { code: fund_type for fund_type, codes in FUND_TYPE_GROUPS.items() for code in codes } HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://fund.eastmoney.com/"} _history_date: str | None = None _history_cache: dict[str, dict[str, str | None]] = {} _name_cache: dict[str, str] = {} _last_exchange_history_request_at = 0.0 class ExchangeQuoteSourceError(RuntimeError): """A public exchange quote provider could not supply a usable response.""" def is_market_trading_time(now: datetime | None = None) -> bool: """判断中国大陆工作日盘中时段。""" current = now or datetime.now() if current.weekday() >= 5: return False current_time = current.time() return (clock_time(9, 30) <= current_time <= clock_time(11, 30) or clock_time(13, 0) <= current_time <= clock_time(15, 0)) def get_southern_fund_market( target_date: str | None = None, limit: int | None = None, fund_type: str | None = None, fund_codes: list[str] | tuple[str, ...] | None = None, ) -> list[dict[str, Any]]: """获取指定南方基金的完整行情表。 每次调用刷新整张表的实时行情;历史收益同一日期只请求一次并保存在进程缓存。 limit 不传时返回 SOUTHERN_FUND_CODES 中的全部产品。 """ query_date = target_date or date.today().isoformat() date.fromisoformat(query_date) if fund_type and fund_type not in FUND_TYPE_GROUPS: raise ValueError("基金类型不在南方基金白名单内") available_codes = FUND_TYPE_GROUPS[fund_type] if fund_type else SOUTHERN_FUND_CODES if fund_codes is not None: requested = tuple(dict.fromkeys(fund_codes)) if any(code not in SOUTHERN_FUND_CODES for code in requested): raise ValueError("基金代码不在南方基金白名单内") available_codes = tuple(code for code in requested if code in available_codes) count = len(available_codes) if limit is None else min(max(limit, 1), MAX_FUNDS_PER_CALL) codes = list(available_codes[:count]) names = _get_names(codes) history = _get_history(codes, query_date) quotes = _get_quotes(codes) if is_market_trading_time() else {} now = time.strftime("%Y-%m-%d") rows = [] for code in codes: old = history.get(code, {}) live = quotes.get(code, {}) rows.append({ "基金代码": code, "基金名称": names.get(code, f"南方基金 {code}"), "基金类型": FUND_TYPE_BY_CODE.get(code, "未分类"), "基金净值": live.get("基金净值") or old.get("基金净值"), "日期": live.get("日期") or old.get("日期"), "日涨幅": live.get("日涨幅") or old.get("日涨幅"), "最近半年": old.get("最近半年"), "最近一年": old.get("最近一年"), "今年以来": old.get("今年以来"), "成立以来": old.get("成立以来"), "行情时间": now, "行情来源": "盘中实时行情" if live.get("基金净值") else "收盘后最新净值", "是否盘中": is_market_trading_time(), }) return rows def get_southern_fund_nav_history( fund_code: str, start_date: str, end_date: str ) -> list[dict[str, str]]: """Return validated historical unit-NAV observations for an allowed fund.""" if fund_code not in SOUTHERN_FUND_CODES: raise ValueError("基金代码不在南方基金白名单内") start = date.fromisoformat(start_date) end = date.fromisoformat(end_date) if start > end: raise ValueError("开始日期不能晚于结束日期") records: list[dict[str, Any]] = [] try: records = _fetch_nav_records( fund_code, start_date=start.isoformat(), end_date=end.isoformat(), all_pages=True ) except (httpx.HTTPError, ValueError, TypeError) as exc: logger.warning("东方财富历史净值接口失败 code=%s error=%s", fund_code, type(exc).__name__) turnover_by_date: dict[str, str] = {} turnover_source_by_date: dict[str, str] = {} nav_source_by_date: dict[str, str] = {} if not records: # Listed funds can use Tencent's exchange close as the historical price when # the fund NAV provider is unavailable. Amount remains the provider field. fallback_rows = get_southern_fund_exchange_history_tencent( fund_code, start_date, end_date ) records = [ { "FSRQ": row["trade_date"], "DWJZ": row["close_price"], "turnover_amount": row["turnover_amount"], "source": "tencent_hq_history", } for row in fallback_rows ] for row in fallback_rows: trade_date = row["trade_date"] turnover_by_date[trade_date] = row["turnover_amount"] turnover_source_by_date[trade_date] = "tencent_hq_history" nav_source_by_date[trade_date] = "tencent_hq_history" try: if records and not nav_source_by_date: for row in get_southern_fund_exchange_history(fund_code, start_date, end_date): if row.get("turnover_amount"): trade_date = row["trade_date"] turnover_by_date[trade_date] = row["turnover_amount"] turnover_source_by_date[trade_date] = "eastmoney_hq_nav" except (httpx.HTTPError, ValueError, TypeError) as exc: logger.warning("历史成交额接口失败 code=%s error=%s", fund_code, type(exc).__name__) needed_dates = { str(record.get("FSRQ") or "") for record in records if record.get("FSRQ") } if needed_dates - turnover_by_date.keys(): try: for row in get_southern_fund_exchange_history_tencent( fund_code, start_date, end_date ): trade_date = row["trade_date"] if trade_date not in turnover_by_date and row.get("turnover_amount"): turnover_by_date[trade_date] = row["turnover_amount"] turnover_source_by_date[trade_date] = "eastmoney_hq_nav+tencent_hq_history" except (httpx.HTTPError, ValueError, TypeError) as exc: logger.warning("腾讯历史成交额接口失败 code=%s error=%s", fund_code, type(exc).__name__) observations: list[dict[str, str]] = [] for record in records: value_date = str(record.get("FSRQ") or "") nav = str(record.get("DWJZ") or "").strip() try: parsed_date = date.fromisoformat(value_date) if parsed_date < start or parsed_date > end or float(nav) <= 0: continue except ValueError: continue row = { "fund_code": fund_code, "trade_date": value_date, "nav": nav, "source": nav_source_by_date.get(value_date, "eastmoney_hq_nav"), } if value_date in turnover_by_date: row["turnover_amount"] = turnover_by_date[value_date] row["source"] = turnover_source_by_date[value_date] observations.append(row) return sorted(observations, key=lambda item: item["trade_date"]) def get_southern_fund_exchange_history( fund_code: str, start_date: str, end_date: str ) -> list[dict[str, str]]: """Return exchange daily close and turnover for a listed fund. The NAV endpoint has no trading amount. Eastmoney's exchange K-line endpoint exposes amount as field f56, which is the only source accepted for historical liquidity calculations here. """ if fund_code not in SOUTHERN_FUND_CODES: raise ValueError("基金代码不在南方基金白名单内") start = date.fromisoformat(start_date) end = date.fromisoformat(end_date) if start > end: raise ValueError("开始日期不能晚于结束日期") secid = ("1." if fund_code.startswith(("5", "6", "9")) else "0.") + fund_code params: dict[str, str | int] = { "secid": secid, "klt": 101, "fqt": 1, "beg": start.strftime("%Y%m%d"), "end": end.strftime("%Y%m%d"), "fields1": "f1,f2,f3,f4,f5,f6", "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61", } for attempt in range(EXCHANGE_HISTORY_RETRIES + 1): try: # The public endpoint intermittently resets concurrent connections. Serialize # historical requests while keeping product-level async orchestration intact. with EXCHANGE_HISTORY_LOCK: _pace_exchange_history_request() response = httpx.get( EXCHANGE_HISTORY_API, params=params, headers={ **HEADERS, "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close", }, timeout=REQUEST_TIMEOUT, ) response.raise_for_status() payload = response.json() if not isinstance(payload, dict): raise ValueError("历史行情响应不是 JSON 对象") break except (httpx.HTTPError, ValueError, TypeError): if attempt == EXCHANGE_HISTORY_RETRIES: raise time.sleep(0.75 * (2**attempt)) data = payload.get("data") or {} if not isinstance(data, dict): raise ValueError("历史行情响应 data 字段格式错误") records = data.get("klines") or [] result: list[dict[str, str]] = [] for raw in records: fields = str(raw).split(",") if len(fields) < 7: continue trade_date, close_price, turnover_amount = fields[0], fields[2], fields[6] try: parsed_date = date.fromisoformat(trade_date) if parsed_date < start or parsed_date > end: continue if float(close_price) <= 0 or float(turnover_amount) < 0: continue except ValueError: continue result.append({ "fund_code": fund_code, "trade_date": trade_date, "close_price": close_price, "turnover_amount": turnover_amount, }) return result def get_southern_fund_exchange_history_tencent( fund_code: str, start_date: str, end_date: str ) -> list[dict[str, str]]: """Return Tencent daily close and turnover as the history fallback. Tencent's public response stores turnover in ``day[][8]`` in ten-thousand yuan. This function converts that explicitly documented unit to yuan; it never derives turnover from volume or price. """ if fund_code not in SOUTHERN_FUND_CODES: raise ValueError("基金代码不在南方基金白名单内") start = date.fromisoformat(start_date) end = date.fromisoformat(end_date) if start > end: raise ValueError("开始日期不能晚于结束日期") symbol = ("sh" if fund_code.startswith(("5", "6", "9")) else "sz") + fund_code result: dict[str, dict[str, str]] = {} for year in range(start.year, end.year + 1): params = { "_var": f"kline_day{year}", "param": f"{symbol},day,{year}-01-01,{year + 1}-12-31,640,", "r": "0.8205512681390605", } for attempt in range(EXCHANGE_HISTORY_RETRIES + 1): try: with EXCHANGE_HISTORY_LOCK: _pace_exchange_history_request() response = httpx.get( TENCENT_HISTORY_API, params=params, headers={ "User-Agent": HEADERS["User-Agent"], "Referer": "https://gu.qq.com/", }, timeout=REQUEST_TIMEOUT, ) response.raise_for_status() payload_text = response.text _, separator, payload_text = payload_text.partition("=") if not separator: raise ValueError("腾讯历史行情响应不是 JSONP") payload = json.loads(payload_text) if not isinstance(payload, dict): raise ValueError("腾讯历史行情响应不是 JSON 对象") break except (httpx.HTTPError, ValueError, TypeError): if attempt == EXCHANGE_HISTORY_RETRIES: raise time.sleep(0.75 * (2**attempt)) data = payload.get("data") or {} if not isinstance(data, dict): raise ValueError("腾讯历史行情响应 data 字段格式错误") symbol_data = data.get(symbol) or {} if not isinstance(symbol_data, dict): raise ValueError("腾讯历史行情响应产品字段格式错误") rows = symbol_data.get("day") or [] if not isinstance(rows, list): raise ValueError("腾讯历史行情响应 day 字段格式错误") for raw in rows: if not isinstance(raw, list) or len(raw) < 9: continue trade_date, close_price, raw_amount = str(raw[0]), str(raw[2]), raw[8] try: parsed_date = date.fromisoformat(trade_date) close = float(close_price) amount = Decimal(str(raw_amount)) * Decimal("10000") if parsed_date < start or parsed_date > end or close <= 0 or amount < 0: continue except (InvalidOperation, TypeError, ValueError): continue result[trade_date] = { "fund_code": fund_code, "trade_date": trade_date, "close_price": close_price, "turnover_amount": format(amount, "f"), } return [result[key] for key in sorted(result)] def _pace_exchange_history_request() -> None: """Avoid triggering provider-side connection resets during bulk refreshes.""" global _last_exchange_history_request_at now = time.monotonic() wait_seconds = ( EXCHANGE_HISTORY_MIN_INTERVAL_SECONDS - (now - _last_exchange_history_request_at) ) if wait_seconds > 0: time.sleep(wait_seconds) _last_exchange_history_request_at = time.monotonic() def get_southern_fund_catalog( fund_codes: list[str] | tuple[str, ...], ) -> list[dict[str, Any]]: """Return Southern Fund's public catalogue rows. The company directory supplies fund type and reported asset scale, which are needed to select test products. It is reference data only: product risk is intentionally not inferred here because the source does not publish a channel-independent R1-R5 suitability rating. """ requested = tuple(dict.fromkeys(fund_codes)) if not requested: return [] response = httpx.get(SOUTHERN_COMPANY_API, headers=HEADERS, timeout=REQUEST_TIMEOUT) response.raise_for_status() content = response.content.decode("utf-8") scale_as_of_date = _company_scale_as_of_date(content) rows = [] for code in requested: row = _company_catalog_row(content, code) if row is not None: row["scale_as_of_date"] = scale_as_of_date rows.append(row) return rows def get_southern_exchange_catalog( fund_codes: list[str] | tuple[str, ...], ) -> list[dict[str, Any]]: """Backward-compatible alias for callers that only request exchange codes.""" return get_southern_fund_catalog(fund_codes) def _company_catalog_row(content: str, fund_code: str) -> dict[str, Any] | None: marker = f'class="code">{fund_code}' position = content.find(marker) if position < 0: return None start = content.rfind("