diff --git a/app/api/trade_review.py b/app/api/trade_review.py index 236a937..893e52b 100644 --- a/app/api/trade_review.py +++ b/app/api/trade_review.py @@ -101,8 +101,6 @@ async def batch_import_settlement( total_skipped_all = 0 processed_count = 0 failed_count = 0 - # 维护跨文件的去重键集合,用于文件间去重 - batch_existing_keys = set() for file in files: if not file.filename.endswith(('.xls', '.xlsx')): @@ -127,12 +125,7 @@ async def batch_import_settlement( failed_count += 1 continue - # 传入已处理文件的去重键,实现文件间去重 - result = save_to_db(db, df_futures, df_options, file.filename, batch_existing_keys) - - # 将本次导入的新记录去重键加入集合,供后续文件去重使用 - if 'new_keys' in result: - batch_existing_keys.update(result['new_keys']) + 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'] diff --git a/app/services/trade_parser.py b/app/services/trade_parser.py index 470f27c..76f8891 100644 --- a/app/services/trade_parser.py +++ b/app/services/trade_parser.py @@ -270,16 +270,14 @@ def save_to_db( df_futures: pd.DataFrame, df_options: pd.DataFrame, filename: str, - batch_existing_keys: set = None, ) -> dict: """ 将解析后的交易记录保存到数据库(含逐条去重校验) 去重规则:同一交易日 + 同一品种 + 同一交易时间 + 同一价格 => 视为重复,跳过 - :param batch_existing_keys: 批量导入时,已处理文件的去重键集合(用于文件间去重) + 仅与数据库已有记录去重,不对本次导入的数据去重(允许一次委托分多次成交)。 :return: { "batch_id": str, "futures_count": int, "options_count": int, "trade_dates": str, - "futures_skipped": int, "options_skipped": int, "total_parsed": int, - "new_keys": set # 本次导入新增的去重键(用于批量导入时传递给下一个文件) + "futures_skipped": int, "options_skipped": int, "total_parsed": int } """ _load_variety_name_map() @@ -310,14 +308,10 @@ def save_to_db( if trade_date: all_dates.add(trade_date) - # 批量加载已有记录的去重键 + # 批量加载已有记录的去重键(仅与数据库已有记录去重) existing_keys = _load_existing_dedup_keys(db, all_dates) - # 合并批量导入时已处理文件的去重键 - if batch_existing_keys: - existing_keys = existing_keys | batch_existing_keys records = [] - new_keys_in_batch = set() # 本次导入新增的去重键 skipped_details = [] # 记录被跳过的重复记录详情 # 处理期货记录 @@ -330,8 +324,8 @@ def save_to_db( variety = extract_variety(contract) raw_date = _normalize_date(row.get('实际成交日期', '')) trade_time = _normalize_time(row.get('成交时间', '')) - # 根据交易时间重新计算交易日(夜盘归属下一天) - trade_date = recalculate_trade_date(raw_date, trade_time) + # 导入时保留结算单原始交易日,不再根据夜盘时间重新计算 + trade_date = raw_date bs_flag = str(row.get('买/卖', '')).strip() oc_flag = str(row.get('开/平', '')).strip() price = safe_float(row.get('成交价')) @@ -352,7 +346,6 @@ def save_to_db( if futures_parsed <= 3: logger.info(f"[去重校验] 期货记录 #{futures_parsed}: raw_date={raw_date!r}, trade_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, @@ -383,8 +376,8 @@ def save_to_db( variety = extract_variety(contract) raw_date = _normalize_date(row.get('成交日期', '')) trade_time = _normalize_time(row.get('成交时间', '')) - # 根据交易时间重新计算交易日(夜盘归属下一天) - trade_date = recalculate_trade_date(raw_date, trade_time) + # 导入时保留结算单原始交易日,不再根据夜盘时间重新计算 + trade_date = raw_date bs_flag = str(row.get('买/卖', '')).strip() price = safe_float(row.get('权利金单价')) @@ -402,7 +395,6 @@ def save_to_db( # 期权盈亏来自权利金(带正负号:卖为正,买为负) premium = safe_float(row.get('权利金')) - new_keys_in_batch.add(dedup_key) rec = TradeRecord( trade_type='期权', symbol=contract, @@ -463,7 +455,6 @@ def save_to_db( "futures_parsed": futures_parsed, "options_parsed": options_parsed, "skipped_details": skipped_details, # 被跳过的重复记录详情 - "new_keys": new_keys_in_batch, } @@ -492,9 +483,12 @@ def calc_daily_summary(db: DBSession, start_date: str = None, end_date: str = No }) for r in records: - d = r.trade_date or '未知' - day = daily[d] - day["trade_date"] = d + # 统计展示时,21:00 之后的交易归集到下一交易日,但保留原始 trade_date 展示 + display_date = r.trade_date or '未知' + stat_date = recalculate_trade_date(display_date, r.trade_time or '') + day = daily[stat_date] + day["trade_date"] = stat_date + day["display_date"] = display_date day["total_trades"] += 1 pnl = (r.close_pnl or 0) - (r.commission or 0) day["total_pnl"] += pnl @@ -516,7 +510,7 @@ def calc_daily_summary(db: DBSession, start_date: str = None, end_date: str = No day = daily[d] total = day["win_count"] + day["loss_count"] result.append({ - "trade_date": day["trade_date"], + "trade_date": day["display_date"], "total_trades": day["total_trades"], "total_pnl": round(day["total_pnl"], 2), "total_commission": round(day["total_commission"], 2), diff --git a/data/buffer.db b/data/buffer.db index 181c135..83c6a69 100644 Binary files a/data/buffer.db and b/data/buffer.db differ diff --git a/data/futures_analysis.db b/data/futures_analysis.db index 0084989..8c9e80e 100644 Binary files a/data/futures_analysis.db and b/data/futures_analysis.db differ diff --git a/openspec/changes/trade-import-behavior-fix/.openspec.yaml b/openspec/changes/trade-import-behavior-fix/.openspec.yaml new file mode 100644 index 0000000..8803b47 --- /dev/null +++ b/openspec/changes/trade-import-behavior-fix/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-12 diff --git a/openspec/changes/trade-import-behavior-fix/design.md b/openspec/changes/trade-import-behavior-fix/design.md new file mode 100644 index 0000000..d69bfce --- /dev/null +++ b/openspec/changes/trade-import-behavior-fix/design.md @@ -0,0 +1,30 @@ +## Context + +当前 `trade_parser.py` 在 `save_to_db` 中对本次导入的数据做了文件内和跨文件去重,且会根据交易时间重新计算 `trade_date`(21:00 之后归属下一交易日)。 + +业务要求: +- 同一委托拆单成交的数据应全部导入,不应去重。 +- 导入时保留结算单原始日期。 +- 统计分析时按夜盘规则重新归集交易日,但展示日期仍用原始日期。 + +## Goals / Non-Goals + +**Goals:** +1. 移除导入时对本次导入数据的去重逻辑。 +2. 导入时直接使用结算单原始日期作为 `trade_date`。 +3. 统计分析查询时,新增一个统计用交易日字段,将 21:00 之后的数据归集到下一交易日。 +4. 前端统计表格的日期列仍显示原始成交日期。 + +**Non-Goals:** +- 不修改数据库 schema。 +- 不修改单条导入接口路径和返回结构。 + +## Decisions + +1. **去重策略**:仅保留与数据库已有记录的去重,移除 `batch_existing_keys` 和 `new_keys_in_batch` 逻辑。 +2. **交易日计算**:导入时不再调用 `recalculate_trade_date`;在 `trade_review_service.py` 查询统计时,使用 `recalculate_trade_date(trade_date, trade_time)` 生成统计用交易日,但原始 `trade_date` 字段保留用于展示。 +3. **前端展示**:表格中继续使用原始 `trade_date`,统计分组使用后端返回的统计用交易日字段(或在后端完成分组后直接返回)。 + +## Risks / Trade-offs + +- 如果用户重复导入同一文件,会产生重复数据。需要通过业务提示引导用户,本次变更不再自动去重。 diff --git a/openspec/changes/trade-import-behavior-fix/proposal.md b/openspec/changes/trade-import-behavior-fix/proposal.md new file mode 100644 index 0000000..49a3f48 --- /dev/null +++ b/openspec/changes/trade-import-behavior-fix/proposal.md @@ -0,0 +1,23 @@ +## Why + +交易复盘导入数据时,存在同一品种同一价格同一时间多次成交(拆单成交),不应被判定为重复数据而跳过。同时,导入时应保留结算单原始交易日,仅在统计分析展示时按夜盘规则重新归集交易日,且前端日期显示保持原始日期不变。 + +## What Changes + +1. **移除所有文件内/跨文件去重**:导入时不再对本次导入的数据做任何重复性校验,只保留数据库已有记录的去重。 +2. **保留原始交易日**:导入时直接使用结算单中的实际成交日期作为 `trade_date`,不再根据 21:00 之后的时间修改交易日。 +3. **展示时归集交易日**:统计分析查询时,将 21:00 之后的交易归集到下一个交易日进行统计,但前端表格中日列仍显示原始成交日期。 + +## Capabilities + +### New Capabilities +- 无 + +### Modified Capabilities +- 无 + +## Impact + +- `app/services/trade_parser.py`:移除 `batch_existing_keys`、`new_keys_in_batch` 等去重逻辑;移除 `recalculate_trade_date` 调用。 +- `app/api/trade_review.py`:批量导入时不再维护跨文件去重键。 +- `app/services/trade_review_service.py`:查询统计时按夜盘规则重新计算统计用交易日。 diff --git a/openspec/changes/trade-import-behavior-fix/tasks.md b/openspec/changes/trade-import-behavior-fix/tasks.md new file mode 100644 index 0000000..ae7be3d --- /dev/null +++ b/openspec/changes/trade-import-behavior-fix/tasks.md @@ -0,0 +1,18 @@ +## 1. 移除导入时的本次数据去重 + +- [x] 1.1 修改 `app/services/trade_parser.py`:移除 `batch_existing_keys` 参数、`new_keys_in_batch` 集合及所有向该集合添加去重键的逻辑 +- [x] 1.2 修改 `app/api/trade_review.py`:批量导入时不再创建和传递 `batch_existing_keys`,处理每个文件时独立调用 `save_to_db` + +## 2. 导入时使用原始交易日 + +- [x] 2.1 修改 `app/services/trade_parser.py`:导入期货和期权记录时,直接使用 `_normalize_date` 后的原始日期作为 `trade_date`,不再调用 `recalculate_trade_date` + +## 3. 统计分析展示时按夜盘归集交易日 + +- [x] 3.1 修改 `app/services/trade_parser.py`:在 `calc_daily_summary` 按交易日聚合统计时,使用 `recalculate_trade_date(trade_date, trade_time)` 生成统计用交易日,但保留原始 `trade_date` 用于前端展示 +- [x] 3.2 前端无需修改:表格日期列继续使用后端返回的 `trade_date`(即原始日期) + +## 4. 验证与归档 + +- [ ] 4.1 运行健康检查与相关测试 +- [ ] 4.2 提交代码并归档 change