看股功能更新
This commit is contained in:
329
backend/app/trades.py
Normal file
329
backend/app/trades.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""交割单解析(券商导出的成交流水 → 结构化买卖记录)。
|
||||
|
||||
支持三类导出物(按内容嗅探,不信任扩展名):
|
||||
- CSV/制表符文本(utf-8-sig / gbk / gb18030 自动探测)
|
||||
- Excel .xlsx(openpyxl;很多券商导出的 .xls 实为 xlsx 或 HTML,先按魔数分流)
|
||||
- HTML 表格(.xls 常见真身:<table><tr><td>)
|
||||
|
||||
列名模糊匹配兼容通达信/恒生/同花顺系的命名差异;业务名称含「买入/卖出」
|
||||
才入库,银行转账、配号、利息、红利等非交易行跳过并计数。
|
||||
"""
|
||||
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"<t[dh][^>]*>(.*?)</t[dh]>", re.IGNORECASE | re.DOTALL)
|
||||
_TR_RE = re.compile(r"<tr[^>]*>(.*?)</tr>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _rows_from_html(data: bytes) -> list[list[object]]:
|
||||
"""券商导出的 .xls 常是 HTML 表格。去掉标签实体后按 <tr>/<td> 切。"""
|
||||
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:])
|
||||
Reference in New Issue
Block a user