From f93ffd381b2c43cad91579a3fb5417ae99f77c9c Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:31:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=B8=E5=BF=83=E8=82=A1=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA=E9=80=89=E8=82=A1=E5=B9=B6=E5=85=A5=E7=83=AD=E7=82=B9?= =?UTF-8?q?=E7=A9=BF=E9=80=8F=E6=A0=B8=E5=BF=83=E8=82=A1(=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E9=A2=98=E6=9D=90=E6=95=B0=E9=99=8D=E5=BA=8F=E5=89=8D?= =?UTF-8?q?100,=E5=8E=BB=E9=87=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 选股口径由仅'题材领涨股涨幅前100'改为: - 热点穿透核心股(覆盖题材数≥2,按覆盖数降序前100) - 合并题材领涨股涨幅前100,按股票代码去重 复用 _build_theme_graph 计算每股覆盖题材数;热点穿透构建失败 时降级为仅领涨股前100,不影响当日采集。 Co-Authored-By: Claude --- backend/services/daily_collector.py | 99 +++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 21 deletions(-) diff --git a/backend/services/daily_collector.py b/backend/services/daily_collector.py index 985d5e9..414c309 100644 --- a/backend/services/daily_collector.py +++ b/backend/services/daily_collector.py @@ -9,15 +9,18 @@ from datetime import datetime, time as dtime, timezone, timedelta from typing import Optional from database import get_connection -from services.themes import fetch_theme_list +from services.themes import fetch_theme_list, _build_theme_graph _CST = timezone(timedelta(hours=8)) # 每天采集的后台任务:每 300 秒(5 分钟)检查一次 CHECK_INTERVAL_SECONDS = 300 COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集 -CORE_STOCK_LIMIT = 100 # 核心股前100 -TOP_THEME_LIMIT = 20 # 题材涨幅前20 +CORE_STOCK_LIMIT = 100 # 题材领涨股涨幅前100 +TOP_THEME_LIMIT = 20 # 题材涨幅前20 +HOTMAP_TOP_N = 50 # 热点穿透采样题材数:涨幅榜+热度榜各取前50,合并(与热点穿透页一致) +HOTMAP_CORE_LIMIT = 100 # 热点穿透核心股前100(按覆盖题材数降序) +CORE_COVER_THRESHOLD = 2 # 热点穿透核心股门槛:覆盖题材数 ≥2 def _is_trading_day(d: datetime) -> bool: @@ -41,6 +44,9 @@ def _has_collected(trade_date: str) -> bool: async def collect_daily(trade_date: str, dry_run: bool = False) -> dict: """采集指定交易日数据并入库。 + 核心股 = 题材领涨股涨幅前100 ∪ 热点穿透核心股(覆盖题材数≥2、按覆盖数降序前100),按股票代码去重。 + 热点穿透构建失败时降级为仅领涨股前100,不影响当日采集。 + Args: trade_date: YYYY-MM-DD dry_run: True 只打印不写库(用于验证) @@ -58,9 +64,32 @@ async def collect_daily(trade_date: str, dry_run: bool = False) -> dict: print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过") return {"core_count": 0, "theme_count": 0, "skipped": True} - # 2. 核心股前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股) + # 领涨题材映射:securityCode -> [(themeCode, themeName), ...](用于核心股"所属题材") + lead_themes: dict[str, list[tuple[str, str]]] = {} + for t in themes: + code = t.get("securityCode") + if not code: + continue + lead_themes.setdefault(code, []).append((t["themeCode"], t["themeName"])) + + # 2. 热点穿透核心股:覆盖题材数≥2,按覆盖题材数降序前100(去重) + # _build_theme_graph 采样 涨幅榜+热度榜 各前 HOTMAP_TOP_N 个题材并拉每股覆盖题材数, + # 已按 (-coverCount, -f3) 降序;失败时降级为空集,仅保留领涨股。 + hotmap_core: list[dict] = [] + graph_theme_name: dict[str, str] = {} + try: + graph = await _build_theme_graph(1, HOTMAP_TOP_N) + graph_theme_name = {t["themeCode"]: t["themeName"] for t in graph.get("themes", [])} + hotmap_core = [ + s for s in graph.get("stocks", []) + if s.get("coverCount", 0) >= CORE_COVER_THRESHOLD + ][:HOTMAP_CORE_LIMIT] + except Exception as e: + print(f"[collector] 热点穿透核心股构建失败,降级为仅领涨股: {e}") + + # 3. 题材领涨股涨幅前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股) stock_map: dict[str, dict] = {} - theme_count: dict[str, int] = {} # securityCode -> 覆盖题材数 + theme_count: dict[str, int] = {} # securityCode -> 领涨题材数 for t in themes: code = t.get("securityCode") if not code: @@ -72,34 +101,62 @@ async def collect_daily(trade_date: str, dry_run: bool = False) -> dict: "stock_name": t.get("securityName", ""), "f3": t.get("f3"), } - core_stocks = sorted( - stock_map.values(), key=lambda x: -(x["f3"] or 0) - )[:CORE_STOCK_LIMIT] + f3_core = sorted(stock_map.values(), key=lambda x: -(x["f3"] or 0))[:CORE_STOCK_LIMIT] - # 3. 题材涨幅前20:bf3 降序 + # 4. 题材涨幅前20:bf3 降序 top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:TOP_THEME_LIMIT] + # 5. 合并去重:热点穿透核心股在前(覆盖数降序,rank 优先),随后补领涨股涨幅前100 + core_stocks: list[dict] = [] + seen: set[str] = set() + theme_pairs: set[tuple[str, str, str]] = set() # (stock_code, theme_code, theme_name) + + for s in hotmap_core: + code = s["securityCode"] + seen.add(code) + core_stocks.append({ + "stock_code": code, + "stock_name": s.get("securityName", ""), + "f3": s.get("f3"), + "cover_count": s.get("coverCount", 0), + }) + # 所属题材:采样榜内覆盖的题材 + 全量题材列表中的领涨题材 + for tc in s.get("themeCodes", []): + theme_pairs.add((code, tc, graph_theme_name.get(tc, tc))) + for tc, tn in lead_themes.get(code, []): + theme_pairs.add((code, tc, tn)) + + for s in f3_core: + code = s["stock_code"] + if code in seen: + continue + seen.add(code) + core_stocks.append({ + "stock_code": code, + "stock_name": s["stock_name"], + "f3": s["f3"], + "cover_count": theme_count.get(code, 0), + }) + for tc, tn in lead_themes.get(code, []): + theme_pairs.add((code, tc, tn)) + if dry_run: - print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)} 只") + print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只(热点穿透 {len(hotmap_core)} + 领涨股 {len(f3_core)}),题材前20 {len(top_themes)} 只") return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False} - # 4. 入库(事务,UNIQUE 幂等) + # 6. 入库(事务,UNIQUE 幂等) conn = get_connection() try: for i, s in enumerate(core_stocks, start=1): conn.execute( "INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)", - (trade_date, s["stock_code"], s["stock_name"], s["f3"], theme_count.get(s["stock_code"], 0), i), + (trade_date, s["stock_code"], s["stock_name"], s["f3"], s["cover_count"], i), + ) + for code, tc, tn in theme_pairs: + conn.execute( + "INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)", + (trade_date, code, tc, tn), ) - # 核心股所属题材:从 themes 列表(含 securityCode/themeCode/themeName)中 - # 为每个核心股收集其全部所属题材,写入 daily_core_stock_themes - core_codes = {s["stock_code"] for s in core_stocks} - for t in themes: - if t.get("securityCode") in core_codes: - conn.execute( - "INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)", - (trade_date, t["securityCode"], t["themeCode"], t["themeName"]), - ) for i, t in enumerate(top_themes, start=1): conn.execute( "INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",