"""交割单解析(券商导出的成交流水 → 结构化买卖记录)。 支持三类导出物(按内容嗅探,不信任扩展名): - CSV/制表符文本(utf-8-sig / gbk / gb18030 自动探测) - Excel .xlsx(openpyxl;很多券商导出的 .xls 实为 xlsx 或 HTML,先按魔数分流) - HTML 表格(.xls 常见真身:
| )
列名模糊匹配兼容通达信/恒生/同花顺系的命名差异;业务名称含「买入/卖出」
才入库,银行转账、配号、利息、红利等非交易行跳过并计数。
"""
from __future__ import annotations
import csv
import io
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from fastapi import HTTPException
@dataclass
class ParsedTrade:
trade_date: date
ts_code: str
code: str
name: str
direction: str # buy | sell
price: float | None
qty: float
amount: float | None
fee: float
raw: dict = field(default_factory=dict)
@dataclass
class ParseResult:
trades: list[ParsedTrade] = field(default_factory=list)
skipped_other: int = 0 # 非证券买卖行(转账/配号/利息等)
skipped_bad: list[str] = field(default_factory=list) # 解析失败样例(截断到前 5 条)
header_row_index: int = -1
columns: dict[str, str] = field(default_factory=dict) # 逻辑列 -> 实际列名
# ---------- 列名别名(归一化后做「包含」匹配,先命中的优先) ----------
COLUMN_ALIASES: dict[str, list[str]] = {
"date": ["成交日期", "交割日期", "交收日期", "交易日期", "过户日期", "发生日期", "清算日期", "日期"],
"op": ["业务名称", "业务摘要", "操作", "业务类型", "交易类型", "交易类别", "摘要", "方向", "买卖标志"],
"code": ["证券代码", "股票代码", "产品代码", "代码"],
"name": ["证券名称", "股票名称", "产品名称", "名称"],
"qty": ["成交数量", "发生数量", "委托数量", "成交股数", "数量"],
"price": ["成交价格", "成交均价", "成交价", "均价", "价格"],
"amount": ["成交金额", "成交清算金额", "清算金额", "发生金额", "资金发生数", "金额"],
"fee": ["手续费", "佣金", "印花税", "过户费", "其他费", "杂费", "规费"],
}
# 手续费类允许多列求和(手续费+印花税+过户费…),其余逻辑列取第一命中
_FEE_KEYS = ("手续费", "佣金", "印花税", "过户费", "其他费", "杂费", "规费")
def _norm_header(h: str) -> str:
"""列名归一化:去空白、去全角、去括号单位(如「成交数量(股)」)。"""
h = str(h).strip().replace(" ", "").replace(" ", "").replace(" ", "")
h = re.sub(r"[((【\[].*?[))】\]]", "", h)
return h
def _match_columns(header: list[str]) -> dict[str, str]:
"""表头 -> 逻辑列映射。返回 {逻辑列: 实际列名};费率类列全部收集到 fee(合并名)。"""
out: dict[str, str] = {}
fee_cols: list[str] = []
for h in header:
n = _norm_header(h)
if not n:
continue
for key, aliases in COLUMN_ALIASES.items():
if key == "fee":
if any(a in n for a in _FEE_KEYS):
fee_cols.append(h)
continue
if key in out:
continue
if any(a in n for a in aliases):
out[key] = h
break
# 「费用合计」列本身已含全部费用明细,取它即可,避免与手续费/印花税等列重复累加
total_col = next((h for h in header if "费用合计" in _norm_header(h)), None)
if total_col is not None:
out["fee"] = total_col
elif fee_cols:
out["fee"] = "\x00".join(fee_cols) # 多列合并存储,取值时拆开求和
return out
def _looks_like_header(row: list[str]) -> bool:
"""前 10 行里找表头:≥3 个逻辑列可识别即认为是表头。"""
return len(_match_columns(row)) >= 3
def _to_float(v) -> float | None:
"""'1,234.50' / '(123.45)' / '--' / '' → float;不可解析返回 None。"""
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip().replace(",", "").replace(",", "")
if not s or s in {"--", "-", "—"}:
return None
neg = s.startswith("(") and s.endswith(")")
if neg:
s = s[1:-1]
try:
f = float(s)
except ValueError:
return None
return -f if neg else f
def _to_date(v) -> date | None:
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
if isinstance(v, (int, float)) and not isinstance(v, bool) and 30000 < v < 60000:
# Excel 日期序列值(1982~2064),openpyxl 读无日期格式的单元格时会给出
from datetime import timedelta
return date(1899, 12, 30) + timedelta(days=int(v))
s = str(v).strip()
m = re.search(r"(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})", s)
if not m:
m2 = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", s)
if not m2:
return None
m = m2
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
try:
return date(y, mo, d)
except ValueError:
return None
def _to_code_suffix(code: str) -> str:
"""6 位代码 → 交易所后缀(60/68 沪,00/30 深,4/8/92 北交所)。"""
if code.startswith(("60", "68", "90")):
return ".SH"
if code.startswith(("00", "30", "20")):
return ".SZ"
return ".BJ"
def _direction(op: str) -> str | None:
s = str(op)
if "买入" in s or "buy" in s.lower() or "证券买" in s:
return "buy"
if "卖出" in s or "sell" in s.lower() or "证券卖" in s:
return "sell"
return None
def _parse_rows(rows: list[list[object]]) -> ParseResult:
"""已抽成二维表的行集 → ParseResult。rows[0] 应是表头(调用方已定位)。"""
res = ParseResult()
if not rows:
return res
header = [str(h) for h in rows[0]]
cols = _match_columns(header)
res.columns = {k: v for k, v in cols.items()}
res.header_row_index = 0
need = ("date", "qty")
if not all(k in cols for k in need) or not ("code" in cols or "name" in cols):
raise HTTPException(
status_code=422,
detail="识别不到交割单表头(需要 成交日期/证券代码或证券名称/成交数量 等列),"
"请确认导出的是「交割单/历史成交」文件",
)
idx = {h: i for i, h in enumerate(header)}
# 无「业务名称」列的导出(如部分招商证券格式):靠发生金额正负判方向(买入为负)。
# 仅当数据里确实存在负数金额才启用,避免「全正数」格式被误判。
def _amount_of(row: list[object]) -> float | None:
i = idx.get(cols["amount"])
return _to_float(row[i]) if i is not None and i < len(row) else None
sign_mode = "op" not in cols and "amount" in cols and any(
(_amount_of(row) or 0) < 0 for row in rows[1:] if any(str(c).strip() for c in row)
)
def cell(row: list[object], col: str):
i = idx.get(col)
return row[i] if i is not None and i < len(row) else None
for row in rows[1:]:
d = _to_date(cell(row, cols["date"]))
code = re.sub(r"\D", "", str(cell(row, cols["code"]) or "")) if "code" in cols else ""
raw_amount = _amount_of(row) if sign_mode else None
direction = (
_direction(str(cell(row, cols["op"]) or "")) if "op" in cols
else ("buy" if (raw_amount or 0) < 0 else "sell") if sign_mode
else None
)
name = str(cell(row, cols["name"]) or "").strip() if "name" in cols else ""
if d is None or (not code and not name) or direction is None:
# 无日期/无代码且无名称/非买卖业务(银行转账、配号、利息、红利等)
if any(str(c).strip() for c in row):
res.skipped_other += 1
continue
if len(code) > 6:
code = code[-6:] # 个别导出带市场前缀(如 1:600000 / sh600000)
qty = abs(_to_float(cell(row, cols["qty"])) or 0)
if qty <= 0:
res.skipped_bad.append(f"{d} {code or name} 数量无效:{cell(row, cols['qty'])!r}")
continue
price = _to_float(cell(row, cols["price"])) if "price" in cols else None
amount = raw_amount if sign_mode else (_to_float(cell(row, cols["amount"])) if "amount" in cols else None)
if amount is not None:
amount = abs(amount)
fee = 0.0
if "fee" in cols:
for fc in cols["fee"].split("\x00"):
f = _to_float(cell(row, fc))
if f:
fee += abs(f)
# 无代码列(招商式导出):ts_code 留空,由 API 层按 name 反查 stock_basic
ts_code = code + _to_code_suffix(code) if code else ""
res.trades.append(ParsedTrade(
trade_date=d,
code=code,
ts_code=ts_code,
name=name,
direction=direction,
price=price,
qty=qty,
amount=amount,
fee=round(fee, 2),
raw={h: row[i] if i < len(row) else None for i, h in enumerate(header)},
))
res.skipped_bad = res.skipped_bad[:5]
return res
def _find_header(rows: list[list[object]]) -> int:
for i, row in enumerate(rows[:10]):
if _looks_like_header([str(c) for c in row]):
return i
return -1
# ---------- 输入格式分流 ----------
def _rows_from_csv(data: bytes) -> list[list[object]]:
"""逗号/制表符分隔文本。sniff 分隔符;跳过全空行。"""
text = None
for enc in ("utf-8-sig", "gbk", "gb18030"):
try:
text = data.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
raise HTTPException(status_code=422, detail="文件编码无法识别(支持 UTF-8 / GBK)")
sample = text[:4096]
delim = "\t" if sample.count("\t") > sample.count(",") else ","
lines = [ln for ln in text.splitlines() if ln.strip()]
if not lines:
raise HTTPException(status_code=422, detail="文件是空的")
return [next(csv.reader([ln], delimiter=delim)) for ln in lines]
def _rows_from_xlsx(data: bytes) -> list[list[object]]:
from openpyxl import load_workbook
try:
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
except Exception as e: # noqa: BLE001 - openpyxl 对损坏文件抛各种类型
raise HTTPException(status_code=422, detail=f"Excel 文件无法读取:{e}") from e
ws = wb.active
rows = [[c for c in row] for row in ws.iter_rows(values_only=True)]
wb.close()
return rows
_TD_RE = re.compile(r" |
| 切。""" text = None for enc in ("utf-8", "gbk", "gb18030"): try: text = data.decode(enc) break except UnicodeDecodeError: continue if text is None: raise HTTPException(status_code=422, detail="文件编码无法识别(支持 UTF-8 / GBK)") import html as html_mod rows: list[list[object]] = [] for tr in _TR_RE.findall(text): cells = [html_mod.unescape(re.sub(r"<[^>]+>", "", td)).strip() for td in _TD_RE.findall(tr)] rows.append(cells) if not rows: raise HTTPException(status_code=422, detail="HTML 里没有表格数据") return rows def parse_statement(data: bytes, filename: str) -> ParseResult: """入口:按内容魔数/特征分流 → 定位表头 → 解析。""" if not data: raise HTTPException(status_code=422, detail="文件是空的") head = data[:512].lstrip() if head.startswith(b"PK"): rows = _rows_from_xlsx(data) elif head[:1] in (b"<",) or head.lower().startswith(b"\xef\xbb\xbf<"): rows = _rows_from_html(data) elif filename.lower().endswith((".xlsx", ".xls")) and not head.startswith((b"PK", b"<")): # 扩展名是 Excel 但内容既非 xlsx 也非 HTML → 试试当文本 rows = _rows_from_csv(data) else: rows = _rows_from_csv(data) # 去尾部全空行,定位表头(导出物常有标题行/账户信息行在前) while rows and not any(str(c).strip() for c in rows[-1]): rows.pop() hi = _find_header(rows) if hi < 0: raise HTTPException( status_code=422, detail="找不到表头行(前 10 行内没有 成交日期/证券代码 等列名),请确认导出的是交割单", ) return _parse_rows(rows[hi:]) |