Files
market_sync/tests/test_smoke.py
T
gao 7a985dddd5 refactor: PG-only 迁移 + 龙虎榜/tick/moneyflow 同步 + stock_code 统一 + mairui 编码修复
## 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
2026-07-01 22:00:58 +08:00

104 lines
3.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""冒烟测试:不依赖 MySQL/网络,只验证 import + 基本逻辑。
跑:pytest tests/test_smoke.py -v
"""
import pytest
def test_import_config():
from app.core.config import settings
assert settings is not None
assert hasattr(settings, "pg_host")
def test_import_data_source_base():
from app.core.datasource.base import DataSource, FetchResult, registry
assert DataSource is not None
assert FetchResult is not None
assert registry is not None
# registry 应该是 dict-like
assert hasattr(registry, "register")
assert hasattr(registry, "get")
def test_import_sync_base():
from app.core.sync.base import SyncTask, _is_market_closed, _effective_sync_end
assert SyncTask is not None
assert isinstance(_is_market_closed(), bool)
assert isinstance(_effective_sync_end(), str)
# 同步任务基类不允许空 dataset_id
with pytest.raises(ValueError):
SyncTask()
def test_tasks_registry():
from app.tasks import TASKS, get_task
assert isinstance(TASKS, dict)
# 注:每次新增 task 都要更新这里的数字
# 当前 11 个:stock_basic, kline_daily, kline_index, kline_5min, tick_trade,
# moneyflow, industry_sector, sector_features, share_snapshot, market_regime,
# longhubang
assert len(TASKS) == 11
# get_task 应该返回实例
task = get_task("kline_daily")
assert task.dataset_id == "kline_daily"
def test_sync_definitions():
from app.core.sync.registry import SYNC_DEFINITIONS
assert len(SYNC_DEFINITIONS) == 11
# 所有 dataset_id 应唯一
ids = [d["dataset_id"] for d in SYNC_DEFINITIONS]
assert len(set(ids)) == 11
# tick_trade 应在其中且 sort_order=45
tt = next(d for d in SYNC_DEFINITIONS if d["dataset_id"] == "tick_trade")
assert tt["sort_order"] == 45
assert "stock_basic" in tt["dependency_ids"]
# longhubang 应在其中且 sort_order=85
lh = next(d for d in SYNC_DEFINITIONS if d["dataset_id"] == "longhubang")
assert lh["sort_order"] == 85
assert "stock_basic" in lh["dependency_ids"]
def test_build_default_registry():
"""不连接网络,只看是否能完成注册(部分源 may not available 不影响注册)。"""
from app.core.datasource.base import registry
from app.core.datasource.registry import build_default_registry
build_default_registry()
keys = [s.key for s in registry.all()]
assert "datasource_xinlang" in keys
assert "datasource_baostock" in keys
assert "datasource_mairui" in keys
# akshare2026-07-01 决策更新——龙虎榜无替代源,烟测通过,可注册。
# 见 [[no-akshare]] 记忆:仅"无替代"+"akshare 烟测 OK"才允许用。
assert "datasource_akshare_lhb" in keys
assert "datasource_xueqiu" in keys
def test_data_source_abstract():
"""DataSource 抽象方法未实现时不应能被直接实例化。"""
from app.core.datasource.base import DataSource
with pytest.raises(TypeError):
DataSource() # 抽象方法未实现
def test_api_health_route():
"""FastAPI 加载 + /api/health 路由可访问。"""
from fastapi.testclient import TestClient
from app.api.main import app
client = TestClient(app)
resp = client.get("/api/health")
assert resp.status_code == 200
body = resp.json()
assert body.get("status") == "ok"
def test_effective_sync_end_format():
from app.core.sync.base import _effective_sync_end
s = _effective_sync_end()
# YYYY-MM-DD 共 10 字符
assert len(s) == 10
assert s[4] == "-"
assert s[7] == "-"