找回密码
 立即注册

QQ登录

只需一步,快速开始

搜索
热搜: 股票 资源 源码
查看: 65|回复: 4

行业+概念+地域,972个板块资金流向一键抓取,主力跑不掉了</span>

 火... [复制链接]

1570

主题

97

回帖

5万

积分

管理员

积分
51505
发表于 昨天 14:44 | 显示全部楼层 |阅读模式



你有没有想过一个问题:
每天几千亿资金在A股里翻江倒海,
主力到底把钱往哪个板块搬?
你不知道。
我以前也不知道。
靠感觉?靠新闻?靠股评大V的嘴?
别逗了。那些东西,等你看到的时候,主力已经吃完肉走人了。
所以我最近干了一件事——
自己写了个脚本,每天自动抓板块资金流向。
主力往哪跑,我就往哪看。
不听废话,只看真金白银。
翻了一圈,BD股市通的接口数据还挺硬。
三类板块,全部覆盖,一个不漏:
[size=1em]行业板块:162个
通信设备、光伏设备、电力设备、电池……
[size=1em]概念板块:779个
太阳能、固态电池、光通信、CPO……
[size=1em]地域板块:31个
四川、广东、北京、上海……
加起来,接近1000个板块的实时资金流数据
别人还在猜"今天什么板块强",
你已经拿到了全市场的资金流向地图。
不搞花里胡哨的,就五个核心字段,刀刀见肉:
字段
说明

板块名称
哪个板块

主力净流入
大资金是进还是出

主力流入
进了多少

主力流出
跑了多少

总成交额
整体活跃度
净流入为正,说明主力在抢筹。
净流入为负,说明主力在跑路。
就这么简单粗暴。

直接上运行效果,你感受一下:# 板块资金流向

> 数据来源: 百度股市通 | 更新时间: 2026-04-18 13:53:16

## 数据总结

| 板块类型 | 数据条数 |
|:---|---:|
| 行业板块 | 100 |
| 概念板块 | 100 |
| 地域板块 | 31 |
| **合计** | **231** |


## 行业板块

| 排名 | 板块名称 | 主力净流入 | 主力流入 | 主力流出 | 总成交额 |
|:---:|:---|---:|---:|---:|---:|
| 1 | 电子 | 4568031232.0 |  |  | 508255912861.0 |
| 2 | 印制电路板 | 2802407680.0 |  |  | 98306866640.0 |
| 3 | 元件 | 2729628160.0 |  |  | 108872054646.0 |
| 4 | 通信设备 | 2528729600.0 |  |  | 192584076185.0 |
| 5 | 通信网络设备及器件 | 2486640640.0 |  |  | 136604872345.0 |
| 6 | 光学光电子 | 1961294848.0 |  |  | 57224468017.0 |
| 7 | LED | 1516533808.0 |  |  | 17313994870.0 |
   ...
[color=hsl(var(--foreground))]一跑,全市场的主力动向,摊在你面前。
谁在吸筹,谁在出货,一目了然。
输出两种格式:JSON + Markdown
想二次分析就用JSON,想直接看报告就用Markdown。

另外我也定制了一个GUI界面,方便不懂的朋友们自行下载。 3583afa01cb4467c45a8adff987bb8c6.png 40dba1d92c2070b6f882b70719e24149.png f8c7a63a8c306385e4a1535ac0acbfe1.png

做交易这行,信息差就是命。
别人还在刷论坛看小作文猜主力意图的时候,
你已经拿到了全市场近1000个板块的资金流向数据。
主力用真金白银投的票,比任何分析师的嘴都靠谱。
钱往哪流,机会就在哪。
钱从哪跑,风险就在哪。
这个脚本不复杂,但它能帮你做到一件事:
每天花30秒,看清主力的底牌。
剩下的,交给你自己的判断和纪律。
工具我给你了,用不用,看你。

