chore: baseline — ORM migration + systemd deploy + MCP server + daily check
- ORM migration: models/ops 重构 - deploy: 标准化 systemd timer/service 部署体系 - MCP server: 5 个只读工具(任务/状态/数据集) - daily check: 数据一致性巡检 - watch: task_watch 后台监控 - kline_daily: 麦蕊优先,时间门禁15:00 - 删除: task_industry_sector, task_sector_features
This commit is contained in:
@@ -84,14 +84,15 @@ def data_stats():
|
||||
from sqlalchemy import func, select
|
||||
from app.core.db.models import (
|
||||
KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade,
|
||||
SectorIndices, SectorFeaturesDaily, MarketRegimeDaily,
|
||||
LonghubangDaily, LonghubangSeat, Stocks, Industry,
|
||||
MarketRegimeDaily,
|
||||
LonghubangDaily, LonghubangSeat, Stock,
|
||||
NodeCategory, Node, StockNodeMap, KlineStockMADaily,
|
||||
KlineStockMACDDaily, KlineStockKDJDaily, KlineStockBOLLDaily,
|
||||
)
|
||||
from app.core.db.orm import SessionLocal
|
||||
|
||||
targets = [
|
||||
("stocks", Stocks),
|
||||
("stocks", Stock),
|
||||
("kline_stock", KlineStock),
|
||||
("kline_index", KlineIndex),
|
||||
("kline_5min", Kline5Min),
|
||||
@@ -99,15 +100,15 @@ def data_stats():
|
||||
("moneyflow", Moneyflow),
|
||||
("share", Share),
|
||||
("tick_trade", TickTrade),
|
||||
("sector_indices", SectorIndices),
|
||||
("sector_features_daily", SectorFeaturesDaily),
|
||||
("market_regime_daily", MarketRegimeDaily),
|
||||
("longhubang_daily", LonghubangDaily),
|
||||
("longhubang_seat", LonghubangSeat),
|
||||
("industry", Industry),
|
||||
("node_categories", NodeCategory),
|
||||
("nodes", Node),
|
||||
("stock_node_map", StockNodeMap),
|
||||
("kline_stock_macd_daily", KlineStockMACDDaily),
|
||||
("kline_stock_kdj_daily", KlineStockKDJDaily),
|
||||
("kline_stock_boll_daily", KlineStockBOLLDaily),
|
||||
]
|
||||
out = []
|
||||
with SessionLocal() as s:
|
||||
|
||||
@@ -30,6 +30,12 @@ if _HAS_DOTENV:
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置。所有字段都可以通过环境变量或 .env 文件覆盖。"""
|
||||
|
||||
# Runtime mode: "docker" | "systemd"
|
||||
# docker : 单容器运行,依赖进程内 scheduler 触发所有同步任务
|
||||
# systemd : Linux 宿主机运行,由 systemd timer/service 触发任务,
|
||||
# 进程内 scheduler 必须关闭,避免双调度器重叠
|
||||
runtime_mode: str = "docker"
|
||||
|
||||
# Scheduler
|
||||
scheduler_tick_seconds: int = 5
|
||||
scheduler_timezone: str = "Asia/Shanghai"
|
||||
|
||||
@@ -1,15 +1,146 @@
|
||||
"""数据源公共工具:代码转换、K 线标准化、日期过滤。
|
||||
|
||||
参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。"""
|
||||
参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, time as dtime, timedelta
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger("datasource.utils")
|
||||
|
||||
KLINE_COLS = ["trade_date", "open", "high", "low", "close", "volume"]
|
||||
KLINE_5MIN_COLS = ["bar_time", "open", "high", "low", "close", "volume", "amount", "turnover_rate"]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_THREAD_POOL: dict[int, threading.Thread] = {}
|
||||
|
||||
|
||||
def call_with_timeout(
|
||||
func: Callable[..., T],
|
||||
*args: Any,
|
||||
timeout: float = 20.0,
|
||||
on_timeout: T | None = None,
|
||||
description: str = "",
|
||||
**kwargs: Any,
|
||||
) -> T | None:
|
||||
"""用 daemon 线程跑 `func(*args, **kwargs)`,超时返回 `on_timeout`。
|
||||
|
||||
Why: 之前用 ThreadPoolExecutor,但超时后 `future.cancel()` 无法终止
|
||||
已在运行的线程,pool 的 `shutdown(wait=True)` 会等待卡死的函数返回,
|
||||
把 20s 超时变成无限阻塞——这是 kline_daily 3406 只股票批量失败的根因。
|
||||
|
||||
改用 daemon 线程:超时后直接返回,线程成为孤儿进程,不会阻塞任何调用方。
|
||||
daemon=True 确保进程退出时不会等待这些孤儿线程。
|
||||
|
||||
Args:
|
||||
func: 要跑的同步函数
|
||||
*args/**kwargs: 透传给 func
|
||||
timeout: 秒数,默认 20s(对标 mairui/sina 的 urlopen timeout)
|
||||
on_timeout: 超时返回的占位值(默认 None,调用方需处理 None)
|
||||
description: 日志里的调用描述(如 'xueqiu.kline SH600519')
|
||||
|
||||
Returns: func 的返回值,或 on_timeout(超时时)
|
||||
"""
|
||||
result: list[T | None | BaseException] = [None]
|
||||
done = threading.Event()
|
||||
|
||||
def _wrapper() -> None:
|
||||
try:
|
||||
result[0] = func(*args, **kwargs)
|
||||
except BaseException as e:
|
||||
result[0] = e
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
t = threading.Thread(
|
||||
target=_wrapper,
|
||||
daemon=True,
|
||||
name=f"ds-timeout-{description or func.__name__}",
|
||||
)
|
||||
t.start()
|
||||
|
||||
if not done.wait(timeout=timeout):
|
||||
logger.warning(
|
||||
"[%s] %s 超时 (>%ss), 返回 on_timeout",
|
||||
description or func.__name__, description or func.__name__, timeout,
|
||||
)
|
||||
return on_timeout
|
||||
|
||||
if isinstance(result[0], BaseException):
|
||||
raise result[0] # type: ignore[misc]
|
||||
return result[0]
|
||||
|
||||
|
||||
def effective_market_date(
|
||||
now: datetime | None = None,
|
||||
holidays: set[str] | None = None,
|
||||
) -> str:
|
||||
"""返回"数据生效日"(最近已完成交易日)。
|
||||
|
||||
15:30 是 A 股收盘时刻;之后今天的数据才完整,之前应取昨天。
|
||||
然后向前回退直到找到一个交易日(跳过周末和法定假期)。
|
||||
用于数据源给"无明确来源日期"的数据(snowball quote_detail 的股本快照等)
|
||||
打 trade_date 时用 — 永远不要用 datetime.now() 直接当 trade_date。
|
||||
|
||||
Why: 任务运行日不等于数据交易日。例如 7月9日 早盘 9:30 跑 share_snapshot,
|
||||
此时拿到的股本快照实际对应 7月8日 收盘,不能标 7月9日。早期代码用
|
||||
`datetime.now().strftime("%Y-%m-%d")` 导致 share 表里 7月9日 跑出来的
|
||||
行标成 7月9日,与实际数据日不符,统计/回溯会出错。
|
||||
|
||||
2026-07-12 增强: 加入交易日历回退逻辑。如果 15:30 后是法定节假日
|
||||
(如春节/国庆),会向前回退到最后一个交易日,不会把节假日当天当 trade_date。
|
||||
|
||||
Args:
|
||||
now: 测试用注入点(默认 datetime.now())
|
||||
holidays: 已知的 A 股休市日集合(YYYY-MM-DD 格式)。
|
||||
默认从 config 表 trading_calendar_holidays 加载。
|
||||
|
||||
Returns: 'YYYY-MM-DD' 字符串(永远是合法的交易日)
|
||||
"""
|
||||
t = now or datetime.now()
|
||||
if t.time() >= dtime(15, 0):
|
||||
candidate = t
|
||||
else:
|
||||
candidate = t - timedelta(days=1)
|
||||
|
||||
# 加载交易日历(如果调用方未提供)
|
||||
if holidays is None:
|
||||
try:
|
||||
from app.core.db import ops as db_ops
|
||||
h_row = db_ops.fetch_config_by_key("trading_calendar_holidays")
|
||||
if h_row and h_row.get("category") == "general":
|
||||
import json
|
||||
vals = json.loads(h_row.get("value", "") or "[]")
|
||||
if isinstance(vals, list):
|
||||
holidays = {h for h in vals if isinstance(h, str)}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if holidays is None:
|
||||
holidays = set()
|
||||
|
||||
# 向前回退直到找到交易日
|
||||
max_iter = 30 # 安全上限,避免死循环
|
||||
while max_iter > 0:
|
||||
date_str = candidate.strftime("%Y-%m-%d")
|
||||
if candidate.weekday() < 5 and date_str not in holidays:
|
||||
return date_str
|
||||
candidate -= timedelta(days=1)
|
||||
max_iter -= 1
|
||||
|
||||
# 兜底(正常情况下不会走到这里)
|
||||
t2 = now or datetime.now()
|
||||
if t2.time() >= dtime(15, 30):
|
||||
return t2.strftime("%Y-%m-%d")
|
||||
return (t2 - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def to_code6(code: str) -> str:
|
||||
"""'SH600000' / 'sh.600000' / '600000' / 'SH600000.SH' → '600000'"""
|
||||
|
||||
+80
-94
@@ -141,7 +141,6 @@ class Stock(ORMBase):
|
||||
exchange: Mapped[str] = mapped_column(String(8), nullable=False, default="")
|
||||
list_date: Mapped[Optional[date]] = mapped_column(Date)
|
||||
listing_status: Mapped[str] = mapped_column(String(16), nullable=False, default="normal")
|
||||
industry: Mapped[Optional[str]] = mapped_column(String(64), default="")
|
||||
total_share: Mapped[float] = mapped_column(Float, default=0)
|
||||
float_share: Mapped[float] = mapped_column(Float, default=0)
|
||||
share_updated_at: Mapped[Optional[date]] = mapped_column(Date)
|
||||
@@ -258,92 +257,7 @@ class Share(ORMBase):
|
||||
float_share: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
|
||||
# ──────────────────────── 10. sectors ────────────────────────
|
||||
class Sectors(ORMBase):
|
||||
"""行业字典(baostock 行业分类 / 申万 / 中证 等多种 taxonomy)。"""
|
||||
__tablename__ = "sectors"
|
||||
__table_args__ = (
|
||||
Index("idx_sectors_sector_name", "sector_name"),
|
||||
{"schema": "market_data"},
|
||||
)
|
||||
|
||||
sector_key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
sector_name: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
taxonomy: Mapped[Optional[str]] = mapped_column(String(64), default="")
|
||||
level: Mapped[Optional[str]] = mapped_column(String(16), default="")
|
||||
source: Mapped[Optional[str]] = mapped_column(String(32), default="")
|
||||
enabled: Mapped[int] = mapped_column(Integer, default=1)
|
||||
updated_at: Mapped[Optional[datetime]] = _updated_at()
|
||||
|
||||
|
||||
# ──────────────────────── 11. stock_sector_map ────────────────────────
|
||||
class StockSectorMap(ORMBase):
|
||||
"""股票-行业多对多映射(一张股票可对应多个行业 / 概念板块)。"""
|
||||
__tablename__ = "stock_sector_map"
|
||||
__table_args__ = (
|
||||
Index("idx_stock_sector_map_sector_key", "sector_key"),
|
||||
{"schema": "market_data"},
|
||||
)
|
||||
|
||||
stock_code: Mapped[str] = mapped_column(String(10), primary_key=True)
|
||||
sector_key: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
updated_at: Mapped[Optional[datetime]] = _updated_at()
|
||||
|
||||
|
||||
# ──────────────────────── 12. industry ────────────────────────
|
||||
class Industry(ORMBase):
|
||||
"""股票-行业映射(baostock 源,证监会分类标准)。"""
|
||||
__tablename__ = "industry"
|
||||
__table_args__ = (
|
||||
Index("idx_industry_industry_name", "industry_name"),
|
||||
{"schema": "market_data"},
|
||||
)
|
||||
|
||||
code: Mapped[str] = mapped_column(String(10), primary_key=True)
|
||||
industry_name: Mapped[Optional[str]] = mapped_column(String(64), default="")
|
||||
industry_classification: Mapped[Optional[str]] = mapped_column(String(32), default="")
|
||||
update_date: Mapped[Optional[date]] = mapped_column(Date)
|
||||
updated_at: Mapped[Optional[datetime]] = _updated_at()
|
||||
|
||||
|
||||
# ──────────────────────── 13. sector_indices ────────────────────────
|
||||
class SectorIndices(ORMBase):
|
||||
"""行业日线(基点 100,复合收益)。"""
|
||||
__tablename__ = "sector_indices"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("trade_date", "sector_name"),
|
||||
Index("idx_sector_indices_sector_name", "sector_name"),
|
||||
{"schema": "market_data"},
|
||||
)
|
||||
|
||||
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
sector_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
close: Mapped[float] = mapped_column(Float, default=0)
|
||||
sector_amplitude: Mapped[float] = mapped_column(Float, default=0)
|
||||
|
||||
|
||||
# ──────────────────────── 14. sector_features_daily ────────────────────────
|
||||
class SectorFeaturesDaily(ORMBase):
|
||||
"""行业日特征:sector_ret / sector_amplitude / close / EMA / score。"""
|
||||
__tablename__ = "sector_features_daily"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("trade_date", "sector_name"),
|
||||
Index("idx_sector_features_sector_name", "sector_name"),
|
||||
{"schema": "market_data"},
|
||||
)
|
||||
|
||||
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
sector_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
sector_ret: Mapped[float] = mapped_column(Float, default=0)
|
||||
sector_amplitude: Mapped[float] = mapped_column(Float, default=0)
|
||||
close: Mapped[float] = mapped_column(Float, default=0)
|
||||
ema10: Mapped[float] = mapped_column(Float, default=0)
|
||||
ema20: Mapped[float] = mapped_column(Float, default=0)
|
||||
ema200: Mapped[float] = mapped_column(Float, default=0)
|
||||
score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
# ──────────────────────── 15. market_regime_daily ────────────────────────
|
||||
# ──────────────────────── 12. market_regime_daily ────────────────────────
|
||||
class MarketRegimeDaily(ORMBase):
|
||||
"""市场情绪衍生指标(基于本地 kline 聚合)。"""
|
||||
__tablename__ = "market_regime_daily"
|
||||
@@ -599,6 +513,80 @@ class StockNodeMap(ORMBase):
|
||||
updated_at: Mapped[Optional[datetime]] = _updated_at()
|
||||
|
||||
|
||||
# ──────────────────────── 22. kline_stock_macd_daily ────────────────────────
|
||||
class KlineStockMACDDaily(ORMBase):
|
||||
"""个股日 K 级别 MACD 指标(mairui /hsstock/history/macd 直拉)。
|
||||
|
||||
字段(mairui 原始字段名,与 K 线口径一致):
|
||||
diff = DIF(快慢 EMA 差)
|
||||
dea = DEA(DIF 的 9 日 EMA,即 signal line)
|
||||
macd = MACD 柱(2 ×(DIF − DEA))
|
||||
ema12 / ema26 = 计算 DIF 用的两条 EMA
|
||||
stock_code 用 hermes 格式(SH600519),与项目其他表一致。
|
||||
"""
|
||||
__tablename__ = "kline_stock_macd_daily"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("stock_code", "trade_date"),
|
||||
Index("idx_kline_stock_macd_date", "trade_date"),
|
||||
{"schema": "market_data"},
|
||||
)
|
||||
|
||||
stock_code: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
diff: Mapped[Optional[float]] = mapped_column(Float)
|
||||
dea: Mapped[Optional[float]] = mapped_column(Float)
|
||||
macd: Mapped[Optional[float]] = mapped_column(Float)
|
||||
ema12: Mapped[Optional[float]] = mapped_column(Float)
|
||||
ema26: Mapped[Optional[float]] = mapped_column(Float)
|
||||
source: Mapped[Optional[str]] = mapped_column(String(32), default="mairui")
|
||||
updated_at: Mapped[Optional[datetime]] = _updated_at()
|
||||
|
||||
|
||||
# ──────────────────────── 23. kline_stock_kdj_daily ────────────────────────
|
||||
class KlineStockKDJDaily(ORMBase):
|
||||
"""个股日 K 级别 KDJ 指标(mairui /hsstock/history/kdj 直拉)。
|
||||
|
||||
k / d / j 三条随机指标线。stock_code 用 hermes 格式(SH600519)。
|
||||
"""
|
||||
__tablename__ = "kline_stock_kdj_daily"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("stock_code", "trade_date"),
|
||||
Index("idx_kline_stock_kdj_date", "trade_date"),
|
||||
{"schema": "market_data"},
|
||||
)
|
||||
|
||||
stock_code: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
k: Mapped[Optional[float]] = mapped_column(Float)
|
||||
d: Mapped[Optional[float]] = mapped_column(Float)
|
||||
j: Mapped[Optional[float]] = mapped_column(Float)
|
||||
source: Mapped[Optional[str]] = mapped_column(String(32), default="mairui")
|
||||
updated_at: Mapped[Optional[datetime]] = _updated_at()
|
||||
|
||||
|
||||
# ──────────────────────── 24. kline_stock_boll_daily ────────────────────────
|
||||
class KlineStockBOLLDaily(ORMBase):
|
||||
"""个股日 K 级别 BOLL 布林带指标(mairui /hsstock/history/boll 直拉)。
|
||||
|
||||
mairui 原始字段:u=上轨, m=中轨, d=下轨。落库列名统一为 upper/mid/lower。
|
||||
stock_code 用 hermes 格式(SH600519)。
|
||||
"""
|
||||
__tablename__ = "kline_stock_boll_daily"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("stock_code", "trade_date"),
|
||||
Index("idx_kline_stock_boll_date", "trade_date"),
|
||||
{"schema": "market_data"},
|
||||
)
|
||||
|
||||
stock_code: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
upper: Mapped[Optional[float]] = mapped_column(Float)
|
||||
mid: Mapped[Optional[float]] = mapped_column(Float)
|
||||
lower: Mapped[Optional[float]] = mapped_column(Float)
|
||||
source: Mapped[Optional[str]] = mapped_column(String(32), default="mairui")
|
||||
updated_at: Mapped[Optional[datetime]] = _updated_at()
|
||||
|
||||
|
||||
__all__ = [
|
||||
# 1-2
|
||||
"Config",
|
||||
@@ -612,13 +600,7 @@ __all__ = [
|
||||
"Kline5Min",
|
||||
"Moneyflow",
|
||||
"Share",
|
||||
# 10-12
|
||||
"Sectors",
|
||||
"StockSectorMap",
|
||||
"Industry",
|
||||
# 13-15
|
||||
"SectorIndices",
|
||||
"SectorFeaturesDaily",
|
||||
# 10-11
|
||||
"MarketRegimeDaily",
|
||||
# 16
|
||||
"TickTrade",
|
||||
@@ -631,4 +613,8 @@ __all__ = [
|
||||
"NodeCategory",
|
||||
"Node",
|
||||
"StockNodeMap",
|
||||
# 22-24 (2026-07-08 mairui 技术指标 MACD/KDJ/BOLL - 日 K 级别)
|
||||
"KlineStockMACDDaily",
|
||||
"KlineStockKDJDaily",
|
||||
"KlineStockBOLLDaily",
|
||||
]
|
||||
|
||||
+94
-157
@@ -22,10 +22,12 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from app.core.db.models import (
|
||||
Config,
|
||||
DatasetRegistry,
|
||||
Industry,
|
||||
Kline5Min,
|
||||
KlineIndex,
|
||||
KlineStock,
|
||||
KlineStockBOLLDaily,
|
||||
KlineStockKDJDaily,
|
||||
KlineStockMACDDaily,
|
||||
KlineStockMADaily,
|
||||
LonghubangDaily,
|
||||
LonghubangSeat,
|
||||
@@ -34,13 +36,9 @@ from app.core.db.models import (
|
||||
Moneyflow,
|
||||
Node,
|
||||
NodeCategory,
|
||||
SectorFeaturesDaily,
|
||||
SectorIndices,
|
||||
Sectors,
|
||||
Share,
|
||||
Stock,
|
||||
StockNodeMap,
|
||||
StockSectorMap,
|
||||
SyncHistory,
|
||||
TickTrade,
|
||||
)
|
||||
@@ -451,19 +449,16 @@ def upsert_stock(
|
||||
exchange: str,
|
||||
list_date: str = "",
|
||||
listing_status: str = "normal",
|
||||
industry: str = "",
|
||||
) -> None:
|
||||
"""upsert 一只股票基础信息(单条)。
|
||||
|
||||
注意:MySQL 9.7.0 在 ON DUPLICATE KEY UPDATE 阶段对 DATE 字段的 VALUES()/new.col
|
||||
求值存在 bug(会强制把空串塞进去,触发 1292 严格模式错误)。所以这里:
|
||||
- list_date 字段在首次 INSERT 时写入;UPDATE 分支不更新(保持原值或 NULL)
|
||||
- industry 字段也避开这个 bug
|
||||
求值存在 bug(会强制把空串塞进去,触发 1292 严格模式错误)。所以这里
|
||||
list_date 字段在首次 INSERT 时写入;UPDATE 分支不更新(保持原值或 NULL)。
|
||||
|
||||
批量写入请用 upsert_stocks_bulk(),性能高 10-20 倍。
|
||||
"""
|
||||
del list_date # 显式不接受 list_date 更新(旧值保留)
|
||||
del industry # 同上
|
||||
with get_session() as s:
|
||||
stmt = _pg_upsert(
|
||||
Stock,
|
||||
@@ -477,7 +472,7 @@ def upsert_stock(
|
||||
def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int:
|
||||
"""批量 upsert 股票基础信息。
|
||||
|
||||
每条 row 需有: code, name, exchange, listing_status(可选 list_date / industry)
|
||||
每条 row 需有: code, name, exchange, listing_status(可选 list_date)
|
||||
性能:~5000 只股票从 ~50s 降到 ~3s。
|
||||
"""
|
||||
if not rows:
|
||||
@@ -499,7 +494,7 @@ def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int
|
||||
stmt = _pg_upsert(
|
||||
Stock, values,
|
||||
conflict_keys=["code"],
|
||||
update_cols=["name", "exchange", "list_date", "listing_status", "industry"],
|
||||
update_cols=["name", "exchange", "list_date", "listing_status"],
|
||||
)
|
||||
s.execute(stmt)
|
||||
total += len(chunk)
|
||||
@@ -507,15 +502,25 @@ def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int
|
||||
|
||||
|
||||
def update_stock_share_snapshot(code: str, total_share: float, float_share: float, trade_date: str = "") -> None:
|
||||
"""更新 stocks 表的股本字段。
|
||||
|
||||
Args:
|
||||
trade_date: 数据源返回的股本快照"as-of"交易日(用于 share 表写入)。
|
||||
注意:这个参数**不会**写到 stocks.share_updated_at 字段 —
|
||||
那个字段的语义是"我们什么时候拉到的"(运行日,date.today()),
|
||||
不是数据交易日。把 trade_date 写到 share_updated_at 会让
|
||||
task_share_snapshot / task_stocks_basic 的 dedup 检查
|
||||
(share_updated_at == today) 失效,见 task 层 fix。
|
||||
"""
|
||||
del trade_date # 显式不接受 — 见 docstring
|
||||
with get_session() as s:
|
||||
snap_date = _to_date_str(trade_date) if trade_date else None
|
||||
s.execute(
|
||||
update(Stock)
|
||||
.where(Stock.code == code)
|
||||
.values(
|
||||
total_share=float(total_share),
|
||||
float_share=float(float_share),
|
||||
share_updated_at=snap_date,
|
||||
share_updated_at=date.today(), # "拉取日",不是"数据日"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -543,7 +548,6 @@ def _stock_row_to_dict(r: Stock) -> dict[str, Any]:
|
||||
"exchange": r.exchange or "",
|
||||
"list_date": _to_date_str(r.list_date),
|
||||
"listing_status": r.listing_status or "normal",
|
||||
"industry": r.industry or "",
|
||||
"total_share": r.total_share if r.total_share is not None else 0,
|
||||
"float_share": r.float_share if r.float_share is not None else 0,
|
||||
"share_updated_at": _to_date_str(r.share_updated_at),
|
||||
@@ -674,9 +678,13 @@ def get_kline_5min_snapshots() -> dict[str, Optional[datetime]]:
|
||||
返回:{stock_code (6位): max(bar_time) 或 None}
|
||||
用于 kline_5min 任务的"全量/增量"统一规划。
|
||||
|
||||
DB 里 stock_code 形如 "000001.SZ"(mairui 写入格式),函数剥掉
|
||||
交易所后缀统一为 6 位。bar_time 是 DATETIME 字段,SQLAlchemy 2.x
|
||||
native 返回 datetime;如果是 str(方言边界情况)手动解析。
|
||||
DB 里 stock_code 是 hermes 格式 "SH600000"(带 SH/SZ/BJ 前缀,无点),
|
||||
2026-07-09 之前误以为 mairui 原始格式 "000001.SZ",用 split(".")[0] 剥
|
||||
不到 → 全部股票被 skip → task 把整市场当"全量"重跑(实际是增量)。
|
||||
修:用正则剥 SH/SZ/BJ 前缀。
|
||||
|
||||
bar_time 是 DATETIME 字段,SQLAlchemy 2.x native 返回 datetime;如果是
|
||||
str(方言边界情况)手动解析。
|
||||
"""
|
||||
_ensure_schema()
|
||||
out: dict[str, Optional[datetime]] = {}
|
||||
@@ -686,7 +694,13 @@ def get_kline_5min_snapshots() -> dict[str, Optional[datetime]]:
|
||||
.group_by(Kline5Min.stock_code)
|
||||
).all()
|
||||
for raw_code, latest in rows:
|
||||
code6 = str(raw_code or "").strip().split(".")[0]
|
||||
# 剥 SH/SZ/BJ 前缀(hermes 格式),容错 6 位裸码
|
||||
code6 = str(raw_code or "").strip()
|
||||
for prefix in ("SH", "SZ", "BJ"):
|
||||
if code6.startswith(prefix):
|
||||
code6 = code6[len(prefix):]
|
||||
break
|
||||
code6 = code6.split(".")[0] # 兜底剥 "000001.SZ" 老格式
|
||||
if len(code6) != 6 or not code6.isdigit():
|
||||
continue
|
||||
if isinstance(latest, datetime):
|
||||
@@ -834,143 +848,6 @@ def replace_all_stock_node_map(rows: list[dict[str, Any]]) -> None:
|
||||
s.execute(stmt)
|
||||
|
||||
|
||||
# ── 行业 / 概念板块 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def replace_all_industries(rows: list[dict[str, Any]]) -> None:
|
||||
"""全量替换 industry 表。rows: code, industry_name, industry_classification, update_date"""
|
||||
if not rows:
|
||||
return
|
||||
with get_session() as s:
|
||||
s.execute(delete(Industry))
|
||||
values = [
|
||||
{
|
||||
"code": str(r.get("code", "")).zfill(6),
|
||||
"industry_name": r.get("industry_name") or None,
|
||||
"industry_classification": r.get("industry_classification") or None,
|
||||
"update_date": _to_date_str(r.get("update_date")),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
if values:
|
||||
stmt = _pg_upsert(Industry, values,
|
||||
conflict_keys=["code"],
|
||||
update_cols=["industry_name", "industry_classification", "update_date"])
|
||||
s.execute(stmt)
|
||||
|
||||
|
||||
def fetch_all_industries() -> list[dict[str, Any]]:
|
||||
_ensure_schema()
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(Industry.code, Industry.industry_name, Industry.industry_classification, Industry.update_date)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"code": r.code,
|
||||
"industry_name": r.industry_name,
|
||||
"industry_classification": r.industry_classification,
|
||||
"update_date": _to_date_str(r.update_date),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def replace_all_sectors(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: sector_key, sector_name, taxonomy, level, source, enabled"""
|
||||
if not rows:
|
||||
return
|
||||
with get_session() as s:
|
||||
s.execute(delete(Sectors))
|
||||
values = [
|
||||
{
|
||||
"sector_key": r.get("sector_key", ""),
|
||||
"sector_name": r.get("sector_name", ""),
|
||||
"taxonomy": r.get("taxonomy", ""),
|
||||
"level": r.get("level", ""),
|
||||
"source": r.get("source", ""),
|
||||
"enabled": int(r.get("enabled", 1)),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
if values:
|
||||
stmt = _pg_upsert(Sectors, values,
|
||||
conflict_keys=["sector_key"],
|
||||
update_cols=["sector_name", "taxonomy", "level", "source", "enabled"])
|
||||
s.execute(stmt)
|
||||
|
||||
|
||||
def replace_all_stock_sector_map(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: stock_code, sector_key"""
|
||||
if not rows:
|
||||
return
|
||||
with get_session() as s:
|
||||
s.execute(delete(StockSectorMap))
|
||||
values = [
|
||||
{
|
||||
"stock_code": str(r.get("stock_code", "")).zfill(6),
|
||||
"sector_key": r.get("sector_key", ""),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
if values:
|
||||
stmt = _pg_upsert(StockSectorMap, values,
|
||||
conflict_keys=["stock_code"],
|
||||
update_cols=["sector_key"])
|
||||
s.execute(stmt)
|
||||
|
||||
|
||||
# ── 行业聚合(衍生)─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def replace_all_sector_indices(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: trade_date, sector_name, close, sector_amplitude"""
|
||||
if not rows:
|
||||
return
|
||||
with get_session() as s:
|
||||
values = [
|
||||
{
|
||||
"trade_date": _to_date_str(r.get("trade_date")),
|
||||
"sector_name": r.get("sector_name", ""),
|
||||
"close": float(r.get("close") or 0),
|
||||
"sector_amplitude": float(r.get("sector_amplitude") or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
if values:
|
||||
stmt = _pg_upsert(SectorIndices, values,
|
||||
conflict_keys=["trade_date", "sector_name"],
|
||||
update_cols=["close", "sector_amplitude"])
|
||||
s.execute(stmt)
|
||||
|
||||
|
||||
def replace_all_sector_features(rows: list[dict[str, Any]]) -> None:
|
||||
"""rows: trade_date, sector_name, sector_ret, sector_amplitude, close, ema10, ema20, ema200, score"""
|
||||
if not rows:
|
||||
return
|
||||
with get_session() as s:
|
||||
values = [
|
||||
{
|
||||
"trade_date": _to_date_str(r.get("trade_date")),
|
||||
"sector_name": r.get("sector_name", ""),
|
||||
"sector_ret": float(r.get("sector_ret") or 0),
|
||||
"sector_amplitude": float(r.get("sector_amplitude") or 0),
|
||||
"close": float(r.get("close") or 0),
|
||||
"ema10": float(r.get("ema10") or 0),
|
||||
"ema20": float(r.get("ema20") or 0),
|
||||
"ema200": float(r.get("ema200") or 0),
|
||||
"score": int(r.get("score") or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
if values:
|
||||
stmt = _pg_upsert(SectorFeaturesDaily, values,
|
||||
conflict_keys=["trade_date", "sector_name"],
|
||||
update_cols=["sector_ret", "sector_amplitude", "close",
|
||||
"ema10", "ema20", "ema200", "score"])
|
||||
s.execute(stmt)
|
||||
|
||||
|
||||
# ── 市场情绪(衍生)─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1031,6 +908,51 @@ def upsert_kline_stock_ma_daily_rows(rows: list[dict[str, Any]]) -> None:
|
||||
s.execute(stmt)
|
||||
|
||||
|
||||
def upsert_kline_stock_macd_daily_rows(rows: list[dict[str, Any]]) -> int:
|
||||
"""rows: stock_code, trade_date, diff, dea, macd, ema12, ema26, source"""
|
||||
if not rows:
|
||||
return 0
|
||||
with get_session() as s:
|
||||
return _bulk_upsert_orm(s, KlineStockMACDDaily, rows)
|
||||
|
||||
|
||||
def upsert_kline_stock_kdj_daily_rows(rows: list[dict[str, Any]]) -> int:
|
||||
"""rows: stock_code, trade_date, k, d, j, source"""
|
||||
if not rows:
|
||||
return 0
|
||||
with get_session() as s:
|
||||
return _bulk_upsert_orm(s, KlineStockKDJDaily, rows)
|
||||
|
||||
|
||||
def upsert_kline_stock_boll_daily_rows(rows: list[dict[str, Any]]) -> int:
|
||||
"""rows: stock_code, trade_date, upper, mid, lower, source"""
|
||||
if not rows:
|
||||
return 0
|
||||
with get_session() as s:
|
||||
return _bulk_upsert_orm(s, KlineStockBOLLDaily, rows)
|
||||
|
||||
|
||||
_INDICATOR_MODEL = {
|
||||
"macd": KlineStockMACDDaily,
|
||||
"kdj": KlineStockKDJDaily,
|
||||
"boll": KlineStockBOLLDaily,
|
||||
}
|
||||
|
||||
|
||||
def get_indicator_max_date(indicator: str, code: str) -> Optional[str]:
|
||||
"""某只票某指标已入库的最大 trade_date(YYYY-MM-DD),无则 None。"""
|
||||
model = _INDICATOR_MODEL.get(indicator.lower())
|
||||
if model is None:
|
||||
raise ValueError(f"未知指标: {indicator!r}")
|
||||
_ensure_schema()
|
||||
with get_session() as s:
|
||||
d = s.execute(
|
||||
select(func.max(model.trade_date))
|
||||
.where(model.stock_code == code)
|
||||
).scalar()
|
||||
return _to_date_str(d) if d else None
|
||||
|
||||
|
||||
def pd_isna(v: Any) -> bool:
|
||||
"""避免直接 import pandas(开销大),手写 nan/None 检查。"""
|
||||
if v is None:
|
||||
@@ -1042,11 +964,26 @@ def pd_isna(v: Any) -> bool:
|
||||
|
||||
|
||||
def get_stock_kline_max_date(code: str) -> Optional[str]:
|
||||
"""某只票 kline_stock 已入库的最大 trade_date (YYYY-MM-DD),无则 None。
|
||||
|
||||
code 接受 6 位 ("600000") 或 hermes 格式 ("SH600000") — DB 里实际存的是
|
||||
hermes 格式,2026-07-09 之前用 `where KlineStock.stock_code == '600000'`
|
||||
永远查不到(hermes 有 SH/SZ/BJ 前缀),导致 incremental 把全市场当"无数据"
|
||||
重跑。修:同时查 6 位和 hermes 两种格式,取较新结果。
|
||||
"""
|
||||
_ensure_schema()
|
||||
c6 = str(code or "").strip()
|
||||
hermes = f"SH{c6}" if c6.startswith(("5", "6", "9")) else (
|
||||
f"SZ{c6}" if c6.startswith(("0", "2", "3")) else (
|
||||
f"BJ{c6}" if c6.startswith(("4", "8")) else c6
|
||||
))
|
||||
candidates = [c6]
|
||||
if hermes != c6:
|
||||
candidates.append(hermes)
|
||||
with get_session() as s:
|
||||
d = s.execute(
|
||||
select(func.max(KlineStock.trade_date))
|
||||
.where(KlineStock.stock_code == code)
|
||||
.where(KlineStock.stock_code.in_(candidates))
|
||||
).scalar()
|
||||
return _to_date_str(d) if d else None
|
||||
|
||||
|
||||
@@ -323,17 +323,6 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
|
||||
},
|
||||
"每个交易日 21:35 拉取个股资金流(mairui 21:30 发布)",
|
||||
),
|
||||
(
|
||||
"schedule_industry_sector",
|
||||
{
|
||||
"name": "周一行业映射",
|
||||
"time": "09:30",
|
||||
"condition": "trading_day",
|
||||
"job": "industry_sector",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 09:30 全量更新股票-行业映射(开销大,可改为 weekly)",
|
||||
),
|
||||
(
|
||||
"schedule_market_regime",
|
||||
{
|
||||
@@ -345,17 +334,6 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
|
||||
},
|
||||
"每个交易日 16:15 聚合市场情绪(基于本地日K线)",
|
||||
),
|
||||
(
|
||||
"schedule_share_snapshot",
|
||||
{
|
||||
"name": "盘后股本快照",
|
||||
"time": "16:20",
|
||||
"condition": "trading_day",
|
||||
"job": "share_snapshot",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 16:20 刷新个股总股本/流通股本快照",
|
||||
),
|
||||
(
|
||||
"schedule_longhubang",
|
||||
{
|
||||
@@ -389,6 +367,17 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
|
||||
},
|
||||
"每个交易日 16:30 基于 kline_stock 计算 MA5/10/20/60 (本地派生,~30s @5213 只)",
|
||||
),
|
||||
(
|
||||
"schedule_mairui_indicators",
|
||||
{
|
||||
"name": "日 K 技术指标 MACD/KDJ/BOLL",
|
||||
"time": "16:40",
|
||||
"condition": "trading_day",
|
||||
"job": "mairui_indicators",
|
||||
"enabled": True,
|
||||
},
|
||||
"每个交易日 16:40 从 mairui 直拉 MACD/KDJ/BOLL 增量 (3 指标 × 全市场,增量约数分钟)",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -428,8 +417,8 @@ def register_sync_jobs() -> None:
|
||||
|
||||
for dataset_id in [
|
||||
"stock_basic", "kline_daily", "kline_index", "kline_5min",
|
||||
"moneyflow", "industry_sector", "share_snapshot", "market_regime",
|
||||
"longhubang", "stock_node", "mairui_ma_daily",
|
||||
"moneyflow", "share_snapshot", "market_regime",
|
||||
"longhubang", "stock_node", "mairui_ma_daily", "mairui_indicators",
|
||||
]:
|
||||
|
||||
def _make_job(did=dataset_id):
|
||||
|
||||
@@ -34,13 +34,13 @@ def _is_market_closed() -> bool:
|
||||
|
||||
|
||||
def _effective_sync_end() -> str:
|
||||
"""若盘后则今天,否则昨天。"""
|
||||
today = datetime.now()
|
||||
if today.time() >= dtime(15, 30):
|
||||
return today.strftime("%Y-%m-%d")
|
||||
# 昨天
|
||||
from datetime import timedelta
|
||||
return (today - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
"""若盘后则今天,否则昨天。
|
||||
|
||||
委派到 app.core.datasource.utils.effective_market_date() 统一实现,
|
||||
该函数加载交易日历,会跳过节假日返回最近交易日。
|
||||
"""
|
||||
from app.core.datasource.utils import effective_market_date
|
||||
return effective_market_date()
|
||||
|
||||
|
||||
class SyncTask:
|
||||
|
||||
+12
-24
@@ -94,30 +94,6 @@ SYNC_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"dependency_ids": ["stock_basic"],
|
||||
"sort_order": 50,
|
||||
},
|
||||
{
|
||||
"dataset_id": "industry_sector",
|
||||
"name": "股票-行业映射",
|
||||
"description": "股票-行业映射 + 行业字典(Baostock)",
|
||||
"storage_uri": "PG market_data.industry + sectors + stock_sector_map",
|
||||
"storage_layer": "pg",
|
||||
"management_role": "基础字典",
|
||||
"source": "Baostock query_stock_industry",
|
||||
"sync_script": "app.tasks.task_industry_sector:run",
|
||||
"dependency_ids": ["stock_basic"],
|
||||
"sort_order": 60,
|
||||
},
|
||||
{
|
||||
"dataset_id": "sector_features",
|
||||
"name": "行业聚合特征",
|
||||
"description": "由 kline_stock + industry 衍生:行业日收益 / 行业指数 close / EMA10-20-200 / score。纯本地计算,无外部 API。",
|
||||
"storage_uri": "PG market_data.sector_indices + sector_features_daily",
|
||||
"storage_layer": "pg",
|
||||
"management_role": "衍生源",
|
||||
"source": "本地计算(kline_stock + industry)",
|
||||
"sync_script": "app.tasks.task_sector_features:run",
|
||||
"dependency_ids": ["kline_daily", "industry_sector"],
|
||||
"sort_order": 65,
|
||||
},
|
||||
{
|
||||
"dataset_id": "share_snapshot",
|
||||
"name": "股本快照",
|
||||
@@ -178,6 +154,18 @@ SYNC_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"dependency_ids": ["kline_daily"],
|
||||
"sort_order": 90,
|
||||
},
|
||||
{
|
||||
"dataset_id": "mairui_indicators",
|
||||
"name": "日 K 技术指标 MACD/KDJ/BOLL (mairui)",
|
||||
"description": "mairui /hsstock/history/{macd,kdj,boll} 直拉,写 3 张表;每交易日 16:40 增量。",
|
||||
"storage_uri": "PG market_data.kline_stock_macd_daily + kdj_daily + boll_daily",
|
||||
"storage_layer": "pg",
|
||||
"management_role": "原始源",
|
||||
"source": "mairui /hsstock/history/{macd,kdj,boll}/{symbol}/d/n",
|
||||
"sync_script": "app.tasks.task_mairui_indicators:run",
|
||||
"dependency_ids": ["stock_basic"],
|
||||
"sort_order": 91,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,15 @@ def start_scheduler_thread() -> None:
|
||||
给 service_run.sh 用:在 uvicorn 同进程跑后台 scheduler,避免
|
||||
docker 一个容器跑两个进程(supervisord / s6 那种方案)。
|
||||
幂等:多次调用只启动一次。
|
||||
|
||||
2026-07-12: 增加 RUNTIME_MODE 感知。systemd 模式下不启动 scheduler,
|
||||
因为同步由 systemd timer 触发,避免双调度器重叠。
|
||||
"""
|
||||
logger.info(f"[worker] runtime_mode={settings.runtime_mode}")
|
||||
if settings.runtime_mode == "systemd":
|
||||
logger.info("[worker] systemd 模式:不启动进程内 scheduler,同步由 systemd timer 触发")
|
||||
return
|
||||
|
||||
# 1. 注册数据源 + seed config
|
||||
from app.core.datasource.registry import (
|
||||
build_default_registry,
|
||||
@@ -74,6 +82,22 @@ def start_scheduler_thread() -> None:
|
||||
|
||||
def main():
|
||||
logger.info("[worker] market_data_sync worker 启动")
|
||||
logger.info(f"[worker] runtime_mode={settings.runtime_mode}")
|
||||
|
||||
if settings.runtime_mode == "systemd":
|
||||
logger.info("[worker] systemd 模式:不启动进程内 scheduler,同步由 systemd timer 触发")
|
||||
logger.info("[worker] 仅启动健康监控(数据源心跳),按 Ctrl+C 退出")
|
||||
from app.core.datasource.registry import build_default_registry, seed_datasource_configs, start_health_monitor
|
||||
build_default_registry()
|
||||
seed_datasource_configs()
|
||||
start_health_monitor()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(60)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[worker] 收到 SIGINT,退出")
|
||||
sys.exit(0)
|
||||
return
|
||||
|
||||
# 1. 注册数据源 + seed config
|
||||
from app.core.datasource.registry import build_default_registry, seed_datasource_configs
|
||||
|
||||
+53
-10
@@ -34,6 +34,7 @@ if str(_PROJECT_ROOT) not in sys.path:
|
||||
|
||||
# ── MCP server bootstrap ────────────────────────────────────────────
|
||||
from mcp.server import Server
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
@@ -125,9 +126,10 @@ def list_datasets() -> list[dict]:
|
||||
from sqlalchemy import func, select
|
||||
from app.core.db.models import (
|
||||
KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade,
|
||||
SectorIndices, SectorFeaturesDaily, MarketRegimeDaily,
|
||||
LonghubangDaily, LonghubangSeat, Stock, Industry,
|
||||
MarketRegimeDaily,
|
||||
LonghubangDaily, LonghubangSeat, Stock,
|
||||
NodeCategory, Node, StockNodeMap, KlineStockMADaily,
|
||||
KlineStockMACDDaily, KlineStockKDJDaily, KlineStockBOLLDaily,
|
||||
)
|
||||
from app.core.db.orm import SessionLocal
|
||||
|
||||
@@ -140,15 +142,15 @@ def list_datasets() -> list[dict]:
|
||||
("moneyflow", Moneyflow),
|
||||
("share", Share),
|
||||
("tick_trade", TickTrade),
|
||||
("sector_indices", SectorIndices),
|
||||
("sector_features_daily", SectorFeaturesDaily),
|
||||
("market_regime_daily", MarketRegimeDaily),
|
||||
("longhubang_daily", LonghubangDaily),
|
||||
("longhubang_seat", LonghubangSeat),
|
||||
("industry", Industry),
|
||||
("node_categories", NodeCategory),
|
||||
("nodes", Node),
|
||||
("stock_node_map", StockNodeMap),
|
||||
("kline_stock_macd_daily", KlineStockMACDDaily),
|
||||
("kline_stock_kdj_daily", KlineStockKDJDaily),
|
||||
("kline_stock_boll_daily", KlineStockBOLLDaily),
|
||||
]
|
||||
out = []
|
||||
with SessionLocal() as s:
|
||||
@@ -175,9 +177,10 @@ def get_dataset_info(table_name: str) -> dict:
|
||||
allowed = {
|
||||
"stocks", "kline_stock", "kline_index", "kline_5min",
|
||||
"kline_stock_ma_daily", "moneyflow", "share", "tick_trade",
|
||||
"sector_indices", "sector_features_daily", "market_regime_daily",
|
||||
"longhubang_daily", "longhubang_seat", "industry",
|
||||
"market_regime_daily",
|
||||
"longhubang_daily", "longhubang_seat",
|
||||
"node_categories", "nodes", "stock_node_map",
|
||||
"kline_stock_macd_daily", "kline_stock_kdj_daily", "kline_stock_boll_daily",
|
||||
"dataset_registry", "sync_history", "config",
|
||||
}
|
||||
if table_name not in allowed:
|
||||
@@ -291,11 +294,51 @@ async def call_tool(name: str, arguments: dict):
|
||||
|
||||
# ── main ────────────────────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
# 启动时注册数据源(让 datasource.health_check 之类方法可用)
|
||||
async def main_sse(host: str = "127.0.0.1", port: int = 8101):
|
||||
"""启动 MCP 的 SSE (HTTP) 服务,供持久运行。
|
||||
|
||||
客户端连接: http://{host}:{port}/sse
|
||||
"""
|
||||
sse_transport = SseServerTransport("/messages")
|
||||
|
||||
async def app(scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
if path == "/sse":
|
||||
async with sse_transport.connect_sse(scope, receive, send) as (read, write):
|
||||
await server.run(read, write, server.create_initialization_options())
|
||||
elif path == "/messages" and scope.get("method") == "POST":
|
||||
await sse_transport.handle_post_message(scope, receive, send)
|
||||
|
||||
import uvicorn
|
||||
from app.core.utils.logging import get_logger
|
||||
logger = get_logger("mcp")
|
||||
logger.info(f"MCP SSE server listening on http://{host}:{port}/sse")
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
srv = uvicorn.Server(config)
|
||||
await srv.serve()
|
||||
|
||||
|
||||
def main():
|
||||
from app.core.datasource.registry import build_default_registry
|
||||
build_default_registry()
|
||||
|
||||
import sys
|
||||
if "--sse" in sys.argv:
|
||||
host = "127.0.0.1"
|
||||
port = 8101
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == "--host" and i + 1 < len(sys.argv):
|
||||
host = sys.argv[i + 1]
|
||||
if arg == "--port" and i + 1 < len(sys.argv):
|
||||
port = int(sys.argv[i + 1])
|
||||
asyncio.run(main_sse(host, port))
|
||||
else:
|
||||
asyncio.run(_stdio_main())
|
||||
|
||||
|
||||
async def _stdio_main():
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
read_stream,
|
||||
@@ -305,4 +348,4 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
main()
|
||||
+63
-1
@@ -32,7 +32,7 @@ from app.core.datasource.utils import (
|
||||
class MairuiSource(DataSource):
|
||||
key = "datasource_mairui"
|
||||
name = "麦蕊智数(mairui.club)"
|
||||
provides = ["kline_daily", "kline_5min", "index_daily", "stock_basic", "moneyflow", "tick_trade", "stock_node"]
|
||||
provides = ["kline_daily", "kline_5min", "index_daily", "stock_basic", "moneyflow", "tick_trade", "stock_node", "indicator_daily"]
|
||||
requires_credential = True
|
||||
credential_key = "MAIRUI_LICENCE"
|
||||
|
||||
@@ -285,6 +285,68 @@ class MairuiSource(DataSource):
|
||||
df = df[df["bar_time"] < pd.Timestamp(end) + pd.Timedelta(days=1)]
|
||||
return df
|
||||
|
||||
# ── 技术指标 fetch(MACD / KDJ / BOLL)─────────────────────
|
||||
# mairui 端点:/hsstock/history/{indicator}/{symbol}/d/n/{licence}
|
||||
# indicator ∈ {macd, kdj, boll};日 K 级别 "/d/";不复权 "/n/"
|
||||
# 各指标字段(除公共 "t" 外):
|
||||
# macd → diff, dea, macd, ema12, ema26
|
||||
# kdj → k, d, j
|
||||
# boll → u(上轨), d(下轨), m(中轨) ← 注意 mairui 用 u/d/m
|
||||
_INDICATOR_FIELDS = {
|
||||
"macd": ["diff", "dea", "macd", "ema12", "ema26"],
|
||||
"kdj": ["k", "d", "j"],
|
||||
"boll": ["u", "d", "m"],
|
||||
}
|
||||
|
||||
def fetch_indicator_daily(
|
||||
self, indicator: str, code6: str, start: str = "", end: str = ""
|
||||
) -> pd.DataFrame:
|
||||
"""拉单只股票某个技术指标的日 K 序列。
|
||||
|
||||
返回列:trade_date + 该指标字段(macd/kdj/boll 各自的原始字段名)。
|
||||
start/end 为 "YYYY-MM-DD"(可空 → 全历史)。
|
||||
"""
|
||||
indicator = indicator.lower()
|
||||
fields = self._INDICATOR_FIELDS.get(indicator)
|
||||
if fields is None:
|
||||
raise ValueError(f"未知指标: {indicator!r},可选 {list(self._INDICATOR_FIELDS)}")
|
||||
lic = self._get_licence()
|
||||
if not lic:
|
||||
return pd.DataFrame()
|
||||
symbol = code6_to_mairui(code6)
|
||||
path = f"/hsstock/history/{indicator}/{symbol}/d/n/{lic}"
|
||||
params = {
|
||||
"st": (start or "").replace("-", ""),
|
||||
"et": (end or "").replace("-", ""),
|
||||
}
|
||||
data = self._fetch(path, params)
|
||||
if not isinstance(data, list):
|
||||
return pd.DataFrame()
|
||||
records = []
|
||||
for item in data:
|
||||
try:
|
||||
t = item.get("t", "")
|
||||
d = t.split(" ")[0] if isinstance(t, str) else ""
|
||||
if not d:
|
||||
continue
|
||||
row = {"trade_date": d}
|
||||
for f in fields:
|
||||
v = item.get(f)
|
||||
row[f] = None if v is None else float(v)
|
||||
records.append(row)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
if not records:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(records)
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"], errors="coerce")
|
||||
df = df.dropna(subset=["trade_date"]).sort_values("trade_date").reset_index(drop=True)
|
||||
if start:
|
||||
df = df[df["trade_date"] >= pd.Timestamp(start)]
|
||||
if end:
|
||||
df = df[df["trade_date"] <= pd.Timestamp(end)]
|
||||
return df
|
||||
|
||||
def fetch_index_daily(self, index_code: str, start: str, end: str) -> pd.DataFrame:
|
||||
"""指数日 K。index_code 用 mairui 格式(如 '000300.SH')。"""
|
||||
lic = self._get_licence()
|
||||
|
||||
+31
-7
@@ -16,7 +16,7 @@ from typing import Any, Optional
|
||||
import pandas as pd
|
||||
|
||||
from app.core.datasource.base import DataSource
|
||||
from app.core.datasource.utils import code6_to_xueqiu
|
||||
from app.core.datasource.utils import call_with_timeout, code6_to_xueqiu, effective_market_date
|
||||
|
||||
logger = logging.getLogger("sync.xueqiu")
|
||||
|
||||
@@ -94,7 +94,11 @@ class XueqiuSource(DataSource):
|
||||
try:
|
||||
ball = self._import_ball()
|
||||
self._set_token_once()
|
||||
result = ball.quote_detail("SH600036")
|
||||
result = call_with_timeout(
|
||||
ball.quote_detail, "SH600036",
|
||||
timeout=10.0, on_timeout=None,
|
||||
description="xueqiu.health_check",
|
||||
)
|
||||
if result is None or result.get("error_code") != 0:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -123,12 +127,23 @@ class XueqiuSource(DataSource):
|
||||
count = min(max((end_dt - start_dt).days + 60, 10), 5000)
|
||||
|
||||
# 加重试:雪球风控偶尔返回空/错误
|
||||
# 用 call_with_timeout 包一层 — 2026-07-08 kline_daily 卡死 4h 根因
|
||||
# 就是 pysnowball ball.kline() 底层 requests 无 read timeout,雪球
|
||||
# 服务端卡住时这里永久阻塞。timeout=20s 与 mairui/sina 对齐。
|
||||
result = None
|
||||
for attempt in range(3):
|
||||
result = ball.kline(symbol, period="day", count=count)
|
||||
result = call_with_timeout(
|
||||
ball.kline, symbol, period="day", count=count,
|
||||
timeout=20.0, on_timeout=None,
|
||||
description=f"xueqiu.kline {symbol}",
|
||||
)
|
||||
if result and result.get("error_code") == 0:
|
||||
break
|
||||
time.sleep(0.3 * (attempt + 1))
|
||||
if result is None and attempt < 2:
|
||||
time.sleep(0.3 * (attempt + 1))
|
||||
continue
|
||||
if result and result.get("error_code") != 0:
|
||||
time.sleep(0.3 * (attempt + 1))
|
||||
if not result or result.get("error_code") != 0:
|
||||
logger.warning(
|
||||
"[kline %s] 雪球 kline 失败: error_code=%s desc=%s",
|
||||
@@ -177,7 +192,12 @@ class XueqiuSource(DataSource):
|
||||
self._wait_rps()
|
||||
|
||||
symbol = code6_to_xueqiu(code6)
|
||||
result = ball.quote_detail(symbol)
|
||||
# ball.quote_detail 同样有 pysnowball 无 read timeout 风险,包一层
|
||||
result = call_with_timeout(
|
||||
ball.quote_detail, symbol,
|
||||
timeout=15.0, on_timeout=None,
|
||||
description=f"xueqiu.quote_detail {symbol}",
|
||||
)
|
||||
if not result or result.get("error_code") != 0:
|
||||
logger.warning(
|
||||
"[share %s] 雪球 quote_detail 失败: error_code=%s desc=%s",
|
||||
@@ -187,14 +207,18 @@ class XueqiuSource(DataSource):
|
||||
)
|
||||
return None
|
||||
quote = result.get("data", {}).get("quote", {})
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
# 雪球 quote_detail 不返回明确"股本变动日",time 字段是 quote 当前
|
||||
# 时间,不是交易日。股本数据本质是慢变快照,这里标"最近已完成交易日"
|
||||
# (15:30 前=昨天,15:30 后=今天) — 比 datetime.now() 精确,避免盘前
|
||||
# 拉到的快照被错标为"今天"。
|
||||
as_of = effective_market_date()
|
||||
total = round(float(quote.get("total_shares") or 0) / 1e8, 4)
|
||||
flt = round(float(quote.get("float_shares") or 0) / 1e8, 4)
|
||||
if total <= 0 or flt <= 0:
|
||||
logger.warning("[share %s] 雪球返回成功但 total/float 为 0", code6)
|
||||
return None
|
||||
return {
|
||||
"trade_date": today_str,
|
||||
"trade_date": as_of,
|
||||
"total_share": total,
|
||||
"float_share": flt,
|
||||
}
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
所有 `SyncTask` 子类在此集中注册,外部通过 `get_task(dataset_id)` 或
|
||||
遍历 `TASKS` 字典使用。
|
||||
"""
|
||||
from app.tasks.task_industry_sector import SyncIndustrySector
|
||||
from app.tasks.task_kline_5min import SyncKline5Min
|
||||
from app.tasks.task_kline_daily import SyncKlineDaily
|
||||
from app.tasks.task_kline_index import SyncKlineIndex
|
||||
from app.tasks.task_longhubang import SyncLonghubang
|
||||
from app.tasks.task_mairui_ma_daily import SyncMairuiMADaily
|
||||
from app.tasks.task_mairui_indicators import SyncMairuiIndicators
|
||||
from app.tasks.task_market_regime import SyncMarketRegime
|
||||
from app.tasks.task_moneyflow import SyncMoneyflow
|
||||
from app.tasks.task_sector_features import SyncSectorFeatures
|
||||
from app.tasks.task_share_snapshot import SyncShareSnapshot
|
||||
from app.tasks.task_stock_node import SyncStockNode
|
||||
from app.tasks.task_stocks_basic import SyncStocksBasic
|
||||
@@ -26,13 +25,12 @@ TASKS: dict[str, type] = {
|
||||
SyncKline5Min,
|
||||
SyncTickTrade,
|
||||
SyncMoneyflow,
|
||||
SyncIndustrySector,
|
||||
SyncSectorFeatures,
|
||||
SyncShareSnapshot,
|
||||
SyncMarketRegime,
|
||||
SyncLonghubang,
|
||||
SyncStockNode,
|
||||
SyncMairuiMADaily,
|
||||
SyncMairuiIndicators,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""同步任务:股票-行业映射(Baostock)。
|
||||
|
||||
数据流:Baostock query_stock_industry() → 写 industry / sectors / stock_sector_map 三张表。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.core.db import ops as db_ops
|
||||
from app.core.db.models import Stock
|
||||
from app.core.datasource.base import registry as ds_registry
|
||||
from app.core.datasource.registry import is_source_ready
|
||||
from app.core.datasource.utils import to_hermes
|
||||
from app.core.sync.base import SyncTask
|
||||
from app.core.sync.registry import mark_sync_blocked
|
||||
from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("sync.industry_sector")
|
||||
|
||||
|
||||
def _build_sector_key(taxonomy: str, sector_name: str) -> str:
|
||||
tax = (taxonomy or "").strip() or "default"
|
||||
name = (sector_name or "").strip()
|
||||
return f"{tax}::{name}"
|
||||
|
||||
|
||||
class SyncIndustrySector(SyncTask):
|
||||
dataset_id = "industry_sector"
|
||||
|
||||
def _run(self, *, trigger_source: str = "manual", **kwargs) -> dict[str, Any]:
|
||||
bs = ds_registry.get("datasource_baostock")
|
||||
if bs is None:
|
||||
return {"status": "error", "message": "Baostock 数据源未注册"}
|
||||
ok, reason = is_source_ready(bs.key)
|
||||
if not ok:
|
||||
mark_sync_blocked(self.dataset_id, message=f"Baostock 未就绪: {reason}")
|
||||
return {"status": "blocked", "message": f"Baostock 未就绪: {reason}"}
|
||||
|
||||
self._progress(message="拉取股票-行业映射...")
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw_rows = bs.fetch_industry_map()
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": f"拉取失败: {e}"}
|
||||
|
||||
if not raw_rows:
|
||||
return {"status": "warning", "message": "Baostock 未返回行业数据"}
|
||||
|
||||
# 合并:sectors + stock_sector_map + industry
|
||||
sectors_rows: dict[str, dict] = {}
|
||||
stock_sector_rows: list[dict] = []
|
||||
industry_rows: list[dict] = []
|
||||
|
||||
for r in raw_rows:
|
||||
code6 = str(r["code"]).zfill(6)
|
||||
name = (r.get("industry_name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
classification = (r.get("industry_classification") or "").strip()
|
||||
sector_key = _build_sector_key(classification, name)
|
||||
|
||||
sectors_rows[sector_key] = {
|
||||
"sector_key": sector_key,
|
||||
"sector_name": name,
|
||||
"taxonomy": classification,
|
||||
"level": "",
|
||||
"source": "baostock_query_stock_industry",
|
||||
"enabled": 1,
|
||||
}
|
||||
stock_sector_rows.append({"stock_code": to_hermes(code6), "sector_key": sector_key})
|
||||
industry_rows.append({
|
||||
"code": to_hermes(code6),
|
||||
"industry_name": name,
|
||||
"industry_classification": classification,
|
||||
"update_date": r.get("update_date", ""),
|
||||
})
|
||||
|
||||
# 写库(全量替换)
|
||||
try:
|
||||
db_ops.replace_all_sectors(list(sectors_rows.values()))
|
||||
db_ops.replace_all_stock_sector_map(stock_sector_rows)
|
||||
db_ops.replace_all_industries(industry_rows)
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": f"写库失败: {e}"}
|
||||
|
||||
# 回填 stocks.industry 字段(让 stocks 表也能直接看到)
|
||||
# 走 ORM update(Stock) + IN (SHxxx, SZxxx, BJxxx, ...)
|
||||
try:
|
||||
# 按 industry_name 分组,对每组用一个 IN 批量更新
|
||||
by_industry: dict[str, list[str]] = {}
|
||||
for r in industry_rows:
|
||||
name = r["industry_name"]
|
||||
code = r["code"]
|
||||
if name not in by_industry:
|
||||
by_industry[name] = []
|
||||
by_industry[name].append(code)
|
||||
for ind_name, codes in by_industry.items():
|
||||
# 6 位 code × 3 个交易所前缀(SH/SZ/BJ)
|
||||
code_variants = [
|
||||
f"{prefix}{c}" for c in codes for prefix in ("SH", "SZ", "BJ")
|
||||
]
|
||||
with db_ops.get_session() as s:
|
||||
s.execute(
|
||||
update(Stock)
|
||||
.where(Stock.code.in_(code_variants))
|
||||
.values(industry=ind_name)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"回填 stocks.industry 失败(不影响主任务): {e}")
|
||||
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
msg = f"行业 {len(sectors_rows)}个 映射{len(stock_sector_rows)}条, {elapsed}s"
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": msg,
|
||||
"sectors": len(sectors_rows),
|
||||
"stock_sector_map": len(stock_sector_rows),
|
||||
"elapsed_sec": elapsed,
|
||||
}
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -37,6 +37,10 @@ MAIRUI_5MIN_FLOOR = datetime(2023, 6, 14, tzinfo=timezone.utc)
|
||||
WINDOW_DAYS = 365
|
||||
# 增量时往前多取的天数(防交易日历边界漏当天)
|
||||
INCREMENT_OVERLAP_DAYS = 2
|
||||
# 2026-07-08 教训: 数据源层有 20s timeout,task 层再硬兜底,
|
||||
# 防 SDK 升级 / 网络层 bug 让单只股票卡住(7月7日 4.4只/秒 后突然 0 持续 24h)
|
||||
# 7月9日 mairui/雪球 间歇性 "服务器连接失败" + Broken pipe,拉慢。给 120s 容忍。
|
||||
FETCH_HARD_TIMEOUT = 120.0 # 5min K 一只拉多年,单只允许更久
|
||||
|
||||
|
||||
class SyncKline5Min(SyncTask):
|
||||
@@ -99,7 +103,12 @@ class SyncKline5Min(SyncTask):
|
||||
|
||||
# ── 2) 内存算计划 ──
|
||||
# PG TIMESTAMPTZ 是 timezone-aware,所以 end 也必须 tz-aware 否则比较会炸
|
||||
end = datetime.now(timezone.utc)
|
||||
# 2026-07-12 修复: end 改为上海时区最近交易日 15:00,避免 UTC 与上海时间混用
|
||||
from app.core.datasource.utils import effective_market_date
|
||||
end_date_str = effective_market_date()
|
||||
end = datetime.strptime(f"{end_date_str} 15:00:00", "%Y-%m-%d %H:%M:%S").replace(
|
||||
tzinfo=timezone(timedelta(hours=8))
|
||||
)
|
||||
plans = self._plan(stock_codes, end, snapshots)
|
||||
if not plans:
|
||||
msg = f"5min K线 — 全部 {len(stock_codes)} 只都已最新(无需同步)"
|
||||
@@ -129,12 +138,17 @@ class SyncKline5Min(SyncTask):
|
||||
t0 = time.time()
|
||||
ok_cnt = fail_cnt = 0
|
||||
rows_total = 0
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(self._sync_one, primary, p[0], p[1], p[2]): p[0] for p in plans}
|
||||
for i, future in enumerate(as_completed(futures), 1):
|
||||
# 不用 `with ThreadPoolExecutor(...) as pool:` — 它的 __exit__ 默认 wait=True,
|
||||
# 一旦某个 worker 卡在 xueqiu IO,主线程会在 as_completed 触发 60s timeout 后
|
||||
# 仍被 __exit__ 阻塞等 worker 退出 → 进程挂死(2026-07-09 13:21 教训)。
|
||||
# 改成手动管理 + shutdown(wait=False),主线程能立刻退出。
|
||||
pool = ThreadPoolExecutor(max_workers=max_workers)
|
||||
futures = {pool.submit(self._sync_one, primary, p[0], p[1], p[2]): p[0] for p in plans}
|
||||
try:
|
||||
for i, future in enumerate(as_completed(futures, timeout=FETCH_HARD_TIMEOUT), 1):
|
||||
c6 = futures[future]
|
||||
try:
|
||||
res = future.result()
|
||||
res = future.result(timeout=0.1) # 已被 as_completed 释放
|
||||
if res["status"] == "ok":
|
||||
ok_cnt += 1
|
||||
rows_total += res["rows"]
|
||||
@@ -142,6 +156,9 @@ class SyncKline5Min(SyncTask):
|
||||
fail_cnt += 1
|
||||
if res.get("error"):
|
||||
logger.warning(f"[5min {c6}] {res['error']}")
|
||||
except FuturesTimeoutError:
|
||||
fail_cnt += 1
|
||||
logger.warning(f"[5min {c6}] 内部 race timeout, 记 fail")
|
||||
except Exception as e:
|
||||
fail_cnt += 1
|
||||
logger.warning(f"[5min {c6}] {e}")
|
||||
@@ -150,6 +167,64 @@ class SyncKline5Min(SyncTask):
|
||||
message=f"5min 进度 {i}/{len(plans)} OK:{ok_cnt} FAIL:{fail_cnt}",
|
||||
current=i, total=len(plans), current_step=c6,
|
||||
)
|
||||
except FuturesTimeoutError:
|
||||
stuck = [c6 for _, c6 in futures.items() if not _.done()]
|
||||
logger.error(
|
||||
"[5min] 所有 fetcher 卡死 (>%ss), %d 只股票未完成",
|
||||
FETCH_HARD_TIMEOUT, len(stuck),
|
||||
)
|
||||
for f, c6 in futures.items():
|
||||
try:
|
||||
if not f.done() and hasattr(f, "cancel"):
|
||||
f.cancel()
|
||||
except Exception as e:
|
||||
logger.warning(f"[5min] cancel future for {c6} 失败: {e}")
|
||||
try:
|
||||
pool.shutdown(wait=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"[5min] pool.shutdown(wait=False) 失败: {e}")
|
||||
|
||||
# ── 补偿:冷却后重入队列 ──
|
||||
# mairui 偶发全 hang(中间件重启/网络抖动),等 30s 再试一次。
|
||||
# 如果还 hang 就放弃,下次调度增量重拉。
|
||||
if stuck:
|
||||
cooldown = 30
|
||||
logger.warning(f"[5min] 冷却 {cooldown}s 后重试 {len(stuck)} 只…")
|
||||
time.sleep(cooldown)
|
||||
retry_ok = retry_fail = retry_rows = 0
|
||||
retry_pool = ThreadPoolExecutor(max_workers=max_workers)
|
||||
plan_dict = {p[0]: (p[1], p[2]) for p in plans}
|
||||
retry_futs = {}
|
||||
for c6 in stuck:
|
||||
if c6 in plan_dict:
|
||||
s, e = plan_dict[c6]
|
||||
# 重试直接用雪球 fallback(Mairui 已全 hang)
|
||||
retry_futs[retry_pool.submit(self._fallback_xueqiu_5m_and_write, c6, s, e)] = c6
|
||||
if retry_futs:
|
||||
for fut in as_completed(retry_futs, timeout=FETCH_HARD_TIMEOUT):
|
||||
c6 = retry_futs[fut]
|
||||
try:
|
||||
res = fut.result(timeout=0.1)
|
||||
if res["status"] == "ok":
|
||||
retry_ok += 1
|
||||
retry_rows += res["rows"]
|
||||
else:
|
||||
retry_fail += 1
|
||||
except Exception:
|
||||
retry_fail += 1
|
||||
try:
|
||||
retry_pool.shutdown(wait=False)
|
||||
except Exception:
|
||||
pass
|
||||
ok_cnt += retry_ok
|
||||
fail_cnt -= retry_ok # 从 fail 挪到 ok
|
||||
rows_total += retry_rows
|
||||
logger.warning(
|
||||
f"[5min] 重试结果: {retry_ok}成 {retry_fail}败 "
|
||||
f"共{retry_rows}行, 救回 {retry_ok} 只"
|
||||
)
|
||||
else:
|
||||
pool.shutdown(wait=True)
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
msg = (
|
||||
f"5min K线 {ok_cnt}成 {fail_cnt}败 共{rows_total}行, "
|
||||
@@ -192,10 +267,16 @@ class SyncKline5Min(SyncTask):
|
||||
})
|
||||
cur = w_end + timedelta(days=1)
|
||||
except Exception as e:
|
||||
return {"status": "fail", "error": str(e)}
|
||||
logger.warning(f"[5min {code6}] mairui 失败, 尝试雪球 5m fallback: {e}")
|
||||
all_rows = self._fallback_xueqiu_5m(code6, start, end)
|
||||
|
||||
if not all_rows:
|
||||
return {"status": "fail", "error": "no data"}
|
||||
# Mairui 无数据, 尝试雪球 fallback
|
||||
logger.info(f"[5min {code6}] mairui 无数据, 尝试雪球 fallback")
|
||||
all_rows = self._fallback_xueqiu_5m(code6, start, end)
|
||||
|
||||
if not all_rows:
|
||||
return {"status": "fail", "error": "no data (mairui + xueqiu fallback)"}
|
||||
try:
|
||||
CHUNK = 5000
|
||||
for i in range(0, len(all_rows), CHUNK):
|
||||
@@ -203,3 +284,77 @@ class SyncKline5Min(SyncTask):
|
||||
except Exception as e:
|
||||
return {"status": "fail", "error": f"db write: {e}"}
|
||||
return {"status": "ok", "rows": len(all_rows)}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_xueqiu_5m_and_write(code6: str, start: datetime, end: datetime) -> dict:
|
||||
"""雪球 5m fallback + 直接写库,对齐 _sync_one 返回格式供 retry 逻辑使用。"""
|
||||
rows = SyncKline5Min._fallback_xueqiu_5m(code6, start, end)
|
||||
if not rows:
|
||||
return {"status": "fail", "error": "no data via xueqiu"}
|
||||
try:
|
||||
CHUNK = 5000
|
||||
for i in range(0, len(rows), CHUNK):
|
||||
db_ops.upsert_kline_5min(rows[i: i + CHUNK])
|
||||
except Exception as e:
|
||||
return {"status": "fail", "error": f"db write: {e}"}
|
||||
return {"status": "ok", "rows": len(rows)}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_xueqiu_5m(code6: str, start: datetime, end: datetime) -> list[dict]:
|
||||
"""通过雪球 5m K线 fallback 拉取数据,对齐 _sync_one 返回格式。"""
|
||||
import os
|
||||
from datetime import datetime as dt_mod
|
||||
from app.core.datasource.utils import code6_to_xueqiu, to_hermes
|
||||
|
||||
token_raw = os.environ.get("XUEQIU_TOKEN", "").strip()
|
||||
if not token_raw:
|
||||
# 从 .env 读
|
||||
try:
|
||||
with open("/home/gao/Development/quant_home/market_sync/.env") as f:
|
||||
for line in f:
|
||||
if line.startswith("XUEQIU_TOKEN="):
|
||||
token_raw = line.strip().split("=", 1)[1]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not token_raw:
|
||||
logger.warning("[xueqiu_5m] 无 XUEQIU_TOKEN, 跳过 fallback")
|
||||
return []
|
||||
|
||||
import pysnowball as ball
|
||||
ball.set_token(token_raw)
|
||||
|
||||
symbol = code6_to_xueqiu(code6)
|
||||
# 计算需要多少根 5m bar (每天 48 根, 加 buffer)
|
||||
days_needed = max((end - start).days + 2, 1)
|
||||
count = days_needed * 48
|
||||
|
||||
try:
|
||||
data = ball.kline(symbol, "5m", min(count, 5000))
|
||||
except Exception as e:
|
||||
logger.warning(f"[xueqiu_5m {code6}] 请求失败: {e}")
|
||||
return []
|
||||
|
||||
items = data.get("data", {}).get("item", [])
|
||||
if not items:
|
||||
return []
|
||||
|
||||
columns = data["data"]["column"] # [timestamp, volume, open, high, low, close, ...]
|
||||
idx = {c: i for i, c in enumerate(columns)}
|
||||
hermes = to_hermes(code6)
|
||||
rows = []
|
||||
for item in items:
|
||||
ts = item[idx["timestamp"]] / 1000
|
||||
bar_time = dt_mod.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
rows.append({
|
||||
"stock_code": hermes,
|
||||
"bar_time": bar_time,
|
||||
"open": float(item[idx["open"]]),
|
||||
"high": float(item[idx["high"]]),
|
||||
"low": float(item[idx["low"]]),
|
||||
"close": float(item[idx["close"]]),
|
||||
"volume": float(item[idx["volume"]]),
|
||||
"amount": 0.0,
|
||||
"turnover_rate": 0.0,
|
||||
})
|
||||
return rows
|
||||
|
||||
+152
-29
@@ -19,6 +19,7 @@ import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -32,15 +33,19 @@ from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("sync.kline_daily")
|
||||
|
||||
# 主源优先级:mairui(5 RPS,免费 + 稳定 + 单接口含5min)→ 雪球 → Baostock → 新浪
|
||||
# 经验:sina 在单日窗口 (start=end=某天) 会返回空,雪球更可靠
|
||||
PRIMARY_PRIORITY = ["datasource_xueqiu", "datasource_mairui", "datasource_xinlang"]
|
||||
# 源优先级:麦蕊 → 雪球 → 新浪
|
||||
# 2026-07-21 改: 麦蕊15:10已有数据,雪球要15:40后才齐,麦蕊为主源提速
|
||||
PRIMARY_PRIORITY = ["datasource_mairui", "datasource_xueqiu", "datasource_xinlang"]
|
||||
DEFAULT_START = "2018-01-01"
|
||||
# 写库批量:攒够 BATCH_SIZE 行就 executemany 一次 + commit
|
||||
BATCH_SIZE = 2000
|
||||
# fetcher 并发:实测单线程最稳(5000 只 × 0.2s = 17 分钟,足够)
|
||||
# 雪球 token 限速 10 RPS,单线程 0.1s/只 已经打满。多 worker 会触发风控 hang
|
||||
DEFAULT_FETCH_WORKERS = 1
|
||||
# 2026-07-08 教训: 数据源层虽然加了 fetch timeout (20s), task 层也加硬上限兜底,
|
||||
# 防止数据源 SDK 升级后又被绕过。60s 对应 xueqiu/mairui 的 15-20s 上限 + DB upsert
|
||||
# / DataFrame 处理余量 + 7月9日 mairui 间歇慢请求的容忍窗口。
|
||||
FETCH_HARD_TIMEOUT = 180.0
|
||||
|
||||
|
||||
class SyncKlineDaily(SyncTask):
|
||||
@@ -57,14 +62,15 @@ class SyncKlineDaily(SyncTask):
|
||||
max_workers: int = DEFAULT_FETCH_WORKERS,
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
# 1. 选主源
|
||||
primary = self._pick_primary()
|
||||
if primary is None:
|
||||
# 1. 获取所有可用源(按优先级),支持 per-stock fallback
|
||||
sources = self._get_available_sources()
|
||||
if not sources:
|
||||
return {"status": "error", "message": "所有 K 线数据源均不可用"}
|
||||
source_names = ", ".join(s.name for s in sources)
|
||||
|
||||
self._progress(
|
||||
message=f"使用 {primary.name} 同步日K线 (fetcher={max_workers}, batch={BATCH_SIZE})...",
|
||||
current_step=primary.key,
|
||||
message=f"使用 [{source_names}] 同步日K线 (fetcher={max_workers}, batch={BATCH_SIZE})...",
|
||||
current_step=sources[0].key,
|
||||
)
|
||||
|
||||
# 2. 拉股票列表
|
||||
@@ -108,7 +114,6 @@ class SyncKlineDaily(SyncTask):
|
||||
)
|
||||
|
||||
# 4. 并发 fetch + 攒 batch 写库(线程安全)
|
||||
# mairui 等外部 API 无并发问题;DB 写串行加锁
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
t0 = time.time()
|
||||
batch_lock = threading.Lock()
|
||||
@@ -116,6 +121,7 @@ class SyncKlineDaily(SyncTask):
|
||||
pending_codes: dict[str, str] = {}
|
||||
ok_lock = threading.Lock()
|
||||
ok_cnt = [0]
|
||||
fallback_ok_cnt = [0]
|
||||
fail_cnt = [0]
|
||||
rows_cnt = [0]
|
||||
|
||||
@@ -138,15 +144,7 @@ class SyncKlineDaily(SyncTask):
|
||||
except Exception as e:
|
||||
logger.warning(f"update synced_at 失败 {c6}: {e}")
|
||||
|
||||
def _fetch_one(code6: str, fetch_start: str) -> tuple[str, list[dict] | None, str | None]:
|
||||
try:
|
||||
df = primary.fetch_kline_daily(code6, fetch_start, _end)
|
||||
except Exception as e:
|
||||
return (code6, None, str(e)[:120])
|
||||
if df is None or df.empty:
|
||||
return (code6, None, "no data")
|
||||
# 写库用 hermes(带 SH/SZ 前缀)—— 2026-07-01 重构:历史上 kline_stock 用
|
||||
# 6位 code 与 stocks.code(SH600519) 格式不一致,跨表 JOIN 失败。现统一。
|
||||
def _rows_from_df(code6: str, df: pd.DataFrame) -> tuple[list[dict], str]:
|
||||
hermes = to_hermes(code6)
|
||||
rows = []
|
||||
for _, r in df.iterrows():
|
||||
@@ -162,20 +160,56 @@ class SyncKlineDaily(SyncTask):
|
||||
})
|
||||
latest = df["trade_date"].max()
|
||||
latest = latest.strftime("%Y-%m-%d") if hasattr(latest, "strftime") else str(latest)[:10]
|
||||
return (code6, rows, latest)
|
||||
return (rows, latest)
|
||||
|
||||
def _fetch_one(code6: str, fetch_start: str) -> tuple[str, list[dict] | None, str | None, bool]:
|
||||
errors = []
|
||||
for src in sources:
|
||||
try:
|
||||
df = src.fetch_kline_daily(code6, fetch_start, _end)
|
||||
except Exception as e:
|
||||
errors.append(f"{src.key}: {e}")
|
||||
continue
|
||||
if df is None or df.empty:
|
||||
errors.append(f"{src.key}: no data")
|
||||
continue
|
||||
rows, latest = _rows_from_df(code6, df)
|
||||
is_fallback = src is not sources[0]
|
||||
return (code6, rows, latest, is_fallback)
|
||||
return (code6, None, "; ".join(errors)[:200], False)
|
||||
|
||||
total = len(jobs)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(_fetch_one, c6, fs): c6 for c6, fs in jobs}
|
||||
done_cnt = 0
|
||||
for future in as_completed(futures):
|
||||
# 不用 `with ThreadPoolExecutor(...) as pool:` — 它的 __exit__ 默认 wait=True,
|
||||
# 一旦某个 worker 卡在 xueqiu IO,主线程会在 as_completed 触发 timeout 后
|
||||
# 仍被 __exit__ 阻塞等 worker 退出 → 进程挂死(2026-07-09 13:21 教训)。
|
||||
# 改成手动管理 + shutdown(wait=False),主线程能立刻退出。
|
||||
pool = ThreadPoolExecutor(max_workers=max_workers)
|
||||
futures = {pool.submit(_fetch_one, c6, fs): c6 for c6, fs in jobs}
|
||||
done_cnt = 0
|
||||
try:
|
||||
for future in as_completed(futures, timeout=FETCH_HARD_TIMEOUT):
|
||||
done_cnt += 1
|
||||
code6, rows, latest = future.result()
|
||||
code6 = futures[future]
|
||||
try:
|
||||
code6_r, rows, latest, is_fallback = future.result(timeout=0.1)
|
||||
except FuturesTimeoutError:
|
||||
with ok_lock:
|
||||
fail_cnt[0] += 1
|
||||
logger.warning(f"[kline {code6}] 内部 race timeout, 记 fail")
|
||||
continue
|
||||
except Exception as e:
|
||||
with ok_lock:
|
||||
fail_cnt[0] += 1
|
||||
logger.warning(f"[kline {code6}] 异常: {e}")
|
||||
continue
|
||||
if rows is None:
|
||||
with ok_lock:
|
||||
fail_cnt[0] += 1
|
||||
logger.warning(f"[kline {code6}] {latest}")
|
||||
continue
|
||||
if is_fallback:
|
||||
with ok_lock:
|
||||
fallback_ok_cnt[0] += 1
|
||||
with batch_lock:
|
||||
batch_rows.extend(rows)
|
||||
pending_codes[code6] = latest
|
||||
@@ -194,32 +228,121 @@ class SyncKlineDaily(SyncTask):
|
||||
message=f"进度 {done_cnt}/{total} OK:{ok_cnt[0]} FAIL:{fail_cnt[0]} {rate:.1f}只/秒",
|
||||
current=done_cnt, total=total, current_step=code6,
|
||||
)
|
||||
except FuturesTimeoutError:
|
||||
# 30s 内没 future 完成 → 所有 worker 都被卡死(数据源层 timeout 兜底失败)
|
||||
# 取消 pending futures,标 fail。shutdown(wait=False) 让主线程立刻返回,
|
||||
# 不被卡住的 worker 拖死。
|
||||
# 2026-07-11 修复: 防御性 cancel,避免异常处理链里变量被遮蔽导致
|
||||
# AttributeError: 'str' object has no attribute 'cancel'。
|
||||
stuck = [(f, c6) for f, c6 in futures.items() if not f.done()]
|
||||
logger.error(
|
||||
"所有 fetcher 卡死 (>%ss), %d 只股票未完成, 标 fail 后退出主循环",
|
||||
FETCH_HARD_TIMEOUT, len(stuck),
|
||||
)
|
||||
with ok_lock:
|
||||
fail_cnt[0] += len(stuck)
|
||||
for f, c6 in stuck:
|
||||
try:
|
||||
if hasattr(f, "cancel"):
|
||||
f.cancel()
|
||||
except Exception as cancel_err:
|
||||
logger.warning(f"[kline] cancel future for {c6} 失败: {cancel_err}")
|
||||
try:
|
||||
pool.shutdown(wait=False)
|
||||
except Exception as shutdown_err:
|
||||
logger.warning(f"[kline] pool.shutdown(wait=False) 失败: {shutdown_err}")
|
||||
|
||||
# ── 补偿:冷却后重入队列 ──
|
||||
# 雪球/mairui 偶发全 hang(服务端限流/网络抖动),等 30s 再试一次。
|
||||
# 单线程拉取场景下,卡住通常意味着当前这只股票请求 hang 死;
|
||||
# 冷却后把未完成的股票重新排队重试,救回临时故障。
|
||||
if stuck:
|
||||
cooldown = 30
|
||||
logger.warning(f"[kline] 冷却 {cooldown}s 后重试 {len(stuck)} 只…")
|
||||
time.sleep(cooldown)
|
||||
retry_ok = 0
|
||||
retry_fb = 0
|
||||
retry_rows = 0
|
||||
retry_pool = ThreadPoolExecutor(max_workers=max_workers)
|
||||
jobs_dict = {c6: fs for c6, fs in jobs}
|
||||
retry_futs = {}
|
||||
for _, c6 in stuck:
|
||||
if c6 in jobs_dict:
|
||||
retry_futs[retry_pool.submit(_fetch_one, c6, jobs_dict[c6])] = c6
|
||||
if retry_futs:
|
||||
try:
|
||||
for fut in as_completed(retry_futs, timeout=FETCH_HARD_TIMEOUT):
|
||||
c6 = retry_futs[fut]
|
||||
try:
|
||||
code6_r, rows, latest, is_fallback = fut.result(timeout=0.1)
|
||||
except Exception:
|
||||
logger.warning(f"[kline {c6}] 重试异常")
|
||||
continue
|
||||
if rows is None:
|
||||
logger.warning(f"[kline {c6}] 重试仍失败: {latest}")
|
||||
continue
|
||||
with batch_lock:
|
||||
batch_rows.extend(rows)
|
||||
pending_codes[c6] = latest
|
||||
cur_size = len(batch_rows)
|
||||
with ok_lock:
|
||||
retry_ok += 1
|
||||
retry_rows += len(rows)
|
||||
if is_fallback:
|
||||
retry_fb += 1
|
||||
if cur_size >= BATCH_SIZE:
|
||||
_flush()
|
||||
except FuturesTimeoutError:
|
||||
logger.error(
|
||||
"[kline] 重试仍卡死 (>%ss), %d 只股票放弃",
|
||||
FETCH_HARD_TIMEOUT, sum(1 for f in retry_futs if not f.done()),
|
||||
)
|
||||
try:
|
||||
retry_pool.shutdown(wait=False)
|
||||
except Exception:
|
||||
pass
|
||||
with ok_lock:
|
||||
ok_cnt[0] += retry_ok
|
||||
fail_cnt[0] -= retry_ok
|
||||
fallback_ok_cnt[0] += retry_fb
|
||||
rows_cnt[0] += retry_rows
|
||||
logger.warning(
|
||||
f"[kline] 重试结果: {retry_ok}成 "
|
||||
f"({retry_fb}只备胎) 共{retry_rows}行, 救回 {retry_ok} 只"
|
||||
)
|
||||
else:
|
||||
pool.shutdown(wait=True)
|
||||
|
||||
# 收尾 flush
|
||||
_flush()
|
||||
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
msg = f"日K线 {ok_cnt[0]}成 {fail_cnt[0]}败 {skipped}跳 共{rows_cnt[0]}行, {elapsed}s"
|
||||
fb = fallback_ok_cnt[0]
|
||||
fb_info = f" ({fb}只备胎)" if fb else ""
|
||||
msg = f"日K线 {ok_cnt[0]}成 {fail_cnt[0]}败 {skipped}跳 共{rows_cnt[0]}行{fb_info}, {elapsed}s"
|
||||
return {
|
||||
"status": "ok" if fail_cnt[0] == 0 else "warning",
|
||||
"message": msg,
|
||||
"ok": ok_cnt[0],
|
||||
"fail": fail_cnt[0],
|
||||
"fallback_ok": fb,
|
||||
"skip": skipped,
|
||||
"rows": rows_cnt[0],
|
||||
"elapsed_sec": elapsed,
|
||||
"primary_source": primary.key,
|
||||
"primary_source": sources[0].key if sources else "",
|
||||
"sources": [s.key for s in sources],
|
||||
}
|
||||
|
||||
def _pick_primary(self):
|
||||
def _get_available_sources(self):
|
||||
from app.core.datasource.registry import is_source_ready, run_health_check, get_health_status
|
||||
if not get_health_status():
|
||||
run_health_check()
|
||||
available = []
|
||||
for key in PRIMARY_PRIORITY:
|
||||
ok, _ = is_source_ready(key)
|
||||
if ok:
|
||||
return ds_registry.get(key)
|
||||
return None
|
||||
available.append(ds_registry.get(key))
|
||||
return available
|
||||
|
||||
def _sync_one(self, primary, code6: str, start: str, end: str) -> dict:
|
||||
"""保留这个方法以兼容外部调用(已不用,但单只测试可能用到)"""
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""同步任务:mairui 日 K 级别技术指标 MACD / KDJ / BOLL。
|
||||
|
||||
数据源:mairui /hsstock/history/{indicator}/{symbol}/d/n/{licence}
|
||||
- macd → market_data.kline_stock_macd_daily (diff, dea, macd, ema12, ema26)
|
||||
- kdj → market_data.kline_stock_kdj_daily (k, d, j)
|
||||
- boll → market_data.kline_stock_boll_daily (upper, mid, lower ← mairui u/m/d)
|
||||
|
||||
设计(对齐 task_kline_daily):
|
||||
- 全市场 A 股循环,每只票分别拉 3 个指标
|
||||
- 增量:读各指标表 MAX(trade_date),只补新增区间(回看 5 天防漏)
|
||||
- 并发 fetch + 攒 BATCH 批量 upsert,写库串行加锁
|
||||
- stock_code 落库用 hermes 格式(SH600519),与项目其他表一致
|
||||
|
||||
参考实现:pre_limit_seeker/production/sync_mairui_indicators.py(parquet 版)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import ops as db_ops
|
||||
from app.core.datasource.base import registry as ds_registry
|
||||
from app.core.datasource.utils import is_a_share_code, to_code6, to_hermes
|
||||
from app.core.sync.base import SyncTask, _effective_sync_end
|
||||
from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("sync.mairui_indicators")
|
||||
|
||||
DEFAULT_START = "2018-01-01"
|
||||
BATCH_SIZE = 2000
|
||||
# mairui 钻石档 100 RPS;5 workers × 10 RPS/worker = 50 RPS,留 buffer
|
||||
DEFAULT_FETCH_WORKERS = 5
|
||||
|
||||
# 指标 → (mairui 源字段, 落库列名, upsert 函数)
|
||||
INDICATORS: dict[str, dict[str, Any]] = {
|
||||
"macd": {
|
||||
"src_fields": ["diff", "dea", "macd", "ema12", "ema26"],
|
||||
"db_cols": ["diff", "dea", "macd", "ema12", "ema26"],
|
||||
"upsert": db_ops.upsert_kline_stock_macd_daily_rows,
|
||||
},
|
||||
"kdj": {
|
||||
"src_fields": ["k", "d", "j"],
|
||||
"db_cols": ["k", "d", "j"],
|
||||
"upsert": db_ops.upsert_kline_stock_kdj_daily_rows,
|
||||
},
|
||||
"boll": {
|
||||
# mairui: u=上轨, m=中轨, d=下轨 → 落库 upper/mid/lower
|
||||
"src_fields": ["u", "m", "d"],
|
||||
"db_cols": ["upper", "mid", "lower"],
|
||||
"upsert": db_ops.upsert_kline_stock_boll_daily_rows,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SyncMairuiIndicators(SyncTask):
|
||||
dataset_id = "mairui_indicators"
|
||||
|
||||
def _run(
|
||||
self,
|
||||
*,
|
||||
trigger_source: str = "manual",
|
||||
indicators: list[str] | None = None,
|
||||
codes: list[str] | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
incremental: bool = True,
|
||||
max_workers: int = DEFAULT_FETCH_WORKERS,
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
targets = [i.lower() for i in (indicators or ["macd", "kdj", "boll"])]
|
||||
for i in targets:
|
||||
if i not in INDICATORS:
|
||||
return {"status": "error", "message": f"未知指标 {i!r},可选 {list(INDICATORS)}"}
|
||||
|
||||
source = self._pick_source()
|
||||
if source is None:
|
||||
return {"status": "error", "message": "mairui 数据源不可用(licence 未配置?)"}
|
||||
|
||||
# 股票列表(hermes 格式)
|
||||
if codes:
|
||||
hermes_codes = [to_hermes(c) for c in codes]
|
||||
else:
|
||||
hermes_codes = [
|
||||
to_hermes(c) for c in db_ops.iter_stock_codes(active_only=True)
|
||||
if is_a_share_code(c)
|
||||
]
|
||||
limit_raw = os.environ.get("MARKET_DATA_STOCK_LIMIT", "").strip()
|
||||
if limit_raw.isdigit() and int(limit_raw) > 0:
|
||||
hermes_codes = hermes_codes[: int(limit_raw)]
|
||||
if not hermes_codes:
|
||||
return {"status": "error", "message": "无股票代码(stocks 表为空?)"}
|
||||
|
||||
_end = end or _effective_sync_end()
|
||||
end_date_obj = datetime.strptime(_end, "%Y-%m-%d").date()
|
||||
|
||||
t0 = time.time()
|
||||
agg_ok = 0
|
||||
agg_fail = 0
|
||||
agg_rows = 0
|
||||
per_indicator: dict[str, dict[str, int]] = {}
|
||||
|
||||
for ind in targets:
|
||||
self._progress(message=f"[{ind}] 规划增量区间...", current_step=ind)
|
||||
jobs = self._plan_jobs(ind, hermes_codes, start, incremental, end_date_obj)
|
||||
total = len(jobs)
|
||||
skipped = len(hermes_codes) - total
|
||||
logger.info(f"[mairui_indicators] {ind}: to_pull={total}, skip={skipped}")
|
||||
self._progress(
|
||||
message=f"[{ind}] 开始同步 {total} 只 (跳过 {skipped} 只已最新)",
|
||||
current=0, total=total, current_step=ind,
|
||||
)
|
||||
|
||||
ok, fail, rows = self._sync_indicator(
|
||||
source, ind, jobs, _end, max_workers,
|
||||
)
|
||||
per_indicator[ind] = {"ok": ok, "fail": fail, "skip": skipped, "rows": rows}
|
||||
agg_ok += ok
|
||||
agg_fail += fail
|
||||
agg_rows += rows
|
||||
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
parts = ", ".join(
|
||||
f"{k}({v['ok']}成/{v['fail']}败/{v['rows']}行)"
|
||||
for k, v in per_indicator.items()
|
||||
)
|
||||
msg = f"指标 {parts};共 {agg_rows} 行, {elapsed}s"
|
||||
logger.info(f"[mairui_indicators] {msg}")
|
||||
return {
|
||||
"status": "ok" if agg_fail == 0 else "warning",
|
||||
"message": msg,
|
||||
"ok": agg_ok,
|
||||
"fail": agg_fail,
|
||||
"rows": agg_rows,
|
||||
"elapsed_sec": elapsed,
|
||||
"per_indicator": per_indicator,
|
||||
}
|
||||
|
||||
def _plan_jobs(
|
||||
self, indicator: str, hermes_codes: list[str],
|
||||
start: str | None, incremental: bool, end_date_obj,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""返回 [(hermes_code, fetch_start), ...]。"""
|
||||
jobs: list[tuple[str, str]] = []
|
||||
if incremental and not start:
|
||||
for hc in hermes_codes:
|
||||
last = db_ops.get_indicator_max_date(indicator, hc)
|
||||
if last is None:
|
||||
fetch_start = DEFAULT_START
|
||||
elif datetime.strptime(last, "%Y-%m-%d").date() >= end_date_obj:
|
||||
continue
|
||||
else:
|
||||
fetch_start = (
|
||||
datetime.strptime(last, "%Y-%m-%d").date() - timedelta(days=5)
|
||||
).strftime("%Y-%m-%d")
|
||||
jobs.append((hc, fetch_start))
|
||||
else:
|
||||
fetch_start = start or DEFAULT_START
|
||||
jobs = [(hc, fetch_start) for hc in hermes_codes]
|
||||
return jobs
|
||||
|
||||
def _sync_indicator(
|
||||
self, source, indicator: str, jobs: list[tuple[str, str]],
|
||||
end: str, max_workers: int,
|
||||
) -> tuple[int, int, int]:
|
||||
cfg = INDICATORS[indicator]
|
||||
upsert_fn = cfg["upsert"]
|
||||
src_fields = cfg["src_fields"]
|
||||
db_cols = cfg["db_cols"]
|
||||
|
||||
batch_lock = threading.Lock()
|
||||
batch_rows: list[dict] = []
|
||||
ok_lock = threading.Lock()
|
||||
ok_cnt = [0]
|
||||
fail_cnt = [0]
|
||||
rows_cnt = [0]
|
||||
|
||||
def _flush():
|
||||
with batch_lock:
|
||||
if not batch_rows:
|
||||
return
|
||||
to_write = list(batch_rows)
|
||||
batch_rows.clear()
|
||||
try:
|
||||
upsert_fn(to_write)
|
||||
except Exception as e:
|
||||
logger.error(f"[{indicator}] batch upsert 失败 ({len(to_write)} 行): {e}")
|
||||
|
||||
def _fetch_one(hermes_code: str, fetch_start: str):
|
||||
code6 = to_code6(hermes_code)
|
||||
try:
|
||||
df = source.fetch_indicator_daily(indicator, code6, fetch_start, end)
|
||||
except Exception as e:
|
||||
return (hermes_code, None, str(e)[:120])
|
||||
if df is None or df.empty:
|
||||
return (hermes_code, None, "no data")
|
||||
rows = []
|
||||
for _, r in df.iterrows():
|
||||
td = r["trade_date"]
|
||||
# 2026-07-11 修复: mairui 可能返回整行 NaN/NaT(如 688 早期无指标),
|
||||
# float(NaT) 会抛 TypeError。用 pandas.isna 统一跳过无效值。
|
||||
row = {
|
||||
"stock_code": hermes_code,
|
||||
"trade_date": td.strftime("%Y-%m-%d") if hasattr(td, "strftime") else str(td)[:10],
|
||||
"source": "mairui",
|
||||
}
|
||||
for src_f, db_c in zip(src_fields, db_cols):
|
||||
v = r.get(src_f)
|
||||
if v is None or (hasattr(v, "isna") and v.isna()) or (isinstance(v, float) and v != v):
|
||||
row[db_c] = None
|
||||
else:
|
||||
try:
|
||||
row[db_c] = float(v)
|
||||
except Exception:
|
||||
row[db_c] = None
|
||||
rows.append(row)
|
||||
return (hermes_code, rows, None)
|
||||
|
||||
total = len(jobs)
|
||||
t0 = time.time()
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(_fetch_one, hc, fs): hc for hc, fs in jobs}
|
||||
done = 0
|
||||
for fut in as_completed(futures):
|
||||
done += 1
|
||||
hermes_code, rows, err = fut.result()
|
||||
if rows is None:
|
||||
with ok_lock:
|
||||
fail_cnt[0] += 1
|
||||
continue
|
||||
with batch_lock:
|
||||
batch_rows.extend(rows)
|
||||
cur = len(batch_rows)
|
||||
with ok_lock:
|
||||
ok_cnt[0] += 1
|
||||
rows_cnt[0] += len(rows)
|
||||
if cur >= BATCH_SIZE:
|
||||
_flush()
|
||||
if done % 100 == 0 or done == total:
|
||||
el = time.time() - t0
|
||||
rate = done / el if el > 0 else 0
|
||||
self._progress(
|
||||
message=f"[{indicator}] {done}/{total} OK:{ok_cnt[0]} FAIL:{fail_cnt[0]} {rate:.1f}只/秒",
|
||||
current=done, total=total, current_step=indicator,
|
||||
)
|
||||
_flush()
|
||||
return ok_cnt[0], fail_cnt[0], rows_cnt[0]
|
||||
|
||||
def _pick_source(self):
|
||||
from app.core.datasource.registry import (
|
||||
get_health_status, is_source_ready, run_health_check,
|
||||
)
|
||||
if not get_health_status():
|
||||
run_health_check()
|
||||
ok, _ = is_source_ready("datasource_mairui")
|
||||
if ok:
|
||||
return ds_registry.get("datasource_mairui")
|
||||
return None
|
||||
@@ -3,7 +3,6 @@
|
||||
基于本地 kline_stock 数据聚合:advancers / decliners / turnover / advance_ratio。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -12,7 +11,7 @@ from sqlalchemy import text
|
||||
|
||||
from app.core.db import ops as db_ops
|
||||
from app.core.db.orm import engine as pg_engine
|
||||
from app.core.sync.base import SyncTask
|
||||
from app.core.sync.base import SyncTask, _effective_sync_end
|
||||
from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("sync.market_regime")
|
||||
@@ -34,7 +33,16 @@ class SyncMarketRegime(SyncTask):
|
||||
dataset_id = "market_regime"
|
||||
|
||||
def _run(self, *, trigger_source: str = "manual", start: str = DEFAULT_START, **kwargs) -> dict[str, Any]:
|
||||
end = datetime.now().strftime("%Y-%m-%d")
|
||||
# 2026-07-12 修复: end 用 kline_stock 最大 trade_date,而不是 datetime.now()
|
||||
# 避免节假日/周末跑时把非交易日当 end,导致 SQL 条件不精确
|
||||
try:
|
||||
with pg_engine.connect() as conn:
|
||||
max_date = conn.execute(
|
||||
text("SELECT MAX(trade_date) FROM market_data.kline_stock")
|
||||
).scalar()
|
||||
end = str(max_date) if max_date else _effective_sync_end()
|
||||
except Exception:
|
||||
end = _effective_sync_end()
|
||||
self._progress(message=f"构建 market_regime {start} ~ {end}...")
|
||||
|
||||
# 1. 取所有非退市股票代码
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
"""同步任务:行业聚合特征(衍生计算)。
|
||||
|
||||
输入:kline_stock(日 K 线)+ industry(股票→行业映射)
|
||||
输出:
|
||||
- sector_indices :行业日线(trade_date × sector_name × close × sector_amplitude)
|
||||
- sector_features_daily :行业特征(+ sector_ret + ema10/20/200 + score)
|
||||
|
||||
算法(参考 dashboard/api/services/sync/sector.py 重写,持久层改为 PG):
|
||||
1) 每只股票日涨跌幅 pct_chg = (close/prev_close - 1) * 100
|
||||
2) 按行业聚合 sector_ret = 该行业所有股票当日 pct_chg 的均值
|
||||
3) 行业指数 close = 100 × ∏(1 + sector_ret/100) (基点 100,复合收益)
|
||||
4) 行业振幅 sector_amplitude = 该行业所有股票当日 (high-low)/close 的均值
|
||||
5) EMA10/20/200 = close 的指数移动平均(adjust=False)
|
||||
6) score: 0/1/2 — close>ema200 + 1,ema10>ema20 + 1
|
||||
|
||||
无外部 API 调用,纯本地计算;增量逻辑以"全量重算"实现(数据量小 ~75K 行)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import ops as db_ops
|
||||
from app.core.sync.base import SyncTask
|
||||
from app.core.utils.logging import get_logger
|
||||
|
||||
logger = get_logger("sync.sector_features")
|
||||
|
||||
|
||||
# 起算点:DB 里 kline_stock 最早一天 往前 5 年,或固定一个默认起点
|
||||
# (不用写死 2021-04-24,让数据自己决定;下面自动算)
|
||||
DEFAULT_START_YEARS_BACK = 5
|
||||
|
||||
# 输出基点(行业指数 close 从多少开始)
|
||||
INDEX_BASE = 100.0
|
||||
|
||||
|
||||
class SyncSectorFeatures(SyncTask):
|
||||
dataset_id = "sector_features"
|
||||
|
||||
def _run(
|
||||
self,
|
||||
*,
|
||||
trigger_source: str = "manual",
|
||||
codes: list[str] | None = None,
|
||||
max_workers: int = 1, # 本任务纯本地计算,单线程足够
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
t0 = time.time()
|
||||
logger.info("[sector] 启动行业聚合特征计算")
|
||||
|
||||
# ── 1) 拉 kline_stock(日线)只取需要的列 ──
|
||||
# 走 SQLAlchemy engine + PG URL(pd.read_sql 一次性读 1.1kw 行 → pandas DataFrame)
|
||||
engine = create_engine(settings.pg_sqlalchemy_url())
|
||||
df_kline = pd.read_sql(
|
||||
'SELECT stock_code, trade_date, open, high, low, "close", volume '
|
||||
'FROM market_data.kline_stock ORDER BY stock_code, trade_date',
|
||||
engine,
|
||||
)
|
||||
if df_kline.empty:
|
||||
return {"status": "error", "message": "kline_stock 为空"}
|
||||
df_kline["stock_code"] = df_kline["stock_code"].astype(str).str.zfill(6)
|
||||
df_kline["trade_date"] = pd.to_datetime(df_kline["trade_date"], errors="coerce")
|
||||
logger.info(f"[sector] kline_stock: {len(df_kline):,} 行, {df_kline['stock_code'].nunique()} 只, "
|
||||
f"{df_kline['trade_date'].min().date()} ~ {df_kline['trade_date'].max().date()}")
|
||||
|
||||
# ── 2) 拉 industry(股票→行业映射)──
|
||||
df_ind = pd.read_sql(
|
||||
"SELECT code, industry_name FROM market_data.industry WHERE industry_name IS NOT NULL",
|
||||
engine,
|
||||
)
|
||||
if df_ind.empty:
|
||||
return {"status": "error", "message": "industry 表为空"}
|
||||
df_ind["code"] = df_ind["code"].astype(str).str.zfill(6)
|
||||
df_ind = df_ind.drop_duplicates(subset=["code"], keep="first")
|
||||
logger.info(f"[sector] industry 映射: {len(df_ind):,} 行, {df_ind['industry_name'].nunique()} 个行业")
|
||||
|
||||
# ── 3) 算每只股票日涨跌幅 + 振幅 ──
|
||||
df = df_kline.merge(df_ind, left_on="stock_code", right_on="code", how="inner")
|
||||
df = df.sort_values(["stock_code", "trade_date"])
|
||||
df["prev_close"] = df.groupby("stock_code")["close"].shift(1)
|
||||
df["stock_amplitude"] = (df["high"] - df["low"]) / df["close"]
|
||||
df = df.dropna(subset=["prev_close", "industry_name"])
|
||||
df["pct_chg"] = (df["close"] / df["prev_close"] - 1.0) * 100.0
|
||||
logger.info(f"[sector] 合并后: {len(df):,} 行, 行业 {df['industry_name'].nunique()} 个")
|
||||
|
||||
# ── 4) 按行业 + 日期聚合 ──
|
||||
sector_daily = (
|
||||
df.groupby(["industry_name", "trade_date"], as_index=False).agg(
|
||||
sector_ret=("pct_chg", "mean"),
|
||||
sector_amplitude=("stock_amplitude", "mean"),
|
||||
)
|
||||
.rename(columns={"industry_name": "sector_name"})
|
||||
.sort_values(["sector_name", "trade_date"])
|
||||
.reset_index(drop=True)
|
||||
)
|
||||
logger.info(f"[sector] 行业日聚合: {len(sector_daily):,} 行")
|
||||
|
||||
# ── 5) 算行业指数 close(基点 100,复合)──
|
||||
sector_index = self._build_index(sector_daily)
|
||||
|
||||
# ── 6) EMA + score ──
|
||||
sector_index = self._calc_ema(sector_index)
|
||||
sector_index["score"] = sector_index.apply(
|
||||
lambda r: self._calc_score(r["close"], r["ema10"], r["ema20"], r["ema200"]),
|
||||
axis=1,
|
||||
)
|
||||
|
||||
# 整理列名
|
||||
out_cols = ["trade_date", "sector_name", "sector_ret", "sector_amplitude",
|
||||
"close", "ema10", "ema20", "ema200", "score"]
|
||||
sector_index = sector_index[out_cols]
|
||||
sector_index["trade_date"] = sector_index["trade_date"].dt.strftime("%Y-%m-%d")
|
||||
# NaN → None(让 MySQL 接受 NULL)
|
||||
sector_index = sector_index.where(pd.notnull(sector_index), None)
|
||||
|
||||
# ── 7) 写入两张表 ──
|
||||
# 7a. sector_indices (4 列)
|
||||
si_rows = sector_index[["trade_date", "sector_name", "close", "sector_amplitude"]].to_dict("records")
|
||||
db_ops.replace_all_sector_indices(si_rows)
|
||||
# 7b. sector_features_daily (9 列)
|
||||
sf_rows = sector_index.to_dict("records")
|
||||
db_ops.replace_all_sector_features(sf_rows)
|
||||
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
msg = (
|
||||
f"行业聚合 {sector_index['sector_name'].nunique()} 个行业 "
|
||||
f"× {sector_index['trade_date'].nunique()} 天, "
|
||||
f"共 {len(sector_index):,} 行, {elapsed}s"
|
||||
)
|
||||
logger.info(f"[sector] {msg}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": msg,
|
||||
"sectors": int(sector_index["sector_name"].nunique()),
|
||||
"days": int(sector_index["trade_date"].nunique()),
|
||||
"rows": int(len(sector_index)),
|
||||
"elapsed_sec": elapsed,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_index(sector_daily: pd.DataFrame) -> pd.DataFrame:
|
||||
"""行业指数 close = INDEX_BASE × ∏(1 + sector_ret/100)"""
|
||||
out = []
|
||||
for sector_name, group in sector_daily.groupby("sector_name", sort=False):
|
||||
g = group.sort_values("trade_date").copy()
|
||||
close_vals = []
|
||||
cur_base = INDEX_BASE
|
||||
for ret in g["sector_ret"].to_numpy(dtype=float):
|
||||
if pd.isna(ret):
|
||||
close_vals.append(cur_base)
|
||||
else:
|
||||
cur_base = cur_base * (1 + float(ret) / 100.0)
|
||||
close_vals.append(cur_base)
|
||||
g["close"] = close_vals
|
||||
out.append(g)
|
||||
return pd.concat(out, ignore_index=True)
|
||||
|
||||
@staticmethod
|
||||
def _calc_ema(sector_index: pd.DataFrame) -> pd.DataFrame:
|
||||
"""每个行业分别算 EMA10/20/200"""
|
||||
out = []
|
||||
for sector_name, group in sector_index.groupby("sector_name", sort=False):
|
||||
g = group.sort_values("trade_date").copy()
|
||||
g["ema10"] = g["close"].ewm(span=10, adjust=False).mean()
|
||||
g["ema20"] = g["close"].ewm(span=20, adjust=False).mean()
|
||||
g["ema200"] = g["close"].ewm(span=200, adjust=False).mean()
|
||||
out.append(g)
|
||||
return pd.concat(out, ignore_index=True)
|
||||
|
||||
@staticmethod
|
||||
def _calc_score(close, ema10, ema20, ema200) -> int:
|
||||
score = 0
|
||||
if pd.notna(close) and pd.notna(ema200) and close > ema200:
|
||||
score += 1
|
||||
if pd.notna(ema10) and pd.notna(ema20) and ema10 > ema20:
|
||||
score += 1
|
||||
return int(score)
|
||||
@@ -91,16 +91,25 @@ class SyncShareSnapshot(SyncTask):
|
||||
# 与 stocks.code 保持一致,方便 JOIN。
|
||||
# 历史 bug:之前 share 表写的是裸 6 位 c6,导致 share JOIN stocks 失败。
|
||||
# 一次性 UPDATE(2026-07-01)已把存量 6.3 万行加上前缀。
|
||||
# trade_date 必须用源数据返回的交易日(数据源层负责"最近已完成交易日"
|
||||
# 兜底),不允许用 today 兜底 — 7月9日 早盘跑任务时 today=7月9日,
|
||||
# 但股本快照实际对应 7月8日 收盘。早期代码用 today 会让 share 表里
|
||||
# 同一股本被重复写多行,统计/回溯出错。
|
||||
trade_date = r.get("trade_date")
|
||||
if not trade_date:
|
||||
fail_cnt += 1
|
||||
logger.warning(f"[share {c6}] 源数据未返回 trade_date,跳过(避免用运行日兜底)")
|
||||
continue
|
||||
hermes = self._to_hermes(c6)
|
||||
db_ops.update_stock_share_snapshot(
|
||||
code=hermes,
|
||||
total_share=r["total_share"],
|
||||
float_share=r["float_share"],
|
||||
trade_date=r.get("trade_date", today),
|
||||
trade_date=trade_date,
|
||||
)
|
||||
rows_to_share_table.append({
|
||||
"stock_code": hermes,
|
||||
"trade_date": r.get("trade_date", today),
|
||||
"trade_date": trade_date,
|
||||
"total_share": r["total_share"],
|
||||
"float_share": r["float_share"],
|
||||
})
|
||||
|
||||
@@ -104,7 +104,6 @@ class SyncStocksBasic(SyncTask):
|
||||
exchange=old.get("exchange", ""),
|
||||
list_date=old.get("list_date", ""),
|
||||
listing_status="delisted",
|
||||
industry=old.get("industry", ""),
|
||||
)
|
||||
delisted += 1
|
||||
|
||||
@@ -197,11 +196,19 @@ class SyncStocksBasic(SyncTask):
|
||||
if r is None:
|
||||
fail += 1
|
||||
else:
|
||||
# trade_date 必须是源数据返回的交易日(数据源层用 effective_market_date
|
||||
# 兜底为"最近已完成交易日"),绝不允许用 today 兜底 — 7月9日 早盘跑
|
||||
# 时 today=7月9日,但股本快照实际对应 7月8日 收盘。
|
||||
trade_date = r.get("trade_date")
|
||||
if not trade_date:
|
||||
fail += 1
|
||||
logger.warning(f"[share {c6}] 源数据未返回 trade_date,跳过(避免用运行日兜底)")
|
||||
continue
|
||||
db_ops.update_stock_share_snapshot(
|
||||
code=self._to_hermes(c6),
|
||||
total_share=r["total_share"],
|
||||
float_share=r["float_share"],
|
||||
trade_date=r.get("trade_date", today),
|
||||
trade_date=trade_date,
|
||||
)
|
||||
ok += 1
|
||||
if done % 100 == 0:
|
||||
|
||||
@@ -12,12 +12,15 @@
|
||||
trade_date 可能比 wall-clock 早一天;重跑靠 PK + ON CONFLICT 幂等
|
||||
4) 单只股票日均 5w-10w tick,CHUNK=2000 upsert 防 driver 撑爆
|
||||
5) 数据是"当天 only",无 start/end 窗口;不存增量概念
|
||||
6) 2026-07-13 加固: 加上外层 FETCH_HARD_TIMEOUT,避免 mairui 挂起时
|
||||
as_completed 无 timeout 导致整个 task 永久卡住(systemd 2h 超时杀)。
|
||||
改用手动 pool 管理 + shutdown(wait=False),与 kline_daily 对齐。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -36,6 +39,10 @@ MAIRUI_TICK_TRADE_PUBLISH_HOUR = 21
|
||||
MAIRUI_TICK_TRADE_PUBLISH_MIN = 0
|
||||
# 21:00 之后再跑(留 5 分钟缓冲,等 mairui 完整入库)
|
||||
TICK_TRADE_RUN_GATE = (MAIRUI_TICK_TRADE_PUBLISH_HOUR, MAIRUI_TICK_TRADE_PUBLISH_MIN + 5)
|
||||
# 外层超时: mairui _fetch 有 20s timeout,但 as_completed 无 timeout 的话
|
||||
# 若所有 worker 同时被 mairui 挂起(罕见),task 会永久卡住等 systemd 2h 杀。
|
||||
# 120s 内无任何 future 完成 → 判全部失败退出。
|
||||
FETCH_HARD_TIMEOUT = 120.0
|
||||
|
||||
|
||||
def _passes_publish_gate(now: Optional[datetime] = None, *, force: bool = False) -> tuple[bool, str]:
|
||||
@@ -108,15 +115,18 @@ class SyncTickTrade(SyncTask):
|
||||
)
|
||||
|
||||
# ── 执行 ──
|
||||
# 不用 `with ThreadPoolExecutor` — 与外层 as_completed timeout 配合,
|
||||
# 超时后 `with` 的 shutdown(wait=True) 会阻塞等卡死线程 → 进程挂死(同 kline_daily 教训)。
|
||||
t0 = time.time()
|
||||
ok_cnt = fail_cnt = 0
|
||||
rows_total = 0
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(self._sync_one, mr, c6): c6 for c6 in stock_codes}
|
||||
for i, future in enumerate(as_completed(futures), 1):
|
||||
pool = ThreadPoolExecutor(max_workers=max_workers)
|
||||
futures = {pool.submit(self._sync_one, mr, c6): c6 for c6 in stock_codes}
|
||||
try:
|
||||
for i, future in enumerate(as_completed(futures, timeout=FETCH_HARD_TIMEOUT), 1):
|
||||
c6 = futures[future]
|
||||
try:
|
||||
res = future.result()
|
||||
res = future.result(timeout=0.1)
|
||||
if res["status"] == "ok":
|
||||
ok_cnt += 1
|
||||
rows_total += res["rows"]
|
||||
@@ -124,6 +134,9 @@ class SyncTickTrade(SyncTask):
|
||||
fail_cnt += 1
|
||||
if res.get("error"):
|
||||
logger.warning(f"[tick_trade {c6}] {res['error']}")
|
||||
except FuturesTimeoutError:
|
||||
fail_cnt += 1
|
||||
logger.warning(f"[tick_trade {c6}] 内部 race timeout, 记 fail")
|
||||
except Exception as e:
|
||||
fail_cnt += 1
|
||||
logger.warning(f"[tick_trade {c6}] {e}")
|
||||
@@ -132,6 +145,25 @@ class SyncTickTrade(SyncTask):
|
||||
message=f"tick_trade 进度 {i}/{total} OK:{ok_cnt} FAIL:{fail_cnt} 行:{rows_total}",
|
||||
current=i, total=total, current_step=c6,
|
||||
)
|
||||
except FuturesTimeoutError:
|
||||
stuck = [(f, c6) for f, c6 in futures.items() if not f.done()]
|
||||
logger.error(
|
||||
"[tick_trade] 所有 fetcher 卡死 (>%ss), %d 只股票未完成, 标 fail 后退出",
|
||||
FETCH_HARD_TIMEOUT, len(stuck),
|
||||
)
|
||||
fail_cnt += len(stuck)
|
||||
for f, c6 in stuck:
|
||||
try:
|
||||
if hasattr(f, "cancel"):
|
||||
f.cancel()
|
||||
except Exception as e:
|
||||
logger.warning(f"[tick_trade] cancel future for {c6} 失败: {e}")
|
||||
try:
|
||||
pool.shutdown(wait=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"[tick_trade] pool.shutdown(wait=False) 失败: {e}")
|
||||
else:
|
||||
pool.shutdown(wait=True)
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
msg = f"逐笔 {ok_cnt}成 {fail_cnt}败 共{rows_total}行, {elapsed}s"
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user