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:
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user