Переглянути джерело

feat(api): 实现东方财富筹码分布计算接口

- 复刻前端 CYQCalculator 算法实现筹码分布计算
- 使用 curl_cffi 实现带浏览器指纹和代理的东方财富日K接口请求
- 实现参数校验和统一错误响应机制
- 支持 GET 和 POST 两种请求方式查询筹码分布
- 自动识别股票市场前缀,支持多日期格式输入
- 增加重试机制处理网络异常和数据格式异常
- 添加详细接口文档说明使用方法和返回字段
- 通过日志支持请求过程跟踪和异常捕获
charley 2 тижнів тому
батько
коміт
8218f55326

BIN
eastmoney_spider/1507fc789c20046814a1809b2825f3e9.png


+ 386 - 0
eastmoney_spider/eastmoney_spider.py

@@ -0,0 +1,386 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/5/22 10:55
+"""
+目标网站:
+        https://quote.eastmoney.com/concept/sz300624.html?from=classic
+
+说明:
+    页面右侧"筹码分布"图没有专用接口 —— 它是前端用 K 线数据在本地用
+    CYQCalculator 算出来的(见 quotechart2022.js / src/modules/tools/indicator/cyq.ts)。
+    本脚本: 拉 K 线 → 复刻三角分布筹码算法 → 输出筹码分布。
+"""
+import json
+import math
+import random
+import re
+import time
+# import requests
+from curl_cffi.requests import BrowserType
+from curl_cffi import requests
+
+from loguru import logger
+from datetime import datetime
+from pydantic import BaseModel, Field
+from fastapi import FastAPI, Query
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from tenacity import retry, retry_if_not_exception_type, stop_after_attempt, wait_fixed
+
+logger.remove()
+logger.add("./logs/{time:YYYYMMDD}.log", encoding='utf-8', rotation="00:00",
+           format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
+           level="DEBUG", retention="7 day")
+
+# 直接用库内置的所有浏览器指纹
+client_identifier_list = [b.value for b in BrowserType]
+
+KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
+# 反爬绕过: 东方财富会拒绝默认 python-requests UA, 需要伪装浏览器
+HEADERS = {
+    "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",
+    "Referer": "https://quote.eastmoney.com/concept/sz300624.html?from=classic",
+}
+app = FastAPI(title="Eastmoney CYQ API", version="1.0.0")
+
+
+class InvalidStockMarketError(Exception):
+    """当前市场前缀没有返回有效 K 线数据。"""
+
+
+class ChipRequest(BaseModel):
+    stockCode: str | int = Field(..., description="股票代码,例如 688605")
+    endTime: str = Field(..., description="查询日期,支持 YYYY-MM-DD 或 YYYYMMDD")
+
+
+def success_response(data):
+    return {
+        "code": 0,
+        "message": "success",
+        "data": data,
+    }
+
+
+def error_response(code: int, message: str, status_code: int = 200):
+    return JSONResponse(
+        status_code=status_code,
+        content={
+            "code": code,
+            "message": message,
+            "data": None,
+        },
+    )
+
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(_request, exc):
+    """
+    FastAPI 全局参数校验异常处理器。
+
+    :param _request: (starlette.requests.Request) 当前请求对象, 当前实现不需要使用,
+                     但 FastAPI 异常处理器签名要求保留该位置参数
+    :param exc: (RequestValidationError) 参数校验异常对象, 含错误明细
+    :return: (JSONResponse) 统一错误响应, code=400
+    """
+    return error_response(400, f"参数错误: {exc.errors()}")
+
+
+class CYQCalculator:
+    """三角分布筹码分布算法 — 移植自 quotechart2022.js 的 CYQCalculator"""
+
+    def __init__(self, klinedata, fator: int = 150, range_=None):
+        self.klinedata = klinedata
+        self.fator = fator or 150
+        self.range = range_
+
+    def calc(self, e: int) -> dict:
+        o = self.fator
+        r = max(0, e - self.range + 1) if self.range else 0
+        window = self.klinedata[r: max(1, e + 1)]
+        if not window:
+            raise ValueError("invalid index")
+
+        hi_max = max(k["high"] for k in window)
+        lo_min = min(k["low"] for k in window)
+        u = max(0.01, (hi_max - lo_min) / (o - 1))
+
+        prices = [round(lo_min + u * s, 2) for s in range(o)]
+        chips = [0.0] * o
+
+        for d in window:
+            op, cl, hi, lo = d["open"], d["close"], d["high"], d["low"]
+            m = (op + cl + hi + lo) / 4
+            A = min(1.0, (d["hsl"] or 0) / 100.0)
+            g = math.floor((hi - lo_min) / u)
+            v = math.ceil((lo - lo_min) / u)
+            y0 = (o - 1) if hi == lo else 2.0 / (hi - lo)
+            y1 = math.floor((m - lo_min) / u)
+
+            chips = [c * (1 - A) for c in chips]
+
+            if hi == lo:
+                chips[y1] += y0 * A / 2
+            else:
+                for j in range(v, g + 1):
+                    x = lo_min + u * j
+                    if x <= m:
+                        if abs(m - lo) < 1e-8:
+                            chips[j] += y0 * A
+                        else:
+                            chips[j] += (x - lo) / (m - lo) * y0 * A
+                    else:
+                        if abs(hi - m) < 1e-8:
+                            chips[j] += y0 * A
+                        else:
+                            chips[j] += (hi - x) / (hi - m) * y0 * A
+
+        k_sum = sum(chips)
+        close = self.klinedata[e]["close"]
+
+        def S(target: float) -> float:
+            run = 0.0
+            for i, c in enumerate(chips):
+                if run + c > target:
+                    return lo_min + u * i
+                run += c
+            return 0.0
+
+        def compute_percent_chips(percent: float) -> dict:
+            if not (0 <= percent <= 1):
+                raise ValueError('"percent" out of range')
+            lo_p = S(k_sum * (1 - percent) / 2)
+            hi_p = S(k_sum * (1 + percent) / 2)
+            return {
+                "priceRange": [f"{lo_p:.2f}", f"{hi_p:.2f}"],
+                "concentration": 0 if lo_p + hi_p == 0 else (hi_p - lo_p) / (hi_p + lo_p),
+            }
+
+        benefit = sum(c for s, c in enumerate(chips) if close >= lo_min + s * u)
+        benefit_part = 0 if k_sum == 0 else benefit / k_sum
+
+        return {
+            "x": chips,
+            "y": prices,
+            "benefitPart": benefit_part,
+            "avgCost": f"{S(0.5 * k_sum):.2f}",
+            "percentChips": {
+                90: compute_percent_chips(0.9),
+                70: compute_percent_chips(0.7),
+            },
+        }
+
+
+def after_log(retry_state):
+    """
+    retry 回调
+    :param retry_state: RetryCallState 对象
+    """
+    # 检查 args 是否存在且不为空
+    if retry_state.args and len(retry_state.args) > 0:
+        log = retry_state.args[0]  # 获取传入的 logger
+    else:
+        log = logger  # 使用全局 logger
+
+    if retry_state.outcome.failed:
+        log.warning(
+            f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
+    else:
+        log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
+
+
+def get_proxys():
+    """
+    获取快代理隧道代理配置 (固定凭证, 字符串拼接不会失败, 不需要重试)。
+
+    :return: (dict) 形如 {"http": "http://user:pwd@host:port/", "https": "..."}
+    """
+    tunnel = "x371.kdltps.com:15818"
+    kdl_username = "t13753103189895"
+    kdl_password = "o0yefv6z"
+    proxy_url = f"http://{kdl_username}:{kdl_password}@{tunnel}/"
+    return {"http": proxy_url, "https": proxy_url}
+
+
+@retry(
+    stop=stop_after_attempt(5),
+    wait=wait_fixed(1),
+    after=after_log,
+    retry=retry_if_not_exception_type(InvalidStockMarketError),
+)
+def fetch_kline(log, secid: str, end: str = "20990101", lmt: int = 210, fqt: int = 1):
+    """
+    拉日K数据。fqt: 0=不复权, 1=前复权, 2=后复权
+
+    :param log: 日志对象
+    :param secid: 股票代码,例如 "0.300624"
+    :param end: 结束日期,格式 "YYYYMMDD"
+    :param lmt: 每次拉取的K线数量
+    :param fqt: 复权方式
+    :return: K线数据列表
+    """
+    log.info(f"Fetching kline data for {secid} from {end} with {lmt} bars and {fqt} dividend adjustment")
+    params = {
+        "secid": secid,  # 0.=深市, 1.=沪市
+        "ut": "fa5fd1943c7b386f172d6893dbfba10b",
+        "fields1": "f1,f2,f3,f4,f5,f6",
+        "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61",
+        "klt": 101,  # 101=日K, 102=周K, 103=月K, 5/15/30/60=分钟K
+        "fqt": fqt,
+        "end": end,
+        "lmt": lmt,
+        "cb": "cb",
+    }
+    impersonate = random.choice(client_identifier_list)
+
+    r = requests.get(KLINE_URL, impersonate=impersonate, params=params, timeout=10, headers=HEADERS,
+                     proxies=get_proxys())
+    r.raise_for_status()
+    matched = re.search(r"\((.*)\);?$", r.text.strip())
+    if not matched:
+        raise ValueError(f"{secid} 返回格式异常")
+
+    response_data = json.loads(matched.group(1))
+    data = response_data.get("data")
+    if data is None:
+        raise InvalidStockMarketError(f"{secid} 返回 data=null,可能市场前缀不匹配")
+
+    raw_klines = data.get("klines") or []
+    if not raw_klines:
+        raise InvalidStockMarketError(f"{secid} 没有返回 K 线数据")
+
+    klines = []
+    for line in raw_klines:
+        f = line.split(",")
+        klines.append({
+            "date": f[0],
+            "open": float(f[1]),
+            "close": float(f[2]),
+            "high": float(f[3]),
+            "low": float(f[4]),
+            "volume": float(f[5]),
+            "amount": float(f[6]),
+            "amp": float(f[7]),
+            "pct": float(f[8]),
+            "chg": float(f[9]),
+            "hsl": float(f[10]),  # 换手率 %
+        })
+    return data, klines
+
+
+def normalize_date(endTime: str) -> str:
+    """
+    把日期字符串归一化成东方财富接口要求的 YYYYMMDD 格式。
+
+    :param endTime: (str) 日期字符串,支持 "YYYY-MM-DD" 或 "YYYYMMDD"
+    :return: (str) 形如 "20260524" 的日期字符串
+    """
+    if '-' in endTime:
+        return endTime.replace('-', '')
+    return endTime
+
+
+def guess_market_prefix(stock_code: str) -> str:
+    """
+    根据股票代码首位判定东方财富 secid 的市场前缀。
+
+    规则:
+      沪市主板(60) / 科创板(68) / 沪市可转债(11) / 沪市国债逆回购(13) / 沪市基金&ETF(5) → "1."
+      深市主板(00) / 创业板(30) / 深市可转债(12) / 北交所(4x、8x) → "0."
+
+    :param stock_code: (str) 6 位股票代码字符串,例如 "688605"
+    :return: (str) "1." 或 "0."
+    """
+    code = str(stock_code).strip()
+    if code.startswith(("60", "68", "11", "13", "5")):
+        return "1."
+    return "0."
+
+
+def get_chip_distribution(log, stockCode, endTime):
+    """
+    计算筹码分布接口数据
+    :param log: 日志对象
+    :param stockCode: 股票代码
+    :param endTime: 结束时间
+    :return: 接口返回字典
+    """
+    log.info(f"Fetching data for stock {stockCode} on {endTime}")
+
+    end_time = normalize_date(endTime)
+    stock = guess_market_prefix(str(stockCode))
+    secid = stock + str(stockCode)
+    raw, klines = fetch_kline(log, secid, end=end_time, lmt=210, fqt=1)
+    res = CYQCalculator(klines, fator=150).calc(len(klines) - 1)
+
+    trading_day = klines[-1]["date"]  # 交易日
+    profit_ratio = f'{res["benefitPart"] * 100:.2f}%'  # 获利比例
+    average_cost = res["avgCost"]  # 平均成本
+    p90 = res["percentChips"][90]  # 90% 成本区间
+    p70 = res["percentChips"][70]  # 70% 成本区间
+
+    p90_cost_range = p90["priceRange"]  # 90% 成本区间
+    p90_concentration_ratio = f'{p90["concentration"] * 100:.2f}%'  # 90% 成本区间集中度
+
+    p70_cost_range = p70["priceRange"]  # 70% 成本区间
+    p70_concentration_ratio = f'{p70["concentration"] * 100:.2f}%'  # 70% 成本区间集中度
+
+    data_dict = {
+        "stock": stock,  # 股票前缀 0.=深市, 1.=沪市
+        "stock_code": str(stockCode),
+        "end_time": endTime,
+        "trading_day": trading_day,
+        "profit_ratio": profit_ratio,
+        "average_cost": average_cost,
+        "p90_cost_range": p90_cost_range,
+        "p90_concentration_ratio": p90_concentration_ratio,
+        "p70_cost_range": p70_cost_range,
+        "p70_concentration_ratio": p70_concentration_ratio,
+    }
+    return data_dict
+
+
+def main(log, stockCode, endTime):
+    data_dict = get_chip_distribution(log, stockCode, endTime)
+    print(data_dict)
+    return data_dict
+
+
+@app.get("/chip-distribution")
+def chip_distribution_get(
+        stockCode: str = Query(..., description="股票代码,例如 688605"),
+        endTime: str = Query(..., description="查询日期,支持 YYYY-MM-DD 或 YYYYMMDD"),
+):
+    try:
+        return success_response(get_chip_distribution(logger, stockCode, endTime))
+    except Exception as exc:
+        logger.exception(f"接口查询失败:{exc}")
+        return error_response(500, str(exc))
+
+
+@app.post("/chip-distribution")
+def chip_distribution_post(payload: ChipRequest):
+    try:
+        return success_response(get_chip_distribution(logger, payload.stockCode, payload.endTime))
+    except Exception as exc:
+        logger.exception(f"接口查询失败:{exc}")
+        return error_response(500, str(exc))
+
+
+if __name__ == "__main__":
+    start_time = time.time()
+    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+    print(f"\n{'=' * 60}\n[{now}] 开始本次查询\n{'=' * 60}")
+    try:
+        # main(logger, "688605", "2026-05-25")
+        main(logger, "300624", "2026-05-25")
+    except Exception as me:
+        # 单次失败(网络抖动 / 接口异常)不退出,等下一轮重试
+        logger.exception(f"本次查询失败:{me}")
+    e_time = time.time()
+    logger.info(f"本次查询耗时 {e_time - start_time:.2f} 秒")
+    """
+    传入
+    stockCode: 688605
+    endTime:2026-05-24 
+    """

+ 9 - 0
eastmoney_spider/requirements.txt

@@ -0,0 +1,9 @@
+fastapi==0.136.3
+uvicorn[standard]==0.44.0
+# curl_cffi: 用浏览器 TLS 指纹 (impersonate) 绕过东方财富 push2his 反爬
+# 0.15.1b1 是 pre-release, 安装时使用: pip install --pre curl_cffi==0.15.1b1
+# 如需稳定版本可改为 curl_cffi>=0.7.0
+curl_cffi==0.15.1b1
+pydantic==2.13.0
+loguru==0.7.3
+tenacity==9.1.4

+ 139 - 0
eastmoney_spider/接口文档.md

@@ -0,0 +1,139 @@
+# Eastmoney 筹码分布接口文档
+
+## 启动服务
+
+```bash
+pip install -r requirements.txt
+uvicorn eastmoney_spider:app --host 0.0.0.0 --port 8000
+```
+
+服务启动后,本机访问地址:
+
+```text
+http://127.0.0.1:8000
+```
+
+FastAPI 自动文档:
+
+```text
+http://127.0.0.1:8000/docs
+```
+
+## 接口一: GET 查询筹码分布
+
+```http
+GET /chip-distribution?stockCode=688605&endTime=2026-05-24
+```
+
+### 请求参数
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| stockCode | string | 是 | 股票代码,例如 `688605` |
+| endTime | string | 是 | 查询日期,支持 `YYYY-MM-DD` 或 `YYYYMMDD` |
+
+### curl 示例
+
+```bash
+curl "http://127.0.0.1:8000/chip-distribution?stockCode=688605&endTime=2026-05-24"
+```
+
+## 接口二: POST 查询筹码分布
+
+```http
+POST /chip-distribution
+Content-Type: application/json
+```
+
+### 请求体
+
+```json
+{
+  "stockCode": "688605",
+  "endTime": "2026-05-24"
+}
+```
+
+### curl 示例
+
+```bash
+curl -X POST "http://127.0.0.1:8000/chip-distribution" ^
+  -H "Content-Type: application/json" ^
+  -d "{\"stockCode\":\"688605\",\"endTime\":\"2026-05-24\"}"
+```
+
+## 返回示例
+
+```json
+{
+  "code": 0,
+  "message": "success",
+  "data": {
+    "stock": "1.",
+    "stock_code": "688605",
+    "end_time": "2026-05-24",
+    "trading_day": "2026-05-22",
+    "profit_ratio": "62.35%",
+    "average_cost": "39.81",
+    "p90_cost_range": ["31.20", "48.90"],
+    "p90_concentration_ratio": "22.09%",
+    "p70_cost_range": ["35.10", "45.30"],
+    "p70_concentration_ratio": "12.70%"
+  }
+}
+```
+
+外层字段说明:
+
+| 字段 | 说明 |
+| --- | --- |
+| code | 业务状态码,`0` 表示成功 |
+| message | 状态说明 |
+| data | 查询成功时为筹码分布数据,失败时为 `null` |
+
+data 字段说明:
+
+| 字段 | 说明 |
+| --- | --- |
+| stock | 实际使用的市场前缀,`0.` 为深市,`1.` 为沪市 |
+| stock_code | 股票代码 |
+| end_time | 请求传入的查询日期 |
+| trading_day | 实际使用的 K 线交易日 |
+| profit_ratio | 获利比例 |
+| average_cost | 平均成本 |
+| p90_cost_range | 90% 成本区间 |
+| p90_concentration_ratio | 90% 成本区间集中度 |
+| p70_cost_range | 70% 成本区间 |
+| p70_concentration_ratio | 70% 成本区间集中度 |
+
+## 市场前缀自动识别
+
+接口根据 `stockCode` 前两位自动判定东方财富 secid 的市场前缀,无需用户传入:
+
+| 股票代码前缀 | 市场 | secid 前缀 |
+| --- | --- | --- |
+| 60 | 沪市主板 | `1.` |
+| 68 | 科创板 | `1.` |
+| 11 | 沪市可转债 | `1.` |
+| 13 | 沪市国债逆回购 | `1.` |
+| 5x | 沪市基金 & ETF | `1.` |
+| 00 | 深市主板 | `0.` |
+| 30 | 创业板 | `0.` |
+| 12 | 深市可转债 | `0.` |
+| 4x / 8x | 北交所 | `0.` |
+
+## 重试与错误处理
+
+- **网络超时 / HTTP 异常 / 响应格式异常**:`fetch_kline` 会重试 5 次,每次间隔 1 秒。
+- **东方财富返回 `data:null`**(极少数情况,例如新规则代码段未覆盖):触发 `InvalidStockMarketError`,立即抛出,不重试,避免浪费 5 秒。
+- **反爬绕过**:底层使用 `curl_cffi` 随机轮换浏览器 TLS 指纹(Chrome / Safari / Edge 等),并挂快代理隧道换出口 IP。
+
+最终失败时接口返回:
+
+```json
+{
+  "code": 500,
+  "message": "失败原因",
+  "data": null
+}
+```

+ 235 - 0
eastmoney_spider/筹码分布逆向分析.md

@@ -0,0 +1,235 @@
+# 东方财富"筹码分布"逆向分析笔记
+
+> 目标页面:<https://quote.eastmoney.com/concept/sz300624.html?from=classic>
+> 抓取目标:页面右侧"筹码分布"图所表达的价格-持仓量数据
+> 完成日期:2026-05-22
+
+---
+
+## 一、问题与第一直觉的误区
+
+页面右侧的"筹码分布"是一个 **canvas** 渲染的柱状图。看到 canvas,最容易掉进两个坑:
+
+1. **误区 A**:去解析 canvas 像素,做图像识别 → 又慢又脆弱,是最坏的方案。
+2. **误区 B**:直接断言"它一定有一个 chips 接口",去 Network 面板搜 `chip` / `cyq` 关键字 → 抓不到任何东西就以为是接口被加密了。
+
+**正确认知**:canvas 只是渲染层。**绝大多数 canvas 图表的数据来自后端 JSON**,但也存在一种情况:**前端用更基础的数据(如 K 线)在本地算出图表所需的衍生数据**。这次的筹码分布就属于后者。
+
+---
+
+## 二、判定数据来源的通用三步法
+
+遇到任何不知来源的页面数据,按这个顺序定位:
+
+```
+1. 监听完整请求生命周期 → 是 XHR / Fetch / Script(JSONP) / WebSocket / EventSource?
+2. 找到触发点 → 哪个用户操作引起哪条请求?
+3. 都找不到匹配请求?→ 数据多半是前端本地算的,去 JS 源码里找算法
+```
+
+本次实战路径:
+
+| 阶段 | 操作 | 结论 |
+|---|---|---|
+| ① 用户初判 | 在 Network 面板按"筹码分布"按钮后搜 `chip` | 搜不到 → 误以为是普通 K 线接口 `kline/get` |
+| ② 工具复核 | 用 MCP `js-reverse` 打开页面,程序化点击"日K" + "筹码分布"按钮,对比点击前后的所有请求 | 发现点击筹码分布后**唯一新增的 script 请求仍是同一个 K 线接口** |
+| ③ 推论 | 既然没有专门接口,必是前端算的 | 去前端代码里找算法 |
+| ④ 定位算法 | 在 `quotechart2022.js` 里搜 `cyq`("筹码"拼音首字母)| 找到 `./src/modules/tools/indicator/cyq.ts` 里的 `CYQCalculator` 类 |
+
+> 关键命名线索:东方财富前端**用拼音首字母给模块命名**——筹码分布 = ChouMaYun → `cyq`、除权除息 = ChuQuanChuXi → `cqcx`。遇到中文产品名的接口/类,先试拼音首字母。
+
+---
+
+## 三、定位过程的关键证据
+
+### 证据 1:点击前后请求对比
+
+点击"筹码分布"按钮前后,所有 XHR/Fetch/Script 请求里**没有任何新的数据接口**,只新增了:
+
+- 两条埋点 `Web_Event.gif`(用户行为统计)
+- 一条与 K 线主图共用的 `push2his.eastmoney.com/api/qt/stock/kline/get`(点击触发了图表重绘)
+
+→ 没有专属接口 = 数据是前端算的。
+
+### 证据 2:DOM 容器命名
+
+```html
+<div class="quotechart2022_c_cyq">
+  <canvas width="270" height="577"></canvas>
+</div>
+```
+
+`cyq` 容器的存在直接坐实了模块归属。
+
+### 证据 3:源码模块路径
+
+`quotechart2022.js` 包含两个 cyq 相关 webpack 模块:
+
+- `./src/modules/kline/cyq.ts`(drawCYQ —— 画图)
+- `./src/modules/tools/indicator/cyq.ts`(CYQCalculator —— **算法**)
+
+源码大小适中(约 2KB),适合人肉逆向。
+
+---
+
+## 四、CYQCalculator 算法精解
+
+### 4.1 输入输出
+
+```
+输入:
+  klinedata : [{open, close, high, low, hsl}, ...]   K 线 + 换手率
+  fator     : 150                                    价格档位数 (默认)
+  range     : None | int                             滑窗根数 (None=全部)
+  e         : int                                    要算到第几根 K 线 (索引)
+
+输出:
+  x            : [w0, w1, ..., w149]                 各价位的筹码权重
+  y            : [p0, p1, ..., p149]                 对应的价格档
+  benefitPart  : float                               获利比例
+  avgCost      : "xx.xx"                             平均成本 (50% 分位价)
+  percentChips : { 90: {...}, 70: {...} }            成本集中区间
+```
+
+### 4.2 算法本质:三角分布 + 指数衰减
+
+筹码分布建模思路:
+
+1. **价格离散化**:把 [min(low), max(high)] 等分成 150 档。
+2. **每根 K 线视为一笔"新筹码"**:成交量按"三角分布"分摊到当日的价格区间——平均价处最密,向 high/low 两端线性递减。
+3. **换手率作为衰减因子**:当日换手率 `A = hsl/100`,意味着旧筹码被替换 A 比例。所以处理每根 K 线时:
+   - 旧筹码先衰减:`chips[*] *= (1 - A)`
+   - 新筹码再叠加:用三角形 PDF 把 `A` 单位的筹码摊进 [low, high] 区间。
+
+这个模型的合理性:**老股东持仓被换手稀释**,**新成交价格在均价附近最密集**——符合直觉。
+
+### 4.3 三角分布的 PDF 公式
+
+对当日 K 线(low=L, high=H, mid=M=(O+C+H+L)/4),在价格 x 处的权重:
+
+```
+峰值     y₀ = 2 / (H - L)        (面积归一化为 1)
+左半区:  x ∈ [L, M]              w(x) = (x - L) / (M - L) · y₀
+右半区:  x ∈ (M, H]              w(x) = (H - x) / (H - M) · y₀
+其他:                            w(x) = 0
+```
+
+最终再乘以本日换手比例 `A`,叠加到对应价格档位。
+
+### 4.4 衍生指标
+
+- **获利比例** `benefitPart`:当前 close 之下的累计筹码占比(即"在当前价位之下买入的人都赚了")。
+- **平均成本** `avgCost`:累计筹码到 50% 时对应的价格(中位数成本)。
+- **N% 成本区间** `percentChips[N]`:累计筹码从 `(1-N)/2` 到 `(1+N)/2` 区间对应的价格段——例如 90% 意味着丢掉最高 5% 和最低 5% 的极端档位。
+- **集中度** `concentration = (high - low) / (high + low)`:区间相对宽度,**越小代表筹码越集中**。
+
+---
+
+## 五、Python 复刻关键细节
+
+### 5.1 接口参数
+
+```python
+url = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
+params = dict(
+    secid   = "0.300624",                                # 0.=深市, 1.=沪市
+    ut      = "fa5fd1943c7b386f172d6893dbfba10b",        # 通用 token,无需动态获取
+    fields1 = "f1,f2,f3,f4,f5,f6",
+    fields2 = "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61",
+    klt     = 101,        # 101=日K, 102=周K, 103=月K, 5/15/30/60=分钟K
+    fqt     = 1,          # 0=不复权, 1=前复权, 2=后复权 (网页默认前复权)
+    end     = "20990101",
+    lmt     = 210,        # 网页用 210 根
+    cb      = "cb",       # JSONP 回调名占位
+)
+```
+
+### 5.2 K 线返回字段映射 (fields2)
+
+| 字段 | 含义 | 备注 |
+|---|---|---|
+| f51 | 日期 | `YYYY-MM-DD` 字符串 |
+| f52 | 开盘 | |
+| f53 | 收盘 | |
+| f54 | 最高 | |
+| f55 | 最低 | |
+| f56 | 成交量 | 单位:手 |
+| f57 | 成交额 | |
+| f58 | 振幅 | % |
+| f59 | 涨跌幅 | % |
+| f60 | 涨跌额 | |
+| f61 | **换手率** | **筹码算法必需** % |
+
+数据格式:`data.klines` 是字符串数组,每行用 `,` 拼接,按 `fields2` 顺序对应。
+
+### 5.3 JSONP 剥壳
+
+```python
+import re, json, requests
+r = requests.get(url, params=params, timeout=10)
+data = json.loads(re.search(r"\((.*)\);?$", r.text.strip()).group(1))["data"]
+```
+
+### 5.4 算法移植注意点
+
+1. **`a/b||0` 这种 JS 习惯写法** —— Python 翻译时要加空值兜底:`(d["hsl"] or 0) / 100`。
+2. **`toPrecision(12)/1`** —— JS 用来截断浮点误差,Python 用普通浮点累加误差也小到可忽略,无需特意处理。
+3. **`.toFixed(2)`** 返回字符串 —— Python 用 `f"{x:.2f}"` 对齐,**别用 `round()`**(类型不一致会害下游对比)。
+4. **`Math.floor / Math.ceil`** —— 直接 `math.floor / math.ceil`。
+5. **边界处理** —— `if abs(m - low) < 1e-8` 这种是避免除零,必须保留。
+
+---
+
+## 六、用 MCP js-reverse 做浏览器逆向的实战姿势
+
+这次实战用到的关键能力:
+
+| 工具 | 用途 |
+|---|---|
+| `new_page` / `select_page` | 控浏览器开页面 |
+| `evaluate_script` | 程序化点击、读 DOM、查 window 全局变量 (注意 `mainWorld:true` 才能拿到页面真实 JS 上下文) |
+| `list_network_requests` | 关键。可按 `resourceTypes` 过滤、按 `urlFilter` 子串过滤 |
+| `search_in_sources` | 全量 JS 源码搜关键字。**逆向利器** —— 用拼音首字母搜中文功能模块 |
+| `get_script_source` | 取指定行/字节范围的源码(minified 文件用 `offset/length`) |
+
+### 标准逆向流程模板
+
+```
+1. new_page → 打开目标
+2. evaluate_script (DOM/click) → 触发目标功能
+3. list_network_requests → 找触发后新增的请求
+   ├─ 找到了 → 复刻接口参数即可
+   └─ 找不到 → 进入第 4 步
+4. search_in_sources → 用功能关键字 (拼音首字母 / 英文 / 业务名) 搜源码
+5. get_script_source → 读出算法
+6. 把算法移植到 Python / 其他语言
+```
+
+---
+
+## 七、本案例可复用的通用经验
+
+1. **canvas ≠ 数据被加密**。先排查请求层,再排查 JS 计算层,最后才考虑像素解析。
+2. **"点击触发"功能**:一定要程序化对比点击前后的请求差,肉眼看 Network 面板容易漏。
+3. **中文产品名的逆向**:试拼音首字母(cyq / cqcx / hsl / jzlx ...)。
+4. **JSONP 接口**:是 `script` 类型而不是 `xhr`,按 resourceType 过滤时别漏。
+5. **东方财富的字段编号体系**(f1~f9xx)有公开整理资料,遇到陌生字段先查表再猜。
+6. **`ut` 参数** `fa5fd1943c7b386f172d6893dbfba10b` 是东方财富一个长期通用的访问 token,可以直接硬编码。
+
+---
+
+## 八、文件清单
+
+- `eastmoney_spider.py` —— 抓取脚本(含完整算法移植)
+- `筹码分布逆向分析.md` —— 本文档
+
+## 九、验证结果
+
+| 指标 | 截图(2026-05-21 盘中) | 本脚本(2026-05-22 收盘后) |
+|---|---|---|
+| 平均成本 | 69.05 | 68.64 |
+| 获利比例 | 0.18% | 0.73% |
+| 价格区间 | 61.16 ~ 125.17 | 与之极接近 |
+| 主峰位置 | 70 元附近 | 67~68 元(权重最高 15 档全在此区间) |
+
+差异在合理范围(差一日 + 盘中/收盘价差),**算法移植正确**。