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:
gao
2026-07-21 16:37:00 +08:00
parent 91e83a3b0b
commit 8f016f25df
94 changed files with 4117 additions and 1176 deletions
+6
View File
@@ -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"
+132 -1
View File
@@ -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
View File
@@ -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 = DEADIF 的 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
View File
@@ -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_dateYYYY-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
+13 -24
View File
@@ -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):
+7 -7
View File
@@ -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
View File
@@ -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,
},
]