7a985dddd5
## 1. PG-only 重构
- 删 app/core/db/connection.py + schema.py (MySQL 路径)
- 新 app/core/db/{orm,models,pg_bootstrap}.py — SQLAlchemy 2.x ORM 一键建表
- 16 张业务表全在 market_data schema,原生 TIMESTAMPTZ / JSONB / Float / TEXT
- requirements.txt 删 PyMySQL 路径,加 psycopg2
## 2. 同步任务扩展(3 个新 task)
- **tick_trade** (mairui hsrl/zbjy):当天逐笔交易,21:00 发布
- **moneyflow** (mairui hsstock/history/transaction):个股资金流,21:30 发布
- **longhubang** (akshare):龙虎榜聚合层 + 席位层(2 张新表)
- 长虎榜放宽 akshare 政策:仅"无替代源 + 烟测通过"场景允许
- data_eastmoney 私有 API 不需要(akshare 烟测通过)
- 3-timer 设计:
- 15:30 market-sync.service (8 base tasks via runall_once)
- 21:05 market-sync-tick.service (tick_trade)
- 21:35 market-sync-moneyflow.service (moneyflow)
- 22:00 market-sync-lhb.service (longhubang,新加)
- bin/systemd/ 新增 tick / moneyflow / lhb 各 1 对 service+timer
- bin/market_sync_*_run.sh wrapper 脚本(不做法定节假日过滤,fail-open)
## 3. stock_code 统一为带 SH/SZ/BJ 前缀
- 历史 bug:stocks.code 用 SH600519,但 kline/moneyflow/tick_trade/kline_5min
/stock_sector_map/industry 6 张表用纯 6 位 600519,跨表 JOIN 全部 0 行
- 新增 to_hermes() 工具:6位 / 9位(mairui `000001.SZ` 格式)→ 统一 SH000001
- 5 个 task 改写:用 to_hermes(code6) 写入 stock_code
- 一次性迁移 6 张表存量 154M 行(CASE WHEN 探测 + 去重 + 加前缀)
- ORM: stock_sector_map.stock_code / industry.code String(6)→String(10)
## 4. Bug 修复
- **share table stock_code 格式**:之前写 6 位不带前缀,与 stocks 不一致
→ 修 task_share_snapshot + 一次性 UPDATE 63,417 行加前缀
- **share_snapshot warning 状态错填 last_error**:
→ 加 mark_sync_warning() 走专用路径,不写 last_failure_at / last_error
- **schedule config lastRun 不同步**:
→ 加 update_job_status_for_dataset(),SyncTask.run() 完成后自动镜像
→ cli/runall 触发的 task 也能更新 schedule config
## 5. mairui UTF-8 编码修复
- 历史 bug:mairui.py:_fetch 用 latin-1 兜底解码,把所有 UTF-8 中文名
double-encoded 写入 stocks.name(如 `歌华有线` 变成 `æ\xad\x8cå\x8d\x8e...`)
- 加 _decode_response():UTF-8 → GBK → latin-1 兜底
- 一次性修复 stocks.name 5,213 行:
- 4,370 行 (encode('latin-1').decode('utf-8') 反向解码)
- 616 行 (含 fullwidth A,宽松 printable 检查)
- 820 行 (mid-character 截断,重新从 mairui 拉)
## 6. 测试
- tests/test_smoke.py: TASKS 10→11, SYNC_DEFINITIONS 10→11
- tests/test_schema_models.py: 16→18 张表,新增 longhubang_daily/seat
- pytest 11/11 passed
## 验证
- 6 张表 0 残留无前缀行
- stocks JOIN kline_stock / kline_5min / moneyflow / tick_trade / stock_sector_map:88-100% 命中
- 5,213 stocks.name 全部正确 UTF-8 中文
- pytest 11/11 passed
184 lines
7.9 KiB
Python
184 lines
7.9 KiB
Python
"""同步任务:行业聚合特征(衍生计算)。
|
||
|
||
输入: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)
|