eastmoney_spider.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/5/22 10:55
  5. """
  6. 目标网站:
  7. https://quote.eastmoney.com/concept/sz300624.html?from=classic
  8. 说明:
  9. 页面右侧"筹码分布"图没有专用接口 —— 它是前端用 K 线数据在本地用
  10. CYQCalculator 算出来的(见 quotechart2022.js / src/modules/tools/indicator/cyq.ts)。
  11. 本脚本: 拉 K 线 → 复刻三角分布筹码算法 → 输出筹码分布。
  12. """
  13. import json
  14. import math
  15. import random
  16. import re
  17. import time
  18. # import requests
  19. from curl_cffi.requests import BrowserType
  20. from curl_cffi import requests
  21. from loguru import logger
  22. from datetime import datetime
  23. from pydantic import BaseModel, Field
  24. from fastapi import FastAPI, Query
  25. from fastapi.exceptions import RequestValidationError
  26. from fastapi.responses import JSONResponse
  27. from tenacity import retry, retry_if_not_exception_type, stop_after_attempt, wait_fixed
  28. logger.remove()
  29. logger.add("./logs/{time:YYYYMMDD}.log", encoding='utf-8', rotation="00:00",
  30. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  31. level="DEBUG", retention="7 day")
  32. # 直接用库内置的所有浏览器指纹
  33. client_identifier_list = [b.value for b in BrowserType]
  34. KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
  35. # 反爬绕过: 东方财富会拒绝默认 python-requests UA, 需要伪装浏览器
  36. HEADERS = {
  37. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
  38. "Referer": "https://quote.eastmoney.com/concept/sz300624.html?from=classic",
  39. }
  40. app = FastAPI(title="Eastmoney CYQ API", version="1.0.0")
  41. class InvalidStockMarketError(Exception):
  42. """当前市场前缀没有返回有效 K 线数据。"""
  43. class ChipRequest(BaseModel):
  44. stockCode: str | int = Field(..., description="股票代码,例如 688605")
  45. endTime: str = Field(..., description="查询日期,支持 YYYY-MM-DD 或 YYYYMMDD")
  46. def success_response(data):
  47. return {
  48. "code": 0,
  49. "message": "success",
  50. "data": data,
  51. }
  52. def error_response(code: int, message: str, status_code: int = 200):
  53. return JSONResponse(
  54. status_code=status_code,
  55. content={
  56. "code": code,
  57. "message": message,
  58. "data": None,
  59. },
  60. )
  61. @app.exception_handler(RequestValidationError)
  62. async def validation_exception_handler(_request, exc):
  63. """
  64. FastAPI 全局参数校验异常处理器。
  65. :param _request: (starlette.requests.Request) 当前请求对象, 当前实现不需要使用,
  66. 但 FastAPI 异常处理器签名要求保留该位置参数
  67. :param exc: (RequestValidationError) 参数校验异常对象, 含错误明细
  68. :return: (JSONResponse) 统一错误响应, code=400
  69. """
  70. return error_response(400, f"参数错误: {exc.errors()}")
  71. class CYQCalculator:
  72. """三角分布筹码分布算法 — 移植自 quotechart2022.js 的 CYQCalculator"""
  73. def __init__(self, klinedata, fator: int = 150, range_=None):
  74. self.klinedata = klinedata
  75. self.fator = fator or 150
  76. self.range = range_
  77. def calc(self, e: int) -> dict:
  78. o = self.fator
  79. r = max(0, e - self.range + 1) if self.range else 0
  80. window = self.klinedata[r: max(1, e + 1)]
  81. if not window:
  82. raise ValueError("invalid index")
  83. hi_max = max(k["high"] for k in window)
  84. lo_min = min(k["low"] for k in window)
  85. u = max(0.01, (hi_max - lo_min) / (o - 1))
  86. prices = [round(lo_min + u * s, 2) for s in range(o)]
  87. chips = [0.0] * o
  88. for d in window:
  89. op, cl, hi, lo = d["open"], d["close"], d["high"], d["low"]
  90. m = (op + cl + hi + lo) / 4
  91. A = min(1.0, (d["hsl"] or 0) / 100.0)
  92. g = math.floor((hi - lo_min) / u)
  93. v = math.ceil((lo - lo_min) / u)
  94. y0 = (o - 1) if hi == lo else 2.0 / (hi - lo)
  95. y1 = math.floor((m - lo_min) / u)
  96. chips = [c * (1 - A) for c in chips]
  97. if hi == lo:
  98. chips[y1] += y0 * A / 2
  99. else:
  100. for j in range(v, g + 1):
  101. x = lo_min + u * j
  102. if x <= m:
  103. if abs(m - lo) < 1e-8:
  104. chips[j] += y0 * A
  105. else:
  106. chips[j] += (x - lo) / (m - lo) * y0 * A
  107. else:
  108. if abs(hi - m) < 1e-8:
  109. chips[j] += y0 * A
  110. else:
  111. chips[j] += (hi - x) / (hi - m) * y0 * A
  112. k_sum = sum(chips)
  113. close = self.klinedata[e]["close"]
  114. def S(target: float) -> float:
  115. run = 0.0
  116. for i, c in enumerate(chips):
  117. if run + c > target:
  118. return lo_min + u * i
  119. run += c
  120. return 0.0
  121. def compute_percent_chips(percent: float) -> dict:
  122. if not (0 <= percent <= 1):
  123. raise ValueError('"percent" out of range')
  124. lo_p = S(k_sum * (1 - percent) / 2)
  125. hi_p = S(k_sum * (1 + percent) / 2)
  126. return {
  127. "priceRange": [f"{lo_p:.2f}", f"{hi_p:.2f}"],
  128. "concentration": 0 if lo_p + hi_p == 0 else (hi_p - lo_p) / (hi_p + lo_p),
  129. }
  130. benefit = sum(c for s, c in enumerate(chips) if close >= lo_min + s * u)
  131. benefit_part = 0 if k_sum == 0 else benefit / k_sum
  132. return {
  133. "x": chips,
  134. "y": prices,
  135. "benefitPart": benefit_part,
  136. "avgCost": f"{S(0.5 * k_sum):.2f}",
  137. "percentChips": {
  138. 90: compute_percent_chips(0.9),
  139. 70: compute_percent_chips(0.7),
  140. },
  141. }
  142. def after_log(retry_state):
  143. """
  144. retry 回调
  145. :param retry_state: RetryCallState 对象
  146. """
  147. # 检查 args 是否存在且不为空
  148. if retry_state.args and len(retry_state.args) > 0:
  149. log = retry_state.args[0] # 获取传入的 logger
  150. else:
  151. log = logger # 使用全局 logger
  152. if retry_state.outcome.failed:
  153. log.warning(
  154. f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  155. else:
  156. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  157. def get_proxys():
  158. """
  159. 获取快代理隧道代理配置 (固定凭证, 字符串拼接不会失败, 不需要重试)。
  160. :return: (dict) 形如 {"http": "http://user:pwd@host:port/", "https": "..."}
  161. """
  162. tunnel = "x371.kdltps.com:15818"
  163. kdl_username = "t13753103189895"
  164. kdl_password = "o0yefv6z"
  165. proxy_url = f"http://{kdl_username}:{kdl_password}@{tunnel}/"
  166. return {"http": proxy_url, "https": proxy_url}
  167. @retry(
  168. stop=stop_after_attempt(5),
  169. wait=wait_fixed(1),
  170. after=after_log,
  171. retry=retry_if_not_exception_type(InvalidStockMarketError),
  172. )
  173. def fetch_kline(log, secid: str, end: str = "20990101", lmt: int = 210, fqt: int = 1):
  174. """
  175. 拉日K数据。fqt: 0=不复权, 1=前复权, 2=后复权
  176. :param log: 日志对象
  177. :param secid: 股票代码,例如 "0.300624"
  178. :param end: 结束日期,格式 "YYYYMMDD"
  179. :param lmt: 每次拉取的K线数量
  180. :param fqt: 复权方式
  181. :return: K线数据列表
  182. """
  183. log.info(f"Fetching kline data for {secid} from {end} with {lmt} bars and {fqt} dividend adjustment")
  184. params = {
  185. "secid": secid, # 0.=深市, 1.=沪市
  186. "ut": "fa5fd1943c7b386f172d6893dbfba10b",
  187. "fields1": "f1,f2,f3,f4,f5,f6",
  188. "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61",
  189. "klt": 101, # 101=日K, 102=周K, 103=月K, 5/15/30/60=分钟K
  190. "fqt": fqt,
  191. "end": end,
  192. "lmt": lmt,
  193. "cb": "cb",
  194. }
  195. impersonate = random.choice(client_identifier_list)
  196. r = requests.get(KLINE_URL, impersonate=impersonate, params=params, timeout=10, headers=HEADERS,
  197. proxies=get_proxys())
  198. r.raise_for_status()
  199. matched = re.search(r"\((.*)\);?$", r.text.strip())
  200. if not matched:
  201. raise ValueError(f"{secid} 返回格式异常")
  202. response_data = json.loads(matched.group(1))
  203. data = response_data.get("data")
  204. if data is None:
  205. raise InvalidStockMarketError(f"{secid} 返回 data=null,可能市场前缀不匹配")
  206. raw_klines = data.get("klines") or []
  207. if not raw_klines:
  208. raise InvalidStockMarketError(f"{secid} 没有返回 K 线数据")
  209. klines = []
  210. for line in raw_klines:
  211. f = line.split(",")
  212. klines.append({
  213. "date": f[0],
  214. "open": float(f[1]),
  215. "close": float(f[2]),
  216. "high": float(f[3]),
  217. "low": float(f[4]),
  218. "volume": float(f[5]),
  219. "amount": float(f[6]),
  220. "amp": float(f[7]),
  221. "pct": float(f[8]),
  222. "chg": float(f[9]),
  223. "hsl": float(f[10]), # 换手率 %
  224. })
  225. return data, klines
  226. def normalize_date(endTime: str) -> str:
  227. """
  228. 把日期字符串归一化成东方财富接口要求的 YYYYMMDD 格式。
  229. :param endTime: (str) 日期字符串,支持 "YYYY-MM-DD" 或 "YYYYMMDD"
  230. :return: (str) 形如 "20260524" 的日期字符串
  231. """
  232. if '-' in endTime:
  233. return endTime.replace('-', '')
  234. return endTime
  235. def guess_market_prefix(stock_code: str) -> str:
  236. """
  237. 根据股票代码首位判定东方财富 secid 的市场前缀。
  238. 规则:
  239. 沪市主板(60) / 科创板(68) / 沪市可转债(11) / 沪市国债逆回购(13) / 沪市基金&ETF(5) → "1."
  240. 深市主板(00) / 创业板(30) / 深市可转债(12) / 北交所(4x、8x) → "0."
  241. :param stock_code: (str) 6 位股票代码字符串,例如 "688605"
  242. :return: (str) "1." 或 "0."
  243. """
  244. code = str(stock_code).strip()
  245. if code.startswith(("60", "68", "11", "13", "5")):
  246. return "1."
  247. return "0."
  248. def get_chip_distribution(log, stockCode, endTime):
  249. """
  250. 计算筹码分布接口数据
  251. :param log: 日志对象
  252. :param stockCode: 股票代码
  253. :param endTime: 结束时间
  254. :return: 接口返回字典
  255. """
  256. log.info(f"Fetching data for stock {stockCode} on {endTime}")
  257. end_time = normalize_date(endTime)
  258. stock = guess_market_prefix(str(stockCode))
  259. secid = stock + str(stockCode)
  260. raw, klines = fetch_kline(log, secid, end=end_time, lmt=210, fqt=1)
  261. res = CYQCalculator(klines, fator=150).calc(len(klines) - 1)
  262. trading_day = klines[-1]["date"] # 交易日
  263. profit_ratio = f'{res["benefitPart"] * 100:.2f}%' # 获利比例
  264. average_cost = res["avgCost"] # 平均成本
  265. p90 = res["percentChips"][90] # 90% 成本区间
  266. p70 = res["percentChips"][70] # 70% 成本区间
  267. p90_cost_range = p90["priceRange"] # 90% 成本区间
  268. p90_concentration_ratio = f'{p90["concentration"] * 100:.2f}%' # 90% 成本区间集中度
  269. p70_cost_range = p70["priceRange"] # 70% 成本区间
  270. p70_concentration_ratio = f'{p70["concentration"] * 100:.2f}%' # 70% 成本区间集中度
  271. data_dict = {
  272. "stock": stock, # 股票前缀 0.=深市, 1.=沪市
  273. "stock_code": str(stockCode),
  274. "end_time": endTime,
  275. "trading_day": trading_day,
  276. "profit_ratio": profit_ratio,
  277. "average_cost": average_cost,
  278. "p90_cost_range": p90_cost_range,
  279. "p90_concentration_ratio": p90_concentration_ratio,
  280. "p70_cost_range": p70_cost_range,
  281. "p70_concentration_ratio": p70_concentration_ratio,
  282. }
  283. return data_dict
  284. def main(log, stockCode, endTime):
  285. data_dict = get_chip_distribution(log, stockCode, endTime)
  286. print(data_dict)
  287. return data_dict
  288. @app.get("/chip-distribution")
  289. def chip_distribution_get(
  290. stockCode: str = Query(..., description="股票代码,例如 688605"),
  291. endTime: str = Query(..., description="查询日期,支持 YYYY-MM-DD 或 YYYYMMDD"),
  292. ):
  293. try:
  294. return success_response(get_chip_distribution(logger, stockCode, endTime))
  295. except Exception as exc:
  296. logger.exception(f"接口查询失败:{exc}")
  297. return error_response(500, str(exc))
  298. @app.post("/chip-distribution")
  299. def chip_distribution_post(payload: ChipRequest):
  300. try:
  301. return success_response(get_chip_distribution(logger, payload.stockCode, payload.endTime))
  302. except Exception as exc:
  303. logger.exception(f"接口查询失败:{exc}")
  304. return error_response(500, str(exc))
  305. if __name__ == "__main__":
  306. start_time = time.time()
  307. now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  308. print(f"\n{'=' * 60}\n[{now}] 开始本次查询\n{'=' * 60}")
  309. try:
  310. # main(logger, "688605", "2026-05-25")
  311. main(logger, "300624", "2026-05-25")
  312. except Exception as me:
  313. # 单次失败(网络抖动 / 接口异常)不退出,等下一轮重试
  314. logger.exception(f"本次查询失败:{me}")
  315. e_time = time.time()
  316. logger.info(f"本次查询耗时 {e_time - start_time:.2f} 秒")
  317. """
  318. 传入
  319. stockCode: 688605
  320. endTime:2026-05-24
  321. """