From 59af9d9c2f48345b9da5d5e844c01b875186867e Mon Sep 17 00:00:00 2001 From: Lxy Date: Sun, 21 Jun 2026 17:15:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BA=A4=E6=98=93=E5=A4=8D=E7=9B=98?= =?UTF-8?q?=E4=B8=AD=E7=9A=84k=E7=BA=BF=E5=8A=A0=E8=BD=BD=E6=88=90?= =?UTF-8?q?=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/trade_review.py | 137 ++++++++++++++++++++++++++++++- app/services/trade_parser.py | 105 +++++++++++++++++++++-- app/static/futures_analysis.html | 18 ++++ app/static/futures_analysis.js | 81 +++++++++++++++++- data/futures_analysis.db | Bin 8204288 -> 8228864 bytes 5 files changed, 330 insertions(+), 11 deletions(-) diff --git a/app/api/trade_review.py b/app/api/trade_review.py index fe9afd6..2c91d25 100644 --- a/app/api/trade_review.py +++ b/app/api/trade_review.py @@ -30,7 +30,7 @@ async def import_settlement( file: UploadFile = File(...), db: Session = Depends(get_analysis_db), ): - """上传并解析期货结算单文件,导入交易记录""" + """上传并解析期货结算单文件,导入交易记录(含逐条去重校验)""" if not file.filename.endswith(('.xls', '.xlsx')): raise HTTPException(status_code=400, detail="仅支持 .xls/.xlsx 格式的结算单文件") @@ -43,16 +43,129 @@ async def import_settlement( result = save_to_db(db, df_futures, df_options, file.filename) + total_skipped = result['futures_skipped'] + result['options_skipped'] + total_new = result['futures_count'] + result['options_count'] + total_parsed = result['futures_parsed'] + result['options_parsed'] + + if total_new == 0 and total_skipped > 0: + return { + "success": True, + "message": f"全部跳过:共解析 {total_parsed} 条记录,均已存在(去重规则:交易日+品种+时间+价格),未导入任何新数据", + "data": result, + } + + msg_parts = [f"导入成功:期货 {result['futures_count']} 条,期权 {result['options_count']} 条"] + if total_skipped > 0: + msg_parts.append(f"跳过重复 {total_skipped} 条") + msg_parts.append(f"交易日期: {result['trade_dates']}") + return { "success": True, - "message": f"导入成功:期货 {result['futures_count']} 条,期权 {result['options_count']} 条", + "message": ",".join(msg_parts), "data": result, } + except HTTPException: + raise except Exception as e: logger.exception("导入结算单失败") raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}") +@router.post("/batch-import") +async def batch_import_settlement( + files: list[UploadFile] = File(...), + db: Session = Depends(get_analysis_db), +): + """批量上传并解析期货结算单文件,导入交易记录(含逐条去重校验)""" + if not files: + raise HTTPException(status_code=400, detail="请选择至少一个结算单文件") + + results = [] + total_futures = 0 + total_options = 0 + total_skipped_all = 0 + processed_count = 0 + failed_count = 0 + + for file in files: + if not file.filename.endswith(('.xls', '.xlsx')): + results.append({ + "filename": file.filename, + "success": False, + "message": "文件格式不支持,仅支持 .xls/.xlsx", + }) + failed_count += 1 + continue + + try: + content = await file.read() + df_futures, df_options = parse_settlement_file(content, file.filename) + + if df_futures.empty and df_options.empty: + results.append({ + "filename": file.filename, + "success": False, + "message": "文件中未找到有效的交易记录", + }) + failed_count += 1 + continue + + result = save_to_db(db, df_futures, df_options, file.filename) + + file_new = result['futures_count'] + result['options_count'] + file_skipped = result['futures_skipped'] + result['options_skipped'] + total_futures += result['futures_count'] + total_options += result['options_count'] + total_skipped_all += file_skipped + processed_count += 1 + + msg_parts = [] + if file_new > 0: + msg_parts.append(f"新导入 {file_new} 条(期货 {result['futures_count']},期权 {result['options_count']})") + if file_skipped > 0: + msg_parts.append(f"跳过重复 {file_skipped} 条") + if not msg_parts: + msg_parts.append("无有效记录") + + results.append({ + "filename": file.filename, + "success": True, + "message": ",".join(msg_parts), + "new_count": file_new, + "skipped_count": file_skipped, + "trade_dates": result['trade_dates'], + "batch_id": result['batch_id'], + }) + except Exception as e: + logger.exception(f"批量导入文件失败: {file.filename}") + results.append({ + "filename": file.filename, + "success": False, + "message": f"导入失败: {str(e)}", + }) + failed_count += 1 + + summary = ( + f"批量导入完成:共 {len(files)} 个文件," + f"处理 {processed_count} 个,失败 {failed_count} 个," + f"合计新导入期货 {total_futures} 条、期权 {total_options} 条,跳过重复 {total_skipped_all} 条" + ) + + return { + "success": True, + "message": summary, + "data": { + "total_files": len(files), + "processed_count": processed_count, + "failed_count": failed_count, + "total_futures": total_futures, + "total_options": total_options, + "total_skipped": total_skipped_all, + "details": results, + }, + } + + # ==================== 交易记录查询 ==================== @router.get("/records") @@ -312,6 +425,19 @@ def get_kline_with_trades( return {"success": False, "message": f"未找到 {kline_symbol} 的 {period} K线数据"} candles = json.loads(market_data.candles_json) + # 将字典格式K线转换为前端期望的数组格式 [date, open, close, low, high, volume] + if candles and isinstance(candles[0], dict): + candles = [ + [ + c.get("time", "")[:10], # 截取日期部分 YYYY-MM-DD + c.get("open", 0), + c.get("close", 0), + c.get("low", 0), + c.get("high", 0), + c.get("volume", 0), + ] + for c in candles + ] kline_symbol = market_data.symbol logger.info(f"[K线查询] 成功获取K线数据: symbol={kline_symbol}, candles_count={len(candles)}") finally: @@ -410,6 +536,13 @@ async def analyze_trade( ).first() if market_data and market_data.candles_json: candles = json.loads(market_data.candles_json) + # 将字典格式K线转换为数组格式 [date, open, close, low, high, volume] + if candles and isinstance(candles[0], dict): + candles = [ + [c.get("time", "")[:10], c.get("open", 0), c.get("close", 0), + c.get("low", 0), c.get("high", 0), c.get("volume", 0)] + for c in candles + ] kline_context[period] = candles[-50:] if len(candles) > 50 else candles finally: main_db.close() diff --git a/app/services/trade_parser.py b/app/services/trade_parser.py index 9b51d77..5bed555 100644 --- a/app/services/trade_parser.py +++ b/app/services/trade_parser.py @@ -5,6 +5,7 @@ import re import uuid import json +import logging from datetime import datetime from collections import defaultdict from pathlib import Path @@ -15,6 +16,8 @@ from sqlalchemy.orm import Session as DBSession from app.analysis_models import TradeRecord, TradeImportBatch +logger = logging.getLogger(__name__) + # 品种代码 -> 中文名 映射(反向映射:从合约代码前缀查中文名) _VARIETY_NAME_MAP = {} @@ -193,6 +196,35 @@ def _normalize_time(time_val) -> str: return s +def _build_dedup_key(trade_date: str, variety: str, trade_time: str, price: float) -> str: + """构建去重键:交易日+品种+交易时间+价格(价格保留2位小数避免浮点精度问题)""" + return f"{trade_date}|{variety}|{trade_time}|{round(price, 2)}" + + +def _load_existing_dedup_keys(db: DBSession, trade_dates: set) -> set: + """ + 根据涉及的交易日,批量加载已有记录的去重键集合 + """ + if not trade_dates: + logger.warning("[去重校验] trade_dates 为空,跳过查询") + return set() + logger.info(f"[去重校验] 查询已有记录,日期范围: {sorted(trade_dates)}") + existing = db.query( + TradeRecord.trade_date, + TradeRecord.variety, + TradeRecord.trade_time, + TradeRecord.price, + ).filter( + TradeRecord.trade_date.in_(trade_dates) + ).all() + logger.info(f"[去重校验] 从数据库加载到 {len(existing)} 条已有记录") + keys = {_build_dedup_key(r[0], r[1], r[2] or '', r[3] or 0.0) for r in existing} + if keys: + sample = list(keys)[:3] + logger.info(f"[去重校验] 去重键示例: {sample}") + return keys + + def save_to_db( db: DBSession, df_futures: pd.DataFrame, @@ -200,16 +232,46 @@ def save_to_db( filename: str, ) -> dict: """ - 将解析后的交易记录保存到数据库 - :return: {"batch_id": str, "futures_count": int, "options_count": int, "trade_dates": str} + 将解析后的交易记录保存到数据库(含逐条去重校验) + 去重规则:同一交易日 + 同一品种 + 同一交易时间 + 同一价格 => 视为重复,跳过 + :return: { + "batch_id": str, "futures_count": int, "options_count": int, "trade_dates": str, + "futures_skipped": int, "options_skipped": int, "total_parsed": int + } """ _load_variety_name_map() batch_id = str(uuid.uuid4()) all_dates = set() futures_count = 0 options_count = 0 + futures_skipped = 0 + options_skipped = 0 + futures_parsed = 0 + options_parsed = 0 + + # 第一遍:收集所有涉及的交易日,用于批量查询已有记录 + if not df_futures.empty: + for _, row in df_futures.iterrows(): + contract = str(row.get('合约', '')).strip() + if not contract or contract == '合计': + continue + trade_date = _normalize_date(row.get('实际成交日期', '')) + if trade_date: + all_dates.add(trade_date) + if not df_options.empty: + for _, row in df_options.iterrows(): + contract = str(row.get('品种合约', '')).strip() + if not contract or contract == '合计': + continue + trade_date = _normalize_date(row.get('成交日期', '')) + if trade_date: + all_dates.add(trade_date) + + # 批量加载已有记录的去重键 + existing_keys = _load_existing_dedup_keys(db, all_dates) records = [] + new_keys_in_batch = set() # 防止本次导入文件内部重复 # 处理期货记录 if not df_futures.empty: @@ -217,12 +279,24 @@ def save_to_db( contract = str(row.get('合约', '')).strip() if not contract or contract == '合计': continue + futures_parsed += 1 variety = extract_variety(contract) trade_date = _normalize_date(row.get('实际成交日期', '')) trade_time = _normalize_time(row.get('成交时间', '')) bs_flag = str(row.get('买/卖', '')).strip() oc_flag = str(row.get('开/平', '')).strip() + price = safe_float(row.get('成交价')) + + dedup_key = _build_dedup_key(trade_date, variety, trade_time, price) + if dedup_key in existing_keys or dedup_key in new_keys_in_batch: + futures_skipped += 1 + continue + # 记录前几条的去重键用于调试 + if futures_parsed <= 3: + logger.info(f"[去重校验] 期货记录 #{futures_parsed}: date={trade_date!r}, variety={variety!r}, time={trade_time!r}, price={price!r} => key={dedup_key!r}") + + new_keys_in_batch.add(dedup_key) rec = TradeRecord( trade_type='期货', symbol=contract, @@ -230,7 +304,7 @@ def save_to_db( symbol_name=_VARIETY_NAME_MAP.get(variety, ''), direction='买' if '买' in bs_flag else '卖', offset='开' if '开' in oc_flag else ('平' if '平' in oc_flag else ''), - price=safe_float(row.get('成交价')), + price=price, volume=safe_float(row.get('手数')), amount=safe_float(row.get('成交额')), close_pnl=safe_float(row.get('平仓盈亏')), @@ -242,8 +316,6 @@ def save_to_db( ) records.append(rec) futures_count += 1 - if trade_date: - all_dates.add(trade_date) # 处理期权记录 if not df_options.empty: @@ -251,11 +323,19 @@ def save_to_db( contract = str(row.get('品种合约', '')).strip() if not contract or contract == '合计': continue + options_parsed += 1 variety = extract_variety(contract) trade_date = _normalize_date(row.get('成交日期', '')) trade_time = _normalize_time(row.get('成交时间', '')) bs_flag = str(row.get('买/卖', '')).strip() + price = safe_float(row.get('权利金单价')) + + dedup_key = _build_dedup_key(trade_date, variety, trade_time, price) + if dedup_key in existing_keys or dedup_key in new_keys_in_batch: + options_skipped += 1 + continue + new_keys_in_batch.add(dedup_key) rec = TradeRecord( trade_type='期权', symbol=contract, @@ -263,7 +343,7 @@ def save_to_db( symbol_name=_VARIETY_NAME_MAP.get(variety, ''), direction='买' if '买' in bs_flag else '卖', offset='', - price=safe_float(row.get('权利金单价')), + price=price, volume=safe_float(row.get('成交量')), amount=safe_float(row.get('权利金')), close_pnl=0.0, @@ -275,8 +355,6 @@ def save_to_db( ) records.append(rec) options_count += 1 - if trade_date: - all_dates.add(trade_date) if records: db.add_all(records) @@ -301,11 +379,22 @@ def save_to_db( db.add(batch) db.commit() + logger.info( + f"[去重校验] 导入完成: 文件={filename}, " + f"期货 解析={futures_parsed} 新增={futures_count} 跳过={futures_skipped}, " + f"期权 解析={options_parsed} 新增={options_count} 跳过={options_skipped}, " + f"已有去重键数量={len(existing_keys)}" + ) + return { "batch_id": batch_id, "futures_count": futures_count, "options_count": options_count, "trade_dates": trade_dates_str, + "futures_skipped": futures_skipped, + "options_skipped": options_skipped, + "futures_parsed": futures_parsed, + "options_parsed": options_parsed, } diff --git a/app/static/futures_analysis.html b/app/static/futures_analysis.html index 0c03578..590a689 100644 --- a/app/static/futures_analysis.html +++ b/app/static/futures_analysis.html @@ -979,9 +979,13 @@
+ + ~ @@ -1128,6 +1132,20 @@
+ +
+
+
+ 批量导入结果 + +
+
+
+
+
+
+
+
diff --git a/app/static/futures_analysis.js b/app/static/futures_analysis.js index e16c92f..05df242 100644 --- a/app/static/futures_analysis.js +++ b/app/static/futures_analysis.js @@ -643,6 +643,12 @@ function initTradeReview() { }); document.getElementById('tr-file-input').addEventListener('change', trHandleFileImport); + // 批量导入按钮 + document.getElementById('tr-btn-batch-import').addEventListener('click', () => { + document.getElementById('tr-batch-file-input').click(); + }); + document.getElementById('tr-batch-file-input').addEventListener('change', trHandleBatchImport); + // 查询按钮 document.getElementById('tr-btn-query').addEventListener('click', () => { trCurrentPage = 1; @@ -821,7 +827,7 @@ async function trHandleFileImport(e) { trCurrentPage = 1; trLoadAllData(); } else { - alert(json.message || '导入失败'); + alert('导入失败: ' + (json.message || '未知错误')); } } catch (err) { alert('导入失败: ' + err.message); @@ -831,6 +837,79 @@ async function trHandleFileImport(e) { } } +async function trHandleBatchImport(e) { + const files = e.target.files; + if (!files || files.length === 0) return; + e.target.value = ''; + + const btn = document.getElementById('tr-btn-batch-import'); + btn.disabled = true; + btn.querySelector('span:last-child').textContent = '导入中...'; + + try { + const formData = new FormData(); + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + const res = await fetch(`${TR_API_BASE}/batch-import`, { method: 'POST', body: formData }); + const json = await res.json(); + + if (json.success) { + trShowBatchImportResult(json); + const data = json.data; + if (data.total_futures > 0 || data.total_options > 0) { + trCurrentPage = 1; + trLoadAllData(); + } + } else { + alert('批量导入失败: ' + (json.message || '未知错误')); + } + } catch (err) { + alert('批量导入失败: ' + err.message); + } finally { + btn.disabled = false; + btn.querySelector('span:last-child').textContent = '批量导入'; + } +} + +function trShowBatchImportResult(json) { + const modal = document.getElementById('tr-batch-import-modal'); + const summaryEl = document.getElementById('tr-batch-import-summary'); + const detailsEl = document.getElementById('tr-batch-import-details'); + const data = json.data; + + summaryEl.textContent = json.message; + + detailsEl.innerHTML = data.details.map(d => { + let statusIcon, statusColor; + if (!d.success) { + statusIcon = '❌'; + statusColor = 'var(--color-error, #ef4444)'; + } else if (d.new_count > 0 && d.skipped_count > 0) { + statusIcon = '⚡'; + statusColor = 'var(--color-warning, #f59e0b)'; + } else if (d.new_count > 0) { + statusIcon = '✅'; + statusColor = 'var(--color-success, #22c55e)'; + } else { + statusIcon = '⚠️'; + statusColor = 'var(--color-warning, #f59e0b)'; + } + return `
+
+ ${statusIcon} ${d.filename} +
+
${d.message}
+
`; + }).join(''); + + modal.classList.add('show'); +} + +function trCloseBatchImportModal() { + document.getElementById('tr-batch-import-modal').classList.remove('show'); +} + // ==================== 批次管理 ==================== async function trShowBatchModal() { diff --git a/data/futures_analysis.db b/data/futures_analysis.db index 5c5da999625ef2f32ea793d37380b1160ac03ab0..9626960accc5a38ce921cff095f8056973539efa 100644 GIT binary patch delta 6458 zcmc(jU5p#m702zhcWsY7bF(2IyUFfmon(`RWQ=F#&Uj|~5kk^J2@o1snv_6}?eQeA z5JG67G&7y};UGXto0g_GqLmu@@zSW9NKv&!z*ZF!53O3Dv=k&%R+SL#Q&E8mWs|n! zT@1$FkL!*EI=X(afA@dy{hxF0SaWph-PqAn|BfABEuM@-60Zg0Dkq`$tq4~mY(vzn;ckR`5Dp^Ti*N|xK7_*v_al4_;p+$wAUufh4TOgfzKL+;SK$6^ zpAaR}K@t*wCess7P92yO&keq%(}24&=BFzOzu4OX+_^c z3)g%b&rB@+miw=0PgXh-k=~MCk=`f2C(n_elOK{h$qurWZ9eMu`Q$t~L-vtTk|isN zNMh2F%zI%YeJ^+T^PpX)Kbgq2w@l60mEy#aZ@Z5M#**O(*%es1hfI;z$n)gar3-&gG{z9JjG?EK}D|x{c&7ePDzLz(X^MT8F!OP|X z{kdvw>0IPoU^g$gWIoVuu5&UU*$hg&ptum|H}+U&A+ixn^Mb-+pkMFr)M8{EO!9&W z9?-AdEnPfh4eaCv{yHYk~tz_ENupLuXwFVJNM>I2MQcY9oSh6l+D@9!^6pFH@*p5sqrY)N^pO?#( zvR<|gqo`@h$3@ga&dO^V)y5Kak#f=09mjNJ%?{S51xJ-l(@x$)@54KExG6zmRz>1vQ;cOfo!9QVvgu$PA%xFWsG&! zMHJPnsHUUKRw+=T1xQ>!?%#H#POy+3%tU0yR+8wq?-j9ftvyrg6C z1|f$QZBs6pmAp)qvLc&`ZOcYQsn}FERfj4c3Uz-dq~s_yb<3PM{|9$RcaI?Lj}S_3 zC$CD!$r))sd6=Z7ze(q71u-c;KKS-A?kDj~PxGXFrlon(I@8iVPwr-VnkPRqEzOgI znU)SfC%c%*ai*MehuQc)-Q6d;yhqk^wa$}qZ*46pAGMB|*US@ddN=XUU)DUoP+hlz zbq{zldnW3w;TLMzNP(#x-mReo-stdd2_ayx$70@%p#-jswBAm(c8q{NqXx;>_lS+Q{GN~ftHft82U1u~={)9Y z@>@2S(3)AHA5Di6%2x*l*yKg`3UrYE delta 10230 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