游客,如果您要查看本帖隐藏内容请回复

  1. import json
  2. import os
  3. import sys
  4. import time
  5. from concurrent.futures import ThreadPoolExecutor, as_completed
  6. from datetime import datetime

  7. import requests
  8. from PySide6.QtCore import Qt, QThread, Signal
  9. from PySide6.QtGui import QColor, QFont, QPainter, QPen
  10. from PySide6.QtWidgets import (
  11.     QApplication,
  12.     QCheckBox,
  13.     QComboBox,
  14.     QFileDialog,
  15.     QFrame,
  16.     QHBoxLayout,
  17.     QLabel,
  18.     QLineEdit,
  19.     QMainWindow,
  20.     QMessageBox,
  21.     QPushButton,
  22.     QProgressBar,
  23.     QScrollArea,
  24.     QSpinBox,
  25.     QSplitter,
  26.     QTabWidget,
  27.     QTableWidget,
  28.     QTableWidgetItem,
  29.     QTextEdit,
  30.     QVBoxLayout,
  31.     QWidget,
  32.     QHeaderView,
  33. )

  34. # ══════════════════════════════════════════════════════════════════
  35. # 数据层
  36. # ══════════════════════════════════════════════════════════════════

  37. API_URL = "https://finance.pae.baidu.com/sapi/v1/ranks"
  38. EASTMONEY_API_URL = "https://push2.eastmoney.com/api/qt/clist/get"

  39. BLOCK_TYPES = {
  40.     "行业": "HY",
  41.     "概念": "GN",
  42.     "地域": "DY",
  43. }

  44. HEADERS = {
  45.     "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
  46.                   "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
  47.     "Referer": "https://gushitong.baidu.com/",
  48.     "Origin": "https://gushitong.baidu.com",
  49.     "Accept": "application/json, text/plain, */*",
  50.     "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
  51.     "Connection": "keep-alive",
  52. }

  53. EASTMONEY_BLOCK_FS = {
  54.     "HY": "m:90+t:2+f:!50",
  55.     "GN": "m:90+t:3+f:!50",
  56.     "DY": "m:90+t:1+f:!50",
  57. }


  58. def _build_session():
  59.     session = requests.Session()
  60.     session.headers.update(HEADERS)
  61.     return session


  62. def _warmup_session(session):
  63.     try:
  64.         session.get("https://gushitong.baidu.com/", timeout=10)
  65.     except requests.RequestException:
  66.         pass


  67. SESSION = _build_session()


  68. def _to_str(v):
  69.     if v is None or v == "-":
  70.         return ""
  71.     return str(v)


  72. def _fetch_from_eastmoney(block_type="HY", page=0, page_size=500, timeout_seconds=10):
  73.     fs = EASTMONEY_BLOCK_FS.get(block_type)
  74.     if not fs:
  75.         return []
  76.     em_params = {
  77.         "pn": page // page_size + 1,
  78.         "pz": page_size,
  79.         "po": 1,
  80.         "np": 1,
  81.         "fltt": 2,
  82.         "invt": 2,
  83.         "fid": "f62",
  84.         "fs": fs,
  85.         "fields": "f14,f62,f6",
  86.         "ut": "b2884a393a59ad64002292a3e90d46a5",
  87.     }
  88.     try:
  89.         em_resp = SESSION.get(EASTMONEY_API_URL, params=em_params, timeout=timeout_seconds)
  90.         em_resp.raise_for_status()
  91.         diff = em_resp.json().get("data", {}).get("diff", [])
  92.         return [
  93.             {
  94.                 "name": _to_str(row.get("f14")),
  95.                 "mainNetIn": _to_str(row.get("f62")),
  96.                 "mainTotalIn": "",
  97.                 "mainTotalOut": "",
  98.                 "totalAmount": _to_str(row.get("f6")),
  99.             }
  100.             for row in diff
  101.         ]
  102.     except requests.RequestException as e:
  103.         print(f"[ERROR] 东方财富回退失败: {e}")
  104.         return []


  105. def fetch_sector_fundflow(
  106.     block_type="HY",
  107.     page=0,
  108.     page_size=500,
  109.     sort_key="",
  110.     sort_type="",
  111.     data_source="auto",
  112.     timeout_seconds=10,
  113.     retry_count=1,
  114. ):
  115.     data_source = (data_source or "auto").lower()
  116.     if data_source == "eastmoney":
  117.         return _fetch_from_eastmoney(
  118.             block_type=block_type, page=page,
  119.             page_size=page_size, timeout_seconds=timeout_seconds,
  120.         )

  121.     params = {
  122.         "bizType": "fundflow_rank",
  123.         "style": "tablelist",
  124.         "fundflowRankTarget": "block",
  125.         "market": "ab",
  126.         "blockType": block_type,
  127.         "pn": page,
  128.         "rn": page_size,
  129.         "sortKey": sort_key,
  130.         "sortType": sort_type,
  131.         "finClientType": "pc",
  132.         "_": int(time.time() * 1000),
  133.     }

  134.     attempts = max(1, int(retry_count) + 1)
  135.     for i in range(attempts):
  136.         try:
  137.             resp = SESSION.get(API_URL, params=params, timeout=timeout_seconds)
  138.             if resp.status_code == 403:
  139.                 _warmup_session(SESSION)
  140.                 if i < attempts - 1:
  141.                     time.sleep(0.2)
  142.                     continue
  143.                 if data_source == "auto":
  144.                     print(f"[WARN] 百度403,切换东方财富: block_type={block_type}")
  145.                     return _fetch_from_eastmoney(
  146.                         block_type=block_type, page=page,
  147.                         page_size=page_size, timeout_seconds=timeout_seconds,
  148.                     )
  149.                 return []
  150.             resp.raise_for_status()
  151.             data = resp.json()
  152.             if data.get("ResultCode") != 0:
  153.                 return []
  154.             return data.get("Result", {}).get("list", {}).get("body", [])
  155.         except requests.RequestException as e:
  156.             if i < attempts - 1:
  157.                 time.sleep(0.2)
  158.                 continue
  159.             if data_source == "auto":
  160.                 print(f"[WARN] 百度异常,切换东方财富: {e}")
  161.                 return _fetch_from_eastmoney(
  162.                     block_type=block_type, page=page,
  163.                     page_size=page_size, timeout_seconds=timeout_seconds,
  164.                 )
  165.             return []
  166.     return []


  167. def fetch_all_pages(
  168.     block_type="HY",
  169.     page_size=500,
  170.     sort_key="",
  171.     sort_type="",
  172.     data_source="auto",
  173.     timeout_seconds=10,
  174.     retry_count=1,
  175. ):
  176.     all_items = []
  177.     offset = 0
  178.     while True:
  179.         batch = fetch_sector_fundflow(
  180.             block_type=block_type, page=offset, page_size=page_size,
  181.             sort_key=sort_key, sort_type=sort_type, data_source=data_source,
  182.             timeout_seconds=timeout_seconds, retry_count=retry_count,
  183.         )
  184.         if not batch:
  185.             break
  186.         all_items.extend(batch)
  187.         if len(batch) < page_size:
  188.             break
  189.         offset += page_size
  190.     return all_items


  191. def fetch_all_sectors(
  192.     page_size=500,
  193.     sort_key="",
  194.     sort_type="",
  195.     data_source="auto",
  196.     timeout_seconds=10,
  197.     retry_count=1,
  198.     concurrent_fetch=False,
  199.     max_workers=3,
  200.     progress_callback=None,
  201. ):
  202.     result = {}
  203.     total = len(BLOCK_TYPES)

  204.     def _fetch(name, code):
  205.         return name, fetch_all_pages(
  206.             block_type=code, page_size=page_size, sort_key=sort_key,
  207.             sort_type=sort_type, data_source=data_source,
  208.             timeout_seconds=timeout_seconds, retry_count=retry_count,
  209.         )

  210.     if concurrent_fetch:
  211.         workers = max(1, min(int(max_workers), total))
  212.         with ThreadPoolExecutor(max_workers=workers) as executor:
  213.             futures = {
  214.                 executor.submit(_fetch, name, code): name
  215.                 for name, code in BLOCK_TYPES.items()
  216.             }
  217.             completed = 0
  218.             for future in as_completed(futures):
  219.                 name, items = future.result()
  220.                 result[name] = items
  221.                 completed += 1
  222.                 if progress_callback:
  223.                     progress_callback(completed, total, name, len(items))
  224.     else:
  225.         completed = 0
  226.         for name, code in BLOCK_TYPES.items():
  227.             _, items = _fetch(name, code)
  228.             result[name] = items
  229.             completed += 1
  230.             if progress_callback:
  231.                 progress_callback(completed, total, name, len(items))

  232.     return {name: result.get(name, []) for name in BLOCK_TYPES}


  233. def save_to_json(data, filename=None):
  234.     if filename is None:
  235.         filename = f"sector_fundflow_{datetime.now().strftime('%Y%m%d')}.json"
  236.     with open(filename, "w", encoding="utf-8") as f:
  237.         json.dump(data, f, ensure_ascii=False, indent=2)
  238.     return filename


  239. def save_to_markdown(data, filename=None, source_name="自动(百度优先,失败回退东方财富)"):
  240.     if filename is None:
  241.         filename = f"sector_fundflow_{datetime.now().strftime('%Y%m%d')}.md"
  242.     now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  243.     label_map = {"行业": "行业板块", "概念": "概念板块", "地域": "地域板块"}
  244.     lines = [
  245.         f"# 板块资金流向\n",
  246.         f"> 数据来源: {source_name} | 更新时间: {now}\n",
  247.         "## 数据总结\n",
  248.         "| 板块类型 | 数据条数 |",
  249.         "|:---|---:|",
  250.     ]
  251.     total = 0
  252.     for cat, items in data.items():
  253.         lines.append(f"| {label_map.get(cat, cat)} | {len(items)} |")
  254.         total += len(items)
  255.     lines.append(f"| **合计** | **{total}** |\n")
  256.     for cat, items in data.items():
  257.         lines.append(f"\n## {label_map.get(cat, cat)}\n")
  258.         lines.append("| 排名 | 板块名称 | 主力净流入 | 主力流入 | 主力流出 | 总成交额 |")
  259.         lines.append("|:---:|:---|---:|---:|---:|---:|")
  260.         for i, item in enumerate(items, 1):
  261.             lines.append(
  262.                 f"| {i} | {item.get('name','')} | {item.get('mainNetIn','')} | "
  263.                 f"{item.get('mainTotalIn','')} | {item.get('mainTotalOut','')} | {item.get('totalAmount','')} |"
  264.             )
  265.         lines.append("")
  266.     with open(filename, "w", encoding="utf-8") as f:
  267.         f.write("\n".join(lines))
  268.     return filename


  269. # ══════════════════════════════════════════════════════════════════
  270. # GUI 层
  271. # ══════════════════════════════════════════════════════════════════

  272. _CONFIG_FILENAME = "fundflow_gui_config.json"

  273. _DEFAULT_CONFIG = {
  274.     "run_mode": {
  275.         "default": "all_sectors",
  276.         "options": [
  277.             {"label": "抓取全部板块", "value": "all_sectors"},
  278.             {"label": "仅抓取单一板块类型", "value": "single_block_type"},
  279.         ],
  280.     },
  281.     "fields": [
  282.         {
  283.             "key": "block_type", "label": "板块类型", "type": "select", "default": "HY",
  284.             "options": [
  285.                 {"label": "行业", "value": "HY"},
  286.                 {"label": "概念", "value": "GN"},
  287.                 {"label": "地域", "value": "DY"},
  288.             ],
  289.             "visible_when": {"run_mode": "single_block_type"},
  290.         },
  291.         {"key": "page_size",        "label": "每页条数",      "type": "int",  "default": 500, "min": 1,   "max": 500},
  292.         {"key": "timeout_seconds",  "label": "请求超时(秒)",  "type": "int",  "default": 10,  "min": 3,   "max": 60},
  293.         {"key": "retry_count",      "label": "重试次数",      "type": "int",  "default": 1,   "min": 0,   "max": 5},
  294.         {"key": "concurrent_fetch", "label": "启用并发抓取",  "type": "bool", "default": True,
  295.          "visible_when": {"run_mode": "all_sectors"}},
  296.         {"key": "max_workers",      "label": "并发线程数",    "type": "int",  "default": 3,   "min": 1,   "max": 8,
  297.          "visible_when": {"run_mode": "all_sectors"}},
  298.         {"key": "save_json",        "label": "输出 JSON",     "type": "bool", "default": True},
  299.         {"key": "save_markdown",    "label": "输出 Markdown", "type": "bool", "default": True},
  300.         {"key": "output_prefix",    "label": "输出文件前缀",  "type": "text", "default": "date"},
  301.         {"key": "output_dir",       "label": "输出目录",      "type": "text", "default": "."},
  302.     ],
  303. }


  304. def _find_config_path():
  305.     start = os.path.dirname(os.path.abspath(__file__))
  306.     current = start
  307.     for _ in range(6):
  308.         candidate = os.path.join(current, _CONFIG_FILENAME)
  309.         if os.path.exists(candidate):
  310.             return candidate
  311.         parent = os.path.dirname(current)
  312.         if parent == current:
  313.             break
  314.         current = parent
  315.     return os.path.join(start, _CONFIG_FILENAME)


  316. def _fmt_yuan(value):
  317.     try:
  318.         v = float(value)
  319.     except (TypeError, ValueError):
  320.         return str(value) if value else ""
  321.     sign = "-" if v < 0 else "+"
  322.     abs_v = abs(v)
  323.     if abs_v >= 1e8:
  324.         return f"{sign}{abs_v / 1e8:.2f}亿"
  325.     if abs_v >= 1e4:
  326.         return f"{sign}{abs_v / 1e4:.0f}万"
  327.     return f"{sign}{abs_v:.0f}"


  328. class BarChartWidget(QWidget):
  329.     def __init__(self, parent=None):
  330.         super().__init__(parent)
  331.         self.rows = []
  332.         self.mode = "top_in"
  333.         self.setMinimumHeight(240)

  334.     def set_rows(self, rows):
  335.         self.rows = rows or []
  336.         self.update()

  337.     def set_mode(self, mode):
  338.         self.mode = mode
  339.         self.update()

  340.     @staticmethod
  341.     def _to_float(value):
  342.         try:
  343.             return float(value)
  344.         except (TypeError, ValueError):
  345.             return 0.0

  346.     def paintEvent(self, _event):
  347.         painter = QPainter(self)
  348.         painter.setRenderHint(QPainter.Antialiasing)
  349.         painter.fillRect(self.rect(), QColor("#f8fafc"))

  350.         if not self.rows:
  351.             painter.setPen(QPen(QColor("#94a3b8")))
  352.             painter.setFont(QFont("", 12))
  353.             painter.drawText(self.rect(), Qt.AlignCenter, "暂无可视化数据")
  354.             return

  355.         if self.mode == "top_out":
  356.             top_rows = sorted(self.rows, key=lambda x: self._to_float(x.get("mainNetIn")))[:15]
  357.         else:
  358.             top_rows = sorted(self.rows, key=lambda x: self._to_float(x.get("mainNetIn")), reverse=True)[:15]

  359.         values = [self._to_float(x.get("mainNetIn")) for x in top_rows]
  360.         max_abs = max([abs(v) for v in values] + [1.0])

  361.         left = 108
  362.         right = 88
  363.         top_pad = 10
  364.         bottom_pad = 10
  365.         plot_w = max(80, self.width() - left - right)
  366.         plot_h = max(100, self.height() - top_pad - bottom_pad)
  367.         row_h = max(15, plot_h // max(1, len(top_rows)))

  368.         painter.setPen(QPen(QColor("#e2e8f0"), 1))
  369.         painter.drawLine(left, top_pad, left, top_pad + len(top_rows) * row_h)

  370.         name_font = QFont("", 11)
  371.         val_font = QFont("", 10)

  372.         for i, row in enumerate(top_rows):
  373.             y = top_pad + i * row_h
  374.             bg = QColor("#f1f5f9") if i % 2 == 0 else QColor("#f8fafc")
  375.             painter.fillRect(0, y, self.width(), row_h, bg)

  376.             name = str(row.get("name", ""))
  377.             value = self._to_float(row.get("mainNetIn"))
  378.             ratio = abs(value) / max_abs
  379.             bar_w = max(2, int(plot_w * ratio * 0.88))

  380.             color = QColor("#16a34a") if value >= 0 else QColor("#dc2626")
  381.             painter.fillRect(left + 3, y + 3, bar_w, row_h - 6, color)

  382.             painter.setFont(name_font)
  383.             painter.setPen(QPen(QColor("#374151")))
  384.             painter.drawText(4, y, left - 8, row_h, Qt.AlignVCenter | Qt.AlignRight, name)

  385.             painter.setFont(val_font)
  386.             painter.setPen(QPen(QColor("#111827") if value >= 0 else QColor("#991b1b")))
  387.             painter.drawText(left + bar_w + 6, y, right - 6, row_h,
  388.                              Qt.AlignVCenter | Qt.AlignLeft, _fmt_yuan(value))


  389. class FetchWorker(QThread):
  390.     log_signal = Signal(str)
  391.     progress_signal = Signal(int, str)
  392.     result_signal = Signal(dict)
  393.     error_signal = Signal(str)

  394.     def __init__(self, params):
  395.         super().__init__()
  396.         self.params = params

  397.     @staticmethod
  398.     def _source_label(data_source):
  399.         mapping = {
  400.             "auto": "自动(百度优先,失败回退东方财富)",
  401.             "baidu": "百度股市通",
  402.             "eastmoney": "东方财富",
  403.         }
  404.         return mapping.get((data_source or "auto").lower(), str(data_source))

  405.     def run(self):
  406.         try:
  407.             run_mode = self.params.get("run_mode", "all_sectors")
  408.             self.log_signal.emit(f"[INFO] 启动任务: run_mode={run_mode}")

  409.             if run_mode == "single_block_type":
  410.                 block_type = self.params.get("block_type", "HY")
  411.                 category = next((k for k, v in BLOCK_TYPES.items() if v == block_type), block_type)
  412.                 rows = fetch_all_pages(
  413.                     block_type=block_type,
  414.                     page_size=self.params.get("page_size", 500),
  415.                     sort_key=self.params.get("sort_key", ""),
  416.                     sort_type=self.params.get("sort_type", ""),
  417.                     data_source=self.params.get("data_source", "auto"),
  418.                     timeout_seconds=self.params.get("timeout_seconds", 10),
  419.                     retry_count=self.params.get("retry_count", 1),
  420.                 )
  421.                 all_data = {category: rows}
  422.                 self.progress_signal.emit(100, f"{category}: {len(rows)} 条")
  423.             else:
  424.                 def on_progress(done, total, name, count):
  425.                     percent = int(done * 100 / max(1, total))
  426.                     self.progress_signal.emit(percent, f"{name}: {count} 条")

  427.                 all_data = fetch_all_sectors(
  428.                     page_size=self.params.get("page_size", 500),
  429.                     sort_key=self.params.get("sort_key", ""),
  430.                     sort_type=self.params.get("sort_type", ""),
  431.                     data_source=self.params.get("data_source", "auto"),
  432.                     timeout_seconds=self.params.get("timeout_seconds", 10),
  433.                     retry_count=self.params.get("retry_count", 1),
  434.                     concurrent_fetch=self.params.get("concurrent_fetch", True),
  435.                     max_workers=self.params.get("max_workers", 3),
  436.                     progress_callback=on_progress,
  437.                 )

  438.             output_dir = self.params.get("output_dir", ".")
  439.             output_prefix = self.params.get("output_prefix", "date")
  440.             os.makedirs(output_dir, exist_ok=True)
  441.             today = datetime.now().strftime("%Y%m%d")
  442.             json_file = os.path.join(output_dir, f"{output_prefix}_{today}.json")
  443.             md_file = os.path.join(output_dir, f"{output_prefix}_{today}.md")

  444.             saved_json = ""
  445.             saved_md = ""
  446.             if self.params.get("save_json", True):
  447.                 saved_json = save_to_json(all_data, filename=json_file)
  448.             if self.params.get("save_markdown", True):
  449.                 saved_md = save_to_markdown(
  450.                     all_data,
  451.                     filename=md_file,
  452.                     source_name=self._source_label(self.params.get("data_source", "auto")),
  453.                 )

  454.             rows = []
  455.             for category, items in all_data.items():
  456.                 for item in items:
  457.                     merged = dict(item)
  458.                     merged["category"] = category
  459.                     rows.append(merged)

  460.             rows.sort(key=lambda x: self._to_float(x.get("mainNetIn")), reverse=True)
  461.             total_count = len(rows)
  462.             self.log_signal.emit(f"[DONE] 抓取完成,总计 {total_count} 条")
  463.             if saved_json:
  464.                 self.log_signal.emit(f"[FILE] JSON: {saved_json}")
  465.             if saved_md:
  466.                 self.log_signal.emit(f"[FILE] Markdown: {saved_md}")

  467.             self.result_signal.emit(
  468.                 {
  469.                     "rows": rows,
  470.                     "summary": all_data,
  471.                     "json_file": saved_json,
  472.                     "markdown_file": saved_md,
  473.                     "total_count": total_count,
  474.                 }
  475.             )
  476.         except Exception as exc:
  477.             self.error_signal.emit(str(exc))

  478.     @staticmethod
  479.     def _to_float(value):
  480.         try:
  481.             return float(value)
  482.         except (TypeError, ValueError):
  483.             return 0.0


  484. class FundflowMainWindow(QMainWindow):
  485.     def __init__(self):
  486.         super().__init__()
  487.         self.setWindowTitle("板块资金流向监控")
  488.         self.resize(1300, 820)

  489.         self.config_path = _find_config_path()
  490.         self.config = self._load_config(self.config_path)
  491.         self.field_widgets = {}
  492.         self.field_metas = {}
  493.         self.row_widgets = {}
  494.         self.worker = None
  495.         self.all_rows = []

  496.         self._build_ui()
  497.         self._apply_styles()
  498.         self._refresh_field_visibility()

  499.     def _apply_styles(self):
  500.         self.setStyleSheet("""
  501.             QWidget { font-size: 12px; color: #1f2937; }
  502.             QMainWindow, #leftPanel { background: #f1f5f9; }
  503.             QScrollArea, QTabWidget::pane, QTextEdit, QTableWidget {
  504.                 background: #ffffff; border: 1px solid #e2e8f0; border-radius: 6px;
  505.             }
  506.             QLineEdit, QComboBox, QSpinBox {
  507.                 background: #ffffff; border: 1px solid #cbd5e1;
  508.                 border-radius: 4px; padding: 3px 6px; min-height: 22px;
  509.             }
  510.             QPushButton#runBtn {
  511.                 background: #0f172a; color: #fff; border: none;
  512.                 border-radius: 5px; padding: 6px 14px; font-weight: 600;
  513.             }
  514.             QPushButton#runBtn:hover { background: #1e293b; }
  515.             QPushButton#runBtn:disabled { background: #94a3b8; }
  516.             QPushButton#secBtn {
  517.                 background: #e2e8f0; color: #374151; border: none;
  518.                 border-radius: 5px; padding: 5px 8px;
  519.             }
  520.             QPushButton#secBtn:hover { background: #cbd5e1; }
  521.             QPushButton#toggleBtn {
  522.                 background: #e2e8f0; color: #374151; border: none;
  523.                 border-radius: 4px; padding: 3px 10px;
  524.             }
  525.             QPushButton#toggleBtn:checked { background: #0f172a; color: #fff; }
  526.             QPushButton#toggleBtn:hover { background: #cbd5e1; }
  527.             QProgressBar {
  528.                 border: 1px solid #e2e8f0; border-radius: 4px;
  529.                 background: #fff; text-align: center; max-height: 14px;
  530.             }
  531.             QProgressBar::chunk { background: #0ea5e9; border-radius: 4px; }
  532.             QHeaderView::section {
  533.                 background: #f8fafc; border: none;
  534.                 border-bottom: 1px solid #e2e8f0; padding: 4px 8px; font-weight: 600;
  535.             }
  536.             QTabBar::tab { padding: 4px 14px; border: none; border-bottom: 2px solid transparent; color: #64748b; }
  537.             QTabBar::tab:selected { color: #0f172a; border-bottom: 2px solid #0f172a; }
  538.             QSplitter::handle { background: #e2e8f0; width: 1px; }
  539.         """)

  540.     def _load_config(self, path):
  541.         if path and os.path.exists(path):
  542.             with open(path, "r", encoding="utf-8") as f:
  543.                 return json.load(f)
  544.         return _DEFAULT_CONFIG

  545.     def _build_ui(self):
  546.         root = QWidget()
  547.         root_layout = QVBoxLayout(root)
  548.         root_layout.setContentsMargins(0, 0, 0, 0)
  549.         root_layout.setSpacing(0)

  550.         # ── header bar ──────────────────────────────
  551.         header = QWidget()
  552.         header.setFixedHeight(40)
  553.         header.setStyleSheet("background:#0f172a;")
  554.         h_layout = QHBoxLayout(header)
  555.         h_layout.setContentsMargins(14, 0, 14, 0)
  556.         h_layout.setSpacing(10)
  557.         h_title = QLabel("板块资金流向监控面板")
  558.         h_title.setStyleSheet("color:#f8fafc; font-size:14px; font-weight:600;")
  559.         h_sub = QLabel("参数化抓取 · 分类筛选 · 可视化分析")
  560.         h_sub.setStyleSheet("color:#94a3b8; font-size:11px;")
  561.         h_layout.addWidget(h_title)
  562.         h_layout.addWidget(h_sub)
  563.         h_layout.addStretch()
  564.         root_layout.addWidget(header)

  565.         # ── body ─────────────────────────────────────
  566.         splitter = QSplitter(Qt.Horizontal)
  567.         root_layout.addWidget(splitter, 1)

  568.         # ── LEFT panel ───────────────────────────────
  569.         left_panel = QWidget()
  570.         left_panel.setObjectName("leftPanel")
  571.         left_panel.setFixedWidth(295)
  572.         left_layout = QVBoxLayout(left_panel)
  573.         left_layout.setContentsMargins(10, 10, 10, 10)
  574.         left_layout.setSpacing(5)

  575.         mode_row = QHBoxLayout()
  576.         mode_lbl = QLabel("运行模式")
  577.         mode_lbl.setFixedWidth(64)
  578.         self.run_mode_combo = QComboBox()
  579.         run_mode_cfg = self.config.get("run_mode", {})
  580.         for item in run_mode_cfg.get("options", []):
  581.             self.run_mode_combo.addItem(item.get("label", ""), item.get("value", ""))
  582.         idx = self.run_mode_combo.findData(run_mode_cfg.get("default", "all_sectors"))
  583.         self.run_mode_combo.setCurrentIndex(max(0, idx))
  584.         self.run_mode_combo.currentIndexChanged.connect(self._refresh_field_visibility)
  585.         mode_row.addWidget(mode_lbl)
  586.         mode_row.addWidget(self.run_mode_combo)
  587.         left_layout.addLayout(mode_row)

  588.         sep = QFrame()
  589.         sep.setFrameShape(QFrame.HLine)
  590.         sep.setStyleSheet("color:#e2e8f0;")
  591.         left_layout.addWidget(sep)

  592.         scroll = QScrollArea()
  593.         scroll.setWidgetResizable(True)
  594.         scroll.setFrameShape(QFrame.NoFrame)
  595.         form_container = QWidget()
  596.         self.form_layout = QVBoxLayout(form_container)
  597.         self.form_layout.setSpacing(4)
  598.         self.form_layout.setContentsMargins(0, 2, 0, 2)
  599.         scroll.setWidget(form_container)
  600.         left_layout.addWidget(scroll, 1)
  601.         self._build_dynamic_fields()

  602.         sep2 = QFrame()
  603.         sep2.setFrameShape(QFrame.HLine)
  604.         sep2.setStyleSheet("color:#e2e8f0;")
  605.         left_layout.addWidget(sep2)

  606.         btn_row = QHBoxLayout()
  607.         self.run_button = QPushButton("▶  开始抓取")
  608.         self.run_button.setObjectName("runBtn")
  609.         self.run_button.setCursor(Qt.PointingHandCursor)
  610.         self.run_button.clicked.connect(self._on_run)
  611.         self.open_output_button = QPushButton("打开目录")
  612.         self.open_output_button.setObjectName("secBtn")
  613.         self.open_output_button.setCursor(Qt.PointingHandCursor)
  614.         self.open_output_button.clicked.connect(self._open_output_dir)
  615.         btn_row.addWidget(self.run_button, 2)
  616.         btn_row.addWidget(self.open_output_button, 1)
  617.         left_layout.addLayout(btn_row)

  618.         self.progress_bar = QProgressBar()
  619.         self.progress_bar.setRange(0, 100)
  620.         self.progress_bar.setValue(0)
  621.         left_layout.addWidget(self.progress_bar)

  622.         self.status_label = QLabel("就绪")
  623.         self.status_label.setStyleSheet("color:#64748b; font-size:11px;")
  624.         left_layout.addWidget(self.status_label)

  625.         # ── RIGHT panel ──────────────────────────────
  626.         right_panel = QWidget()
  627.         right_layout = QVBoxLayout(right_panel)
  628.         right_layout.setContentsMargins(8, 8, 8, 8)
  629.         right_layout.setSpacing(5)

  630.         result_toolbar = QHBoxLayout()
  631.         result_toolbar.setSpacing(6)
  632.         result_toolbar.addWidget(QLabel("分类:"))
  633.         self.category_filter_combo = QComboBox()
  634.         self.category_filter_combo.setFixedWidth(90)
  635.         self.category_filter_combo.addItem("全部", "全部")
  636.         self.category_filter_combo.currentIndexChanged.connect(self._apply_category_filter)
  637.         result_toolbar.addWidget(self.category_filter_combo)
  638.         result_toolbar.addStretch()
  639.         self.result_count_label = QLabel("共 0 条")
  640.         self.result_count_label.setStyleSheet("color:#64748b;")
  641.         result_toolbar.addWidget(self.result_count_label)
  642.         right_layout.addLayout(result_toolbar)

  643.         self.tabs = QTabWidget()
  644.         self.tabs.setDocumentMode(True)

  645.         # table tab
  646.         self.table = QTableWidget()
  647.         self.table.setColumnCount(4)
  648.         self.table.setHorizontalHeaderLabels(["分类", "板块名称", "主力净流入", "总成交额"])
  649.         self.table.setAlternatingRowColors(True)
  650.         self.table.setSortingEnabled(True)
  651.         self.table.setShowGrid(False)
  652.         self.table.verticalHeader().setVisible(False)
  653.         self.table.verticalHeader().setDefaultSectionSize(22)
  654.         self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
  655.         self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
  656.         self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
  657.         self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
  658.         self.tabs.addTab(self.table, "结果表格")

  659.         # chart tab
  660.         chart_tab = QWidget()
  661.         chart_tab_layout = QVBoxLayout(chart_tab)
  662.         chart_tab_layout.setContentsMargins(6, 6, 6, 6)
  663.         chart_tab_layout.setSpacing(4)
  664.         chart_toolbar = QHBoxLayout()
  665.         chart_toolbar.addWidget(QLabel("图表:"))
  666.         self.btn_top_in = QPushButton("净流入 Top")
  667.         self.btn_top_in.setObjectName("toggleBtn")
  668.         self.btn_top_in.setCheckable(True)
  669.         self.btn_top_in.setChecked(True)
  670.         self.btn_top_in.clicked.connect(lambda: self._set_chart_mode("top_in"))
  671.         self.btn_top_out = QPushButton("净流出 Top")
  672.         self.btn_top_out.setObjectName("toggleBtn")
  673.         self.btn_top_out.setCheckable(True)
  674.         self.btn_top_out.setChecked(False)
  675.         self.btn_top_out.clicked.connect(lambda: self._set_chart_mode("top_out"))
  676.         chart_toolbar.addWidget(self.btn_top_in)
  677.         chart_toolbar.addWidget(self.btn_top_out)
  678.         chart_toolbar.addStretch()
  679.         chart_tab_layout.addLayout(chart_toolbar)
  680.         self.chart = BarChartWidget()
  681.         chart_tab_layout.addWidget(self.chart, 1)
  682.         self.tabs.addTab(chart_tab, "可视化")

  683.         # log tab
  684.         self.log_edit = QTextEdit()
  685.         self.log_edit.setReadOnly(True)
  686.         self.log_edit.setStyleSheet("font-family:Consolas,monospace; font-size:11px;")
  687.         self.tabs.addTab(self.log_edit, "运行日志")

  688.         right_layout.addWidget(self.tabs, 1)

  689.         splitter.addWidget(left_panel)
  690.         splitter.addWidget(right_panel)
  691.         splitter.setStretchFactor(0, 0)
  692.         splitter.setStretchFactor(1, 1)

  693.         self.setCentralWidget(root)

  694.     def _build_dynamic_fields(self):
  695.         for field in self.config.get("fields", []):
  696.             key = field.get("key")
  697.             if not key:
  698.                 continue

  699.             row_wrap = QWidget()
  700.             row_layout = QHBoxLayout(row_wrap)
  701.             row_layout.setContentsMargins(0, 0, 0, 0)
  702.             row_layout.setSpacing(6)
  703.             lbl = QLabel(field.get("label", key))
  704.             lbl.setFixedWidth(76)
  705.             lbl.setWordWrap(True)
  706.             widget = self._create_widget_for_field(field)
  707.             row_layout.addWidget(lbl)
  708.             row_layout.addWidget(widget, 1)

  709.             if key == "output_dir":
  710.                 browse_btn = QPushButton("…")
  711.                 browse_btn.setObjectName("secBtn")
  712.                 browse_btn.setFixedWidth(26)
  713.                 browse_btn.clicked.connect(self._select_output_dir)
  714.                 row_layout.addWidget(browse_btn)

  715.             self.form_layout.addWidget(row_wrap)
  716.             self.field_widgets[key] = widget
  717.             self.field_metas[key] = field
  718.             self.row_widgets[key] = row_wrap

  719.         self.form_layout.addStretch()

  720.     def _create_widget_for_field(self, field):
  721.         ftype = field.get("type", "text")
  722.         default = field.get("default")

  723.         if ftype == "select":
  724.             combo = QComboBox()
  725.             for option in field.get("options", []):
  726.                 combo.addItem(option.get("label", option.get("value", "")), option.get("value", ""))
  727.             idx = combo.findData(default)
  728.             combo.setCurrentIndex(max(0, idx))
  729.             return combo

  730.         if ftype == "int":
  731.             spin = QSpinBox()
  732.             spin.setMinimum(int(field.get("min", -999999)))
  733.             spin.setMaximum(int(field.get("max", 999999)))
  734.             spin.setValue(int(default if default is not None else 0))
  735.             return spin

  736.         if ftype == "bool":
  737.             check = QCheckBox()
  738.             check.setChecked(bool(default))
  739.             return check

  740.         edit = QLineEdit()
  741.         edit.setText("" if default is None else str(default))
  742.         placeholder = field.get("placeholder", "")
  743.         if placeholder:
  744.             edit.setPlaceholderText(placeholder)
  745.         return edit

  746.     def _refresh_field_visibility(self):
  747.         run_mode = self.run_mode_combo.currentData()
  748.         for key, meta in self.field_metas.items():
  749.             cond = meta.get("visible_when")
  750.             visible = True
  751.             if cond and "run_mode" in cond:
  752.                 visible = run_mode == cond.get("run_mode")
  753.             self.row_widgets[key].setVisible(visible)

  754.     def _get_widget_value(self, key, widget):
  755.         meta = self.field_metas[key]
  756.         ftype = meta.get("type", "text")

  757.         if ftype == "select":
  758.             return widget.currentData()
  759.         if ftype == "int":
  760.             return int(widget.value())
  761.         if ftype == "bool":
  762.             return bool(widget.isChecked())
  763.         return widget.text().strip()

  764.     def _collect_params(self):
  765.         params = {
  766.             "run_mode": self.run_mode_combo.currentData(),
  767.         }
  768.         for key, widget in self.field_widgets.items():
  769.             if not self.row_widgets[key].isVisible():
  770.                 continue
  771.             params[key] = self._get_widget_value(key, widget)
  772.         return params

  773.     def _select_output_dir(self):
  774.         current = self.field_widgets["output_dir"].text().strip() or "."
  775.         selected = QFileDialog.getExistingDirectory(self, "选择输出目录", current)
  776.         if selected:
  777.             self.field_widgets["output_dir"].setText(selected)

  778.     def _open_output_dir(self):
  779.         output_dir = self.field_widgets.get("output_dir")
  780.         if output_dir is None:
  781.             return
  782.         path = output_dir.text().strip() or "."
  783.         try:
  784.             os.startfile(path)
  785.         except OSError as exc:
  786.             QMessageBox.warning(self, "提示", f"无法打开目录: {exc}")

  787.     def _on_run(self):
  788.         params = self._collect_params()

  789.         if not params.get("save_json") and not params.get("save_markdown"):
  790.             ret = QMessageBox.question(
  791.                 self,
  792.                 "确认",
  793.                 "JSON 和 Markdown 都未勾选,将不会保存文件。继续吗?",
  794.             )
  795.             if ret != QMessageBox.StandardButton.Yes:
  796.                 return

  797.         self.run_button.setEnabled(False)
  798.         self.progress_bar.setValue(0)
  799.         self.status_label.setText("运行中...")
  800.         self.log_edit.clear()

  801.         self.worker = FetchWorker(params)
  802.         self.worker.log_signal.connect(self._append_log)
  803.         self.worker.progress_signal.connect(self._on_progress)
  804.         self.worker.result_signal.connect(self._on_result)
  805.         self.worker.error_signal.connect(self._on_error)
  806.         self.worker.finished.connect(self._on_finished)
  807.         self.worker.start()

  808.     def _append_log(self, text):
  809.         self.log_edit.append(text)

  810.     def _on_progress(self, value, text):
  811.         self.progress_bar.setValue(value)
  812.         self.status_label.setText(text)
  813.         self._append_log(f"[{value}%] {text}")

  814.     @staticmethod
  815.     def _to_float(value):
  816.         try:
  817.             return float(value)
  818.         except (TypeError, ValueError):
  819.             return 0.0

  820.     def _set_chart_mode(self, mode):
  821.         self.chart.set_mode(mode)
  822.         self.btn_top_in.setChecked(mode == "top_in")
  823.         self.btn_top_out.setChecked(mode == "top_out")

  824.     def _on_result(self, payload):
  825.         self.all_rows = payload.get("rows", [])
  826.         self._refresh_category_filter_options(self.all_rows)
  827.         self._apply_category_filter()
  828.         self.status_label.setText(f"完成 · 共 {payload.get('total_count', 0)} 条")
  829.         self.tabs.setCurrentIndex(0)

  830.     def _refresh_category_filter_options(self, rows):
  831.         cats = sorted({str(r.get("category", "")) for r in rows if r.get("category")})
  832.         self.category_filter_combo.blockSignals(True)
  833.         self.category_filter_combo.clear()
  834.         self.category_filter_combo.addItem("全部", "全部")
  835.         for c in cats:
  836.             self.category_filter_combo.addItem(c, c)
  837.         self.category_filter_combo.blockSignals(False)

  838.     def _apply_category_filter(self):
  839.         selected = self.category_filter_combo.currentData()
  840.         filtered = self.all_rows if selected in (None, "全部") else [
  841.             x for x in self.all_rows if x.get("category") == selected
  842.         ]
  843.         self._fill_table(filtered)
  844.         self.chart.set_rows(filtered)
  845.         self.result_count_label.setText(f"共 {len(filtered)} 条")

  846.     def _fill_table(self, rows):
  847.         self.table.setSortingEnabled(False)
  848.         self.table.setRowCount(len(rows))
  849.         for r, row in enumerate(rows):
  850.             net_in_raw = row.get("mainNetIn", "")
  851.             total_raw = row.get("totalAmount", "")
  852.             values = [
  853.                 row.get("category", ""),
  854.                 row.get("name", ""),
  855.                 _fmt_yuan(net_in_raw),
  856.                 _fmt_yuan(total_raw),
  857.             ]
  858.             for c, value in enumerate(values):
  859.                 item = QTableWidgetItem(str(value))
  860.                 if c in (2, 3):
  861.                     item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
  862.                 if c == 2:
  863.                     try:
  864.                         fg = QColor("#16a34a") if float(net_in_raw) >= 0 else QColor("#dc2626")
  865.                         item.setForeground(fg)
  866.                     except (TypeError, ValueError):
  867.                         pass
  868.                 self.table.setItem(r, c, item)
  869.         self.table.setSortingEnabled(True)

  870.     def _on_error(self, message):
  871.         self._append_log(f"[ERROR] {message}")
  872.         QMessageBox.critical(self, "运行失败", message)

  873.     def _on_finished(self):
  874.         self.run_button.setEnabled(True)
  875.         if self.progress_bar.value() < 100:
  876.             self.progress_bar.setValue(100)


  877. def main():
  878.     app = QApplication(sys.argv)
  879.     window = FundflowMainWindow()
  880.     window.show()
  881.     sys.exit(app.exec())


  882. if __name__ == "__main__":
  883.     main()
复制代码


0

主题

4

回帖

34

积分

金币会员

积分
34
发表于 昨天 18:48 | 显示全部楼层
消息差,顶你

0

主题

4

回帖

20

积分

金币会员

积分
20
发表于 昨天 21:12 | 显示全部楼层
没有程序下载吗?这个代码是哪个开发程序上面的?如何运行?

0

主题

1

回帖

9

积分

注册会员

积分
9
发表于 昨天 23:48 | 显示全部楼层
12 21321312321

0

主题

2

回帖

0

积分

至尊VIP会员

积分
0
发表于 2 小时前 | 显示全部楼层
666,不错啊
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

QQ|手机版|小黑屋| 股指标网 ( 渝ICP备2024026571号-1 )

GMT+8, 2026-4-19 10:34 Powered by Discuz! X3.5