看股功能更新
This commit is contained in:
122
backend/scripts/backfill_adj_factor.py
Normal file
122
backend/scripts/backfill_adj_factor.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""全量回补历史复权因子(adj_factor 表)。
|
||||
|
||||
用法(在 backend 目录下):
|
||||
uv run python scripts/backfill_adj_factor.py # 从 candles 最早日期回补到今天
|
||||
uv run python scripts/backfill_adj_factor.py --start 20180101
|
||||
uv run python scripts/backfill_adj_factor.py --force # 已有日期也重拉
|
||||
|
||||
- 按交易日逐日拉取全市场因子(pro.adj_factor(trade_date=...)),幂等可断点续跑;
|
||||
- 交易日取自本地 trade_calendar(缓存不到的区间自动刷新一次日历);
|
||||
- Tushare 每分钟限频由 _call_retry 自动等待 62s 重试。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from sqlalchemy import delete, func, insert, select
|
||||
|
||||
from app.db import async_session
|
||||
from app.models import AdjFactor, Candle, TradeCalendar
|
||||
from app.screener.market_sync import _call_retry, _get_pro, _norm_date, _parse_d
|
||||
|
||||
_INTERVAL_MSG = 20 # 每完成 N 个交易日打印一次进度
|
||||
|
||||
|
||||
async def _calendar_dates(start: str, end: str) -> list[str]:
|
||||
"""[start, end] 交易日(升序)。本地日历覆盖不足时直接拉宽范围日历并回写缓存。"""
|
||||
async with async_session() as session:
|
||||
all_cached = set((await session.execute(select(TradeCalendar.trade_date))).scalars().all())
|
||||
cached = sorted(d for d in all_cached if start <= d <= end)
|
||||
if cached and min(cached) <= start:
|
||||
return cached
|
||||
|
||||
# 覆盖不到起点:按需拉宽范围日历(trade_cal 低积分限频 1 次/小时,失败沿用缓存)
|
||||
pro = _get_pro()
|
||||
try:
|
||||
cal = await asyncio.to_thread(
|
||||
_call_retry, pro.trade_cal, exchange="SSE", start_date=start, end_date=end, is_open="1"
|
||||
)
|
||||
dates = sorted(cal["cal_date"].tolist())
|
||||
except Exception as e: # noqa: BLE001
|
||||
if not cached:
|
||||
raise
|
||||
print(f"交易日历拉取受限({str(e)[:100]}),沿用本地缓存")
|
||||
return cached
|
||||
fresh = [d for d in dates if d not in all_cached]
|
||||
if fresh:
|
||||
async with async_session() as session:
|
||||
await session.execute(insert(TradeCalendar), [{"trade_date": d} for d in fresh])
|
||||
await session.commit()
|
||||
return dates
|
||||
|
||||
|
||||
async def _existing_dates() -> set[str]:
|
||||
async with async_session() as session:
|
||||
res = await session.execute(select(func.distinct(AdjFactor.trade_date)))
|
||||
return {_norm_date(r[0]) for r in res}
|
||||
|
||||
|
||||
async def main(start: str, end: str, force: bool) -> None:
|
||||
# 默认起点:candles 最早日线(因子只需覆盖有 K 线的区间)
|
||||
if start is None:
|
||||
async with async_session() as session:
|
||||
first = await session.scalar(select(func.min(Candle.ts)).where(Candle.timeframe == "1d"))
|
||||
start = first.strftime("%Y%m%d") if first else "20050101"
|
||||
if end is None:
|
||||
end = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
dates = await _calendar_dates(start, end)
|
||||
have = set() if force else await _existing_dates()
|
||||
todo = [d for d in dates if d not in have]
|
||||
print(f"区间 {start}~{end} 共 {len(dates)} 个交易日,待回补 {len(todo)} 个(已有 {len(dates) - len(todo)})")
|
||||
if not todo:
|
||||
return
|
||||
|
||||
pro = _get_pro()
|
||||
done = 0
|
||||
for d in todo:
|
||||
time.sleep(0.15) # 轻微控频;分钟级限频由 _call_retry 自动等待重试
|
||||
df = None
|
||||
for attempt in range(5): # 网络抖动(超时/断连)也重试,_call_retry 只兜限频
|
||||
try:
|
||||
df = _call_retry(pro.adj_factor, trade_date=d) # noqa: 线性脚本直接同步调用
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
wait = min(30 * (attempt + 1), 120)
|
||||
print(f" {d} 拉取异常({str(e)[:80]}),{wait}s 后重试 {attempt + 1}/5")
|
||||
time.sleep(wait)
|
||||
if df is None:
|
||||
print(f" {d} 连续 5 次失败,跳过(断点续跑可补)")
|
||||
continue
|
||||
if df is None or df.empty:
|
||||
print(f" {d} 无数据(非交易日或未生成),跳过")
|
||||
continue
|
||||
rows = [
|
||||
{"trade_date": _parse_d(d), "ts_code": r["ts_code"], "adj_factor": float(r["adj_factor"])}
|
||||
for _, r in df.iterrows()
|
||||
]
|
||||
async with async_session() as session:
|
||||
dt = _parse_d(d)
|
||||
await session.execute(delete(AdjFactor).where(AdjFactor.trade_date == dt))
|
||||
await session.execute(insert(AdjFactor), rows)
|
||||
await session.commit()
|
||||
done += 1
|
||||
if done % _INTERVAL_MSG == 0 or done == len(todo):
|
||||
print(f" 进度 {done}/{len(todo)}({d},+{len(rows)} 行)")
|
||||
print(f"回补完成:{done} 个交易日")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="全量回补历史复权因子")
|
||||
ap.add_argument("--start", default=None, help="YYYYMMDD,默认 candles 最早日期")
|
||||
ap.add_argument("--end", default=None, help="YYYYMMDD,默认今天")
|
||||
ap.add_argument("--force", action="store_true", help="已有日期也重拉")
|
||||
a = ap.parse_args()
|
||||
asyncio.run(main(a.start, a.end, a.force))
|
||||
162
backend/scripts/backfill_turnover.py
Normal file
162
backend/scripts/backfill_turnover.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""全量回补换手率(candles.turnover,单位 %)。
|
||||
|
||||
用法(在 backend 目录下):
|
||||
uv run python scripts/backfill_turnover.py # 从 2000-01-01(daily_basic 起点)回补到今天
|
||||
uv run python scripts/backfill_turnover.py --start 20200101
|
||||
uv run python scripts/backfill_turnover.py --force # 已回补的交易日也重拉
|
||||
|
||||
- 数据源:Tushare daily_basic(trade_date=..., fields='ts_code,turnover_rate'),按日全市场;
|
||||
- 幂等可断点续跑:某交易日 candles 已有非空 turnover 即跳过(--force 强制重做);
|
||||
- 交易日取自本地 trade_calendar(缓存覆盖不到起点时自动拉一次宽范围日历);
|
||||
- 每日一条 UPDATE ... FROM unnest(...) 批量写回,仅更新 turnover 列;
|
||||
- Tushare 每分钟限频由 _call_retry 自动等待 62s 重试。
|
||||
|
||||
注意:与 import_tdx_day.py(回填 amount 会整行 upsert)串行运行,避免同表行锁竞争。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.screener.market_sync import _call_retry, _get_pro
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
def load_db_url() -> str:
|
||||
"""与 import_tdx_day.py 相同的 .env -> libpq URL 解析(本地复制避免跨脚本导入)。"""
|
||||
env = Path(__file__).resolve().parent.parent / ".env"
|
||||
if env.exists():
|
||||
for line in env.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("DATABASE_URL=postgresql+asyncpg://"):
|
||||
return "postgresql://" + line.split("://", 1)[1]
|
||||
return "postgresql://postgres:postgres@localhost:5432/stock"
|
||||
|
||||
_DAILY_BASIC_FLOOR = "20000101" # daily_basic 最早覆盖 2000-01-04,更早的交易日无换手数据
|
||||
_INTERVAL_MSG = 20
|
||||
|
||||
|
||||
async def _calendar_dates(conn: asyncpg.Connection, start: str, end: str) -> list[str]:
|
||||
"""[start, end] 交易日(升序)。本地缓存覆盖不到起点时拉一次宽范围日历并回写。"""
|
||||
cached = [r[0] for r in await conn.fetch(
|
||||
"SELECT trade_date FROM trade_calendar WHERE trade_date >= $1 AND trade_date <= $2 "
|
||||
"ORDER BY trade_date", start, end)]
|
||||
if cached and cached[0] <= start:
|
||||
return cached
|
||||
|
||||
pro = _get_pro()
|
||||
try:
|
||||
cal = await asyncio.to_thread(
|
||||
_call_retry, pro.trade_cal, exchange="SSE", start_date=start, end_date=end, is_open="1"
|
||||
)
|
||||
dates = sorted(cal["cal_date"].tolist())
|
||||
except Exception as e: # noqa: BLE001
|
||||
if not cached:
|
||||
raise
|
||||
print(f"交易日历拉取受限({str(e)[:100]}),沿用本地缓存")
|
||||
return cached
|
||||
have = set(cached)
|
||||
fresh = [d for d in dates if d not in have]
|
||||
if fresh:
|
||||
await conn.executemany(
|
||||
"INSERT INTO trade_calendar (trade_date) VALUES ($1) ON CONFLICT DO NOTHING", [(d,) for d in fresh]
|
||||
)
|
||||
return dates
|
||||
|
||||
|
||||
async def _day_status(conn: asyncpg.Connection, d: str) -> tuple[int, int]:
|
||||
"""(已有换手的行数, 当日总行数)。无行情的日子 total=0 直接跳过。"""
|
||||
row = await conn.fetchrow(
|
||||
"SELECT count(*) FILTER (WHERE turnover IS NOT NULL) AS done, count(*) AS total "
|
||||
"FROM candles WHERE timeframe = '1d' AND ts = $1::timestamp", datetime.strptime(d, "%Y%m%d")
|
||||
)
|
||||
return row["done"], row["total"]
|
||||
|
||||
|
||||
async def main(start: str, end: str, force: bool) -> None:
|
||||
conn = await asyncpg.connect(load_db_url())
|
||||
try:
|
||||
# 默认起点:daily_basic 覆盖范围与 candles 最早日线的较大者(更早的日期拉了也是空)
|
||||
if start is None:
|
||||
first = await conn.fetchval(
|
||||
"SELECT min(ts) FROM candles WHERE timeframe = '1d' AND symbol <> 'DEMO'")
|
||||
start = max(first.strftime("%Y%m%d"), _DAILY_BASIC_FLOOR) if first else _DAILY_BASIC_FLOOR
|
||||
if end is None:
|
||||
end = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
dates = await _calendar_dates(conn, start, end)
|
||||
todo: list[str] = []
|
||||
for d in dates:
|
||||
if force:
|
||||
done, total = await _day_status(conn, d)
|
||||
if total:
|
||||
todo.append(d)
|
||||
continue
|
||||
done, total = await _day_status(conn, d)
|
||||
if total and done < total // 2: # 过半缺换手才重做(容忍个别股票无快照)
|
||||
todo.append(d)
|
||||
print(f"区间 {start}~{end} 共 {len(dates)} 个交易日,待回补 {len(todo)} 个")
|
||||
|
||||
pro = _get_pro()
|
||||
done = 0
|
||||
t0 = time.time()
|
||||
for d in todo:
|
||||
time.sleep(0.15) # 轻微控频;分钟级限频由 _call_retry 自动等待重试
|
||||
df = None
|
||||
for attempt in range(5): # 网络抖动(超时/断连)也重试,_call_retry 只兜限频
|
||||
try:
|
||||
df = _call_retry(
|
||||
pro.daily_basic, trade_date=d, fields="ts_code,trade_date,turnover_rate"
|
||||
)
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
wait = min(30 * (attempt + 1), 120)
|
||||
print(f" {d} 拉取异常({str(e)[:80]}),{wait}s 后重试 {attempt + 1}/5")
|
||||
time.sleep(wait)
|
||||
if df is None:
|
||||
print(f" {d} 连续 5 次失败,跳过(断点续跑可补)")
|
||||
continue
|
||||
if df.empty:
|
||||
continue
|
||||
|
||||
syms: list[str] = []
|
||||
vals: list[float] = []
|
||||
for _, r in df.iterrows():
|
||||
tr = r["turnover_rate"]
|
||||
if tr is None or tr != tr: # None / NaN
|
||||
continue
|
||||
syms.append(str(r["ts_code"]).split(".")[0])
|
||||
vals.append(float(tr))
|
||||
if not syms:
|
||||
continue
|
||||
n = await conn.execute(
|
||||
"UPDATE candles AS c SET turnover = v.t "
|
||||
"FROM unnest($1::text[], $2::float8[]) AS v(sym, t) "
|
||||
"WHERE c.symbol = v.sym AND c.timeframe = '1d' AND c.ts = $3::timestamp",
|
||||
syms, vals, datetime.strptime(d, "%Y%m%d"),
|
||||
)
|
||||
done += 1
|
||||
if done % _INTERVAL_MSG == 0 or done == len(todo):
|
||||
elapsed = time.time() - t0
|
||||
eta = elapsed / done * (len(todo) - done) if done else 0
|
||||
print(f" 进度 {done}/{len(todo)}({d},{len(syms)} 只,{n}),"
|
||||
f"{elapsed:.0f}s 已用,预计还需 {eta/60:.0f}m")
|
||||
print(f"回补完成:{done} 个交易日")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="全量回补换手率 candles.turnover")
|
||||
ap.add_argument("--start", default=None, help="YYYYMMDD,默认 max(candles 最早, 20000101)")
|
||||
ap.add_argument("--end", default=None, help="YYYYMMDD,默认今天")
|
||||
ap.add_argument("--force", action="store_true", help="已有换手的交易日也重拉")
|
||||
a = ap.parse_args()
|
||||
asyncio.run(main(a.start, a.end, a.force))
|
||||
160
backend/scripts/import_tdx_day.py
Normal file
160
backend/scripts/import_tdx_day.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""通达信「沪深京日线数据完整包」全量导入 candles 表。
|
||||
|
||||
用法(在 backend 目录下):
|
||||
uv run python scripts/import_tdx_day.py C:/Users/cirry/Downloads/hsjday [symbol ...]
|
||||
# symbol 为可选的 6 位代码过滤(如 000001 002671),只重导这些标的
|
||||
uv run python scripts/import_tdx_day.py <目录> --no-clear
|
||||
# --no-clear:不清空任何行,纯 upsert(用于给已导入的底座回补 amount 成交额)
|
||||
|
||||
- 解析 vipdoc 的 .day 二进制文件(每条 32 字节):
|
||||
日期(YYYYMMDD) 开 高 低 收(×100) 成交额(元, float32) 成交量(股) 保留
|
||||
- 只导入 stock_basic 里登记的股票(自动排除指数/基金/可转债/回购);
|
||||
sh000001(上证指数) 与 sz000001(平安银行) 这类代码冲突也由此化解。
|
||||
- 价格为**不复权**:全量模式导入前清空已有的非 DEMO 行情;指定 symbol 过滤时
|
||||
只清空这些标的(用于修复被复权口径污染的个别股票),其余不动。
|
||||
- amount 为 TDX 原生 float32(元),精度 ~6 位有效数字,展示用途足够;
|
||||
ON CONFLICT 时仅更新 amount 列,不动 OHLCV/turnover(避免与换手率回补互相干扰)。
|
||||
- 写入用 asyncpg execute_many + ON CONFLICT DO UPDATE,可重复执行(幂等)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
# .env 里的 DATABASE_URL 是 SQLAlchemy 格式,asyncpg 需要 libpq 格式
|
||||
DEFAULT_URL = "postgresql://postgres:postgres@localhost:5432/stock"
|
||||
BATCH = 20_000 # 每批 upsert 行数
|
||||
|
||||
|
||||
def load_db_url() -> str:
|
||||
env = Path(__file__).resolve().parent.parent / ".env"
|
||||
if env.exists():
|
||||
for line in env.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("DATABASE_URL=postgresql+asyncpg://"):
|
||||
return "postgresql://" + line.split("://", 1)[1]
|
||||
return DEFAULT_URL
|
||||
|
||||
|
||||
def parse_day_file(path: Path) -> list[tuple[int, float, float, float, float, float, float]]:
|
||||
"""解析单个 .day 文件 -> [(date, open, high, low, close, volume(股), amount(元)), ...]"""
|
||||
raw = path.read_bytes()
|
||||
unpack = struct.Struct("<IIIIIfII").unpack_from
|
||||
out = []
|
||||
for i in range(len(raw) // 32):
|
||||
date, o, h, l, c, amount, vol, _reserved = unpack(raw, i * 32)
|
||||
out.append((date, o / 100.0, h / 100.0, l / 100.0, c / 100.0, float(vol), float(amount)))
|
||||
return out
|
||||
|
||||
|
||||
async def main(root: Path, symbols: list[str] | None = None, no_clear: bool = False) -> None:
|
||||
if not root.exists():
|
||||
sys.exit(f"目录不存在: {root}")
|
||||
|
||||
conn = await asyncpg.connect(load_db_url())
|
||||
try:
|
||||
# 股票清单:ts_code 形如 000001.SZ,用于过滤指数/基金/转债
|
||||
rows = await conn.fetch("SELECT ts_code, symbol FROM stock_basic WHERE list_status = 'L'")
|
||||
by_exchange: dict[str, set[str]] = {"sh": set(), "sz": set(), "bj": set()}
|
||||
for r in rows:
|
||||
suffix = r["ts_code"].split(".")[-1].lower() # SH/SZ/BJ -> sh/sz/bj
|
||||
if suffix in by_exchange:
|
||||
by_exchange[suffix].add(r["symbol"])
|
||||
print(f"stock_basic 在市股票: " + ", ".join(f"{k}={len(v)}" for k, v in by_exchange.items()))
|
||||
|
||||
files = sorted(root.glob("*/lday/*.day"))
|
||||
print(f"发现 .day 文件: {len(files)} 个")
|
||||
|
||||
if no_clear:
|
||||
print("--no-clear:不清空任何行,纯 upsert 回补 amount")
|
||||
elif symbols:
|
||||
# 清空旧行情(保留 DEMO 合成数据),避免 qfq/不复权混用;
|
||||
# 带 symbol 过滤时只清空目标标的(修复个别被污染的股票,不动其余底座)
|
||||
deleted = await conn.execute(
|
||||
"DELETE FROM candles WHERE symbol = ANY($1)", symbols
|
||||
)
|
||||
print(f"清空目标标的 {symbols}: {deleted}")
|
||||
keep = set(symbols)
|
||||
files = [p for p in files if p.name[2:8] in keep]
|
||||
print(f"过滤后待导入 .day 文件: {len(files)} 个")
|
||||
else:
|
||||
deleted = await conn.execute("DELETE FROM candles WHERE symbol <> 'DEMO'")
|
||||
print(f"清空旧行情: {deleted}")
|
||||
|
||||
if no_clear:
|
||||
# 回填模式:只写 amount,不动 OHLCV/turnover(底座已就位,避免全表重写)
|
||||
upsert_sql = """
|
||||
INSERT INTO candles (symbol, timeframe, ts, open, high, low, close, volume, amount)
|
||||
VALUES ($1, '1d', to_timestamp($2::text, 'YYYYMMDD')::timestamp, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (symbol, timeframe, ts) DO UPDATE
|
||||
SET amount = EXCLUDED.amount
|
||||
"""
|
||||
else:
|
||||
upsert_sql = """
|
||||
INSERT INTO candles (symbol, timeframe, ts, open, high, low, close, volume, amount)
|
||||
VALUES ($1, '1d', to_timestamp($2::text, 'YYYYMMDD')::timestamp, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (symbol, timeframe, ts) DO UPDATE
|
||||
SET open = EXCLUDED.open, high = EXCLUDED.high, low = EXCLUDED.low,
|
||||
close = EXCLUDED.close, volume = EXCLUDED.volume, amount = EXCLUDED.amount
|
||||
"""
|
||||
|
||||
t0 = time.time()
|
||||
total_stocks = 0
|
||||
skipped = 0
|
||||
batch: list[tuple] = []
|
||||
|
||||
rows_done = 0
|
||||
|
||||
async def flush() -> None:
|
||||
nonlocal batch, rows_done
|
||||
if batch:
|
||||
await conn.executemany(upsert_sql, batch)
|
||||
rows_done += len(batch)
|
||||
batch = []
|
||||
|
||||
for n, path in enumerate(files, 1):
|
||||
market = path.name[:2].lower() # sh / sz / bj
|
||||
code = path.name[2:8]
|
||||
if code not in by_exchange.get(market, set()):
|
||||
skipped += 1
|
||||
continue
|
||||
for date, o, h, l, c, v, amount in parse_day_file(path):
|
||||
batch.append((code, str(date), o, h, l, c, v, amount))
|
||||
total_stocks += 1
|
||||
if len(batch) >= BATCH:
|
||||
await flush()
|
||||
if n % 500 == 0:
|
||||
elapsed = time.time() - t0
|
||||
print(f" 进度 {n}/{len(files)} 文件, 已入库 {total_stocks} 只股票, "
|
||||
f"{rows_done + len(batch):,} 行, {elapsed:.0f}s")
|
||||
await flush()
|
||||
|
||||
cnt = await conn.fetchval("SELECT count(*) FROM candles WHERE symbol <> 'DEMO'")
|
||||
span = await conn.fetchrow(
|
||||
"SELECT min(ts) AS lo, max(ts) AS hi FROM candles WHERE symbol <> 'DEMO'"
|
||||
)
|
||||
with_amt = await conn.fetchval(
|
||||
"SELECT count(*) FROM candles WHERE symbol <> 'DEMO' AND amount IS NOT NULL"
|
||||
)
|
||||
print(f"\n完成: {total_stocks} 只股票, {cnt:,} 行日线, "
|
||||
f"范围 {span['lo']:%Y-%m-%d} ~ {span['hi']:%Y-%m-%d}, "
|
||||
f"含成交额 {with_amt:,} 行, "
|
||||
f"跳过非股票文件 {skipped} 个, 耗时 {time.time() - t0:.0f}s")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="TDX 沪深京日线全量导入 candles")
|
||||
ap.add_argument("root", help="hsjday 目录(其下 */lday/*.day)")
|
||||
ap.add_argument("symbols", nargs="*", help="可选的 6 位代码过滤")
|
||||
ap.add_argument("--no-clear", action="store_true",
|
||||
help="不清空任何行,纯 upsert(amount 回补模式)")
|
||||
a = ap.parse_args()
|
||||
asyncio.run(main(Path(a.root), a.symbols or None, a.no_clear))
|
||||
113
backend/scripts/test_trades_parser.py
Normal file
113
backend/scripts/test_trades_parser.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""交割单解析器离线自测:不碰数据库,直接调 app.trades.parse_statement。
|
||||
|
||||
覆盖四类真实导出格式 + 边界行(转账/配号/利息跳过、费用合计列去重、日期多格式)。
|
||||
运行:uv run python scripts/test_trades_parser.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.trades import parse_statement # noqa: E402
|
||||
|
||||
FAIL: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, cond: bool, detail: str = "") -> None:
|
||||
mark = "ok " if cond else "FAIL"
|
||||
print(f"[{mark}] {name}{(' — ' + detail) if detail and not cond else ''}")
|
||||
if not cond:
|
||||
FAIL.append(name)
|
||||
|
||||
|
||||
# ---------- 1) 通达信式:GBK + 制表符 + 标题行在前 ----------
|
||||
tdx = (
|
||||
"交割单\n"
|
||||
"股东账号: A123456789 起始日期: 20240102 终止日期: 20240105 币种: 人民币\n"
|
||||
"\t交割日期\t业务名称\t证券代码\t证券名称\t成交价格\t成交数量\t成交金额\t手续费\t印花税\t过户费\t发生金额\t资金余额\t合同号\n"
|
||||
"\t20240102\t证券买入\t600519\t贵州茅台\t1680.00\t100\t168000.00\t5.00\t0.00\t1.68\t-168006.68\t200000.00\t1000001\n"
|
||||
"\t20240102\t银行转存\t\t\t\t\t\t\t\t\t50000.00\t250000.00\t\n"
|
||||
"\t20240103\t证券卖出\t600519\t贵州茅台\t1700.50\t100\t170050.00\t5.00\t170.05\t1.70\t169873.25\t419873.25\t1000002\n"
|
||||
"\t20240105\t利息归本\t\t\t\t\t\t\t\t\t1.25\t419874.50\t\n"
|
||||
)
|
||||
r = parse_statement(tdx.encode("gbk"), "交割单.txt")
|
||||
check("tdx: 2 笔成交", len(r.trades) == 2, f"got {len(r.trades)}")
|
||||
check("tdx: 跳过 2 行非交易", r.skipped_other == 2, f"got {r.skipped_other}")
|
||||
t0, t1 = r.trades[0], r.trades[1]
|
||||
check("tdx: 日期/代码/后缀", (t0.trade_date.isoformat(), t0.ts_code) == ("2024-01-02", "600519.SH"), f"{t0.trade_date} {t0.ts_code}")
|
||||
check("tdx: 买入方向+费用合计", t0.direction == "buy" and abs(t0.fee - 6.68) < 1e-9, f"{t0.direction} fee={t0.fee}")
|
||||
check("tdx: 卖出费用含印花税", t1.direction == "sell" and abs(t1.fee - 176.75) < 1e-9, f"fee={t1.fee}")
|
||||
check("tdx: 金额取绝对值", t0.amount == 168000.0, f"amount={t0.amount}")
|
||||
|
||||
# ---------- 2) 恒生柜台式:UTF-8 CSV,交收日期/交易类别/费用合计 ----------
|
||||
hs = (
|
||||
"序号,交收日期,证券代码,证券名称,交易类别,成交价格,成交数量,证券余额,成交金额,资金发生数,资金余额,流水序号,业务标志,业务名称,发生金额,后资金额,货币类别,费用合计,净佣金,规费,印花税,过户费,合同号\n"
|
||||
"1,2024-06-07,000858,五粮液,证券买入,132.50,200,200,26500.00,-26505.80,73494.20,1,0101,证券买入,-26505.80,73494.20,人民币,5.80,4.20,1.60,0.00,0.00,66778001\n"
|
||||
"2,2024-06-07,,,\t,,,,5120.00,78614.20,2,2041,银行转存,5120.00,78614.20,人民币,0,0,0,0,0,\n"
|
||||
"3,2024-06-10,000858,五粮液,证券卖出,135.00,200,0,27000.00,26975.30,105589.50,3,0102,证券卖出,26975.30,105589.50,人民币,24.70,4.20,1.60,18.90,0.00,66779001\n"
|
||||
)
|
||||
r2 = parse_statement(hs.encode("utf-8"), "hsi.csv")
|
||||
check("hs: 2 笔成交", len(r2.trades) == 2, f"got {len(r2.trades)}")
|
||||
check("hs: 费用合计不重复累加", abs(r2.trades[1].fee - 24.70) < 1e-9, f"fee={r2.trades[1].fee}")
|
||||
check("hs: 深市后缀", r2.trades[0].ts_code == "000858.SZ", r2.trades[0].ts_code)
|
||||
check("hs: 日期 YYYY-MM-DD", r2.trades[0].trade_date.isoformat() == "2024-06-07")
|
||||
|
||||
# ---------- 3) HTML 伪 .xls(同花顺导出常见真身) ----------
|
||||
html = """<html><head><meta charset="gbk"></head><body>
|
||||
<table>
|
||||
<tr><td>客户姓名</td><td>测试</td></tr>
|
||||
<tr><td>成交日期</td><td>业务名称</td><td>证券代码</td><td>证券名称</td><td>成交价格</td><td>成交数量</td><td>成交金额</td><td>手续费</td></tr>
|
||||
<tr><td>2024/03/15</td><td>证券买入</td><td>300750</td><td>宁德时代</td><td>182.30</td><td>300</td><td>54,690.00</td><td>16.41</td></tr>
|
||||
<tr><td>2024/03/18</td><td>证券卖出</td><td>300750</td><td>宁德时代</td><td>185.00</td><td>300</td><td>55,500.00</td><td>5.55</td></tr>
|
||||
</table></body></html>"""
|
||||
r3 = parse_statement(html.encode("gbk"), "jiaogedan.xls")
|
||||
check("html: 2 笔成交", len(r3.trades) == 2, f"got {len(r3.trades)}")
|
||||
check("html: 千分位金额", r3.trades[0].amount == 54690.0, f"{r3.trades[0].amount}")
|
||||
check("html: 创业板后缀", r3.trades[0].ts_code == "300750.SZ", r3.trades[0].ts_code)
|
||||
check("html: 斜杠日期", r3.trades[1].trade_date.isoformat() == "2024-03-18")
|
||||
|
||||
# ---------- 4) 无业务名称列:发生金额正负判方向(招商式) ----------
|
||||
zh = (
|
||||
"证券名称,成交日期,成交价格,成交数量,发生金额,资金余额,合同编号\n"
|
||||
"贵州茅台,20240102,1680.00,100,-168005.00,200000.00,SZ1000001\n"
|
||||
"贵州茅台,20240103,1700.50,100,170049.50,370049.50,SZ1000002\n"
|
||||
)
|
||||
r4 = parse_statement(zh.encode("utf-8"), "zszs.csv")
|
||||
check("sign: 2 笔成交", len(r4.trades) == 2, f"got {len(r4.trades)}")
|
||||
check("sign: 负金额=买入", (r4.trades[0].direction, r4.trades[1].direction) == ("buy", "sell"),
|
||||
f"{r4.trades[0].direction}/{r4.trades[1].direction}")
|
||||
|
||||
# ---------- 5) xlsx(openpyxl 内存构造) ----------
|
||||
import io # noqa: E402
|
||||
from openpyxl import Workbook # noqa: E402
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["对账单", None, None])
|
||||
ws.append(["成交日期", "业务名称", "证券代码", "证券名称", "成交均价", "成交股数", "成交金额", "佣金", "过户费"])
|
||||
from datetime import datetime as dt # noqa: E402
|
||||
ws.append([dt(2024, 2, 28, 14, 35, 0), "证券买入", "688981", "中芯国际", 52.80, 200, 10560.00, 2.50, 1.06])
|
||||
ws.append([dt(2024, 3, 1, 9, 31, 0), "证券卖出", "688981", "中芯国际", 54.10, 200, 10820.00, 2.50, 1.06])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
r5 = parse_statement(buf.getvalue(), "sm.xlsx")
|
||||
check("xlsx: 2 笔成交", len(r5.trades) == 2, f"got {len(r5.trades)}")
|
||||
check("xlsx: datetime 日期", r5.trades[0].trade_date.isoformat() == "2024-02-28")
|
||||
check("xlsx: 科创板后缀", r5.trades[0].ts_code == "688981.SH", r5.trades[0].ts_code)
|
||||
check("xlsx: 佣金+过户费", abs(r5.trades[0].fee - 3.56) < 1e-9, f"fee={r5.trades[0].fee}")
|
||||
|
||||
# ---------- 6) 错误分支 ----------
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
try:
|
||||
parse_statement("随便一串不是交割单的文字,1,2,3".encode("utf-8"), "x.csv")
|
||||
check("garbage: 应 422", False)
|
||||
except HTTPException as e:
|
||||
check("garbage: 422", e.status_code == 422)
|
||||
|
||||
print()
|
||||
if FAIL:
|
||||
print(f"FAIL {len(FAIL)}: {FAIL}")
|
||||
sys.exit(1)
|
||||
print("PASS: 交割单解析器全部用例通过")
|
||||
Reference in New Issue
Block a user