tweak: 修复交易复盘导入问题(去重详情提示、期权盈亏计算)

refactor3.0
Lxy 1 week ago
parent caeb1a56be
commit be91dbf06c

@ -60,10 +60,12 @@ async def import_settlement(
total_parsed = result['futures_parsed'] + result['options_parsed'] total_parsed = result['futures_parsed'] + result['options_parsed']
if total_new == 0 and total_skipped > 0: if total_new == 0 and total_skipped > 0:
skipped_details = result.get('skipped_details', [])
return { return {
"success": True, "success": True,
"message": f"全部跳过:共解析 {total_parsed} 条记录,均已存在(去重规则:交易日+品种+时间+价格),未导入任何新数据", "message": f"全部跳过:共解析 {total_parsed} 条记录,均已存在(去重规则:交易日+品种+时间+价格),未导入任何新数据",
"data": result, "data": result,
"skipped_details": skipped_details,
} }
msg_parts = [f"导入成功:期货 {result['futures_count']} 条,期权 {result['options_count']}"] msg_parts = [f"导入成功:期货 {result['futures_count']} 条,期权 {result['options_count']}"]
@ -75,6 +77,7 @@ async def import_settlement(
"success": True, "success": True,
"message": "".join(msg_parts), "message": "".join(msg_parts),
"data": result, "data": result,
"skipped_details": result.get('skipped_details', []),
} }
except HTTPException: except HTTPException:
raise raise
@ -145,6 +148,7 @@ async def batch_import_settlement(
"message": "".join(msg_parts), "message": "".join(msg_parts),
"new_count": file_new, "new_count": file_new,
"skipped_count": file_skipped, "skipped_count": file_skipped,
"skipped_details": result.get('skipped_details', []),
"trade_dates": result['trade_dates'], "trade_dates": result['trade_dates'],
"batch_id": result['batch_id'], "batch_id": result['batch_id'],
}) })

@ -270,6 +270,7 @@ def save_to_db(
records = [] records = []
new_keys_in_batch = set() # 防止本次导入文件内部重复 new_keys_in_batch = set() # 防止本次导入文件内部重复
skipped_details = [] # 记录被跳过的重复记录详情
# 处理期货记录 # 处理期货记录
if not df_futures.empty: if not df_futures.empty:
@ -288,6 +289,13 @@ def save_to_db(
dedup_key = _build_dedup_key(trade_date, variety, trade_time, price) 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: if dedup_key in existing_keys or dedup_key in new_keys_in_batch:
futures_skipped += 1 futures_skipped += 1
skipped_details.append({
'type': '期货',
'variety': variety,
'date': trade_date,
'time': trade_time,
'price': price,
})
continue continue
# 记录前几条的去重键用于调试 # 记录前几条的去重键用于调试
@ -331,9 +339,18 @@ def save_to_db(
dedup_key = _build_dedup_key(trade_date, variety, trade_time, price) 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: if dedup_key in existing_keys or dedup_key in new_keys_in_batch:
options_skipped += 1 options_skipped += 1
skipped_details.append({
'type': '期权',
'variety': variety,
'date': trade_date,
'time': trade_time,
'price': price,
})
continue continue
new_keys_in_batch.add(dedup_key) new_keys_in_batch.add(dedup_key)
# 期权盈亏来自权利金(带正负号:卖为正,买为负)
premium = safe_float(row.get('权利金'))
rec = TradeRecord( rec = TradeRecord(
trade_type='期权', trade_type='期权',
symbol=contract, symbol=contract,
@ -343,8 +360,8 @@ def save_to_db(
offset='', offset='',
price=price, price=price,
volume=safe_float(row.get('成交量')), volume=safe_float(row.get('成交量')),
amount=safe_float(row.get('权利金')), amount=premium,
close_pnl=0.0, close_pnl=premium, # 期权盈亏 = 权利金(带正负号)
commission=safe_float(row.get('手续费')), commission=safe_float(row.get('手续费')),
trade_date=trade_date, trade_date=trade_date,
trade_time=trade_time, trade_time=trade_time,
@ -393,6 +410,7 @@ def save_to_db(
"options_skipped": options_skipped, "options_skipped": options_skipped,
"futures_parsed": futures_parsed, "futures_parsed": futures_parsed,
"options_parsed": options_parsed, "options_parsed": options_parsed,
"skipped_details": skipped_details, # 被跳过的重复记录详情
} }

@ -2326,7 +2326,7 @@ async function trHandleFileImport(e) {
const res = await fetch(`${TR_API_BASE}/import`, { method: 'POST', body: formData }); const res = await fetch(`${TR_API_BASE}/import`, { method: 'POST', body: formData });
const json = await res.json(); const json = await res.json();
if (json.success) { if (json.success) {
alert(json.message); trShowImportResult(json.message, json.skipped_details || []);
trLoadAllData(); trLoadAllData();
} else { } else {
alert('导入失败: ' + (json.message || '未知错误')); alert('导入失败: ' + (json.message || '未知错误'));
@ -2339,6 +2339,53 @@ async function trHandleFileImport(e) {
} }
} }
// 显示导入结果(含去重详情)
function trShowImportResult(message, skippedDetails) {
let html = `<div style="padding:16px;max-width:500px;">
<div style="font-size:14px;margin-bottom:12px;">${message}</div>`;
if (skippedDetails && skippedDetails.length > 0) {
html += `<div style="margin-top:12px;border-top:1px solid #eee;padding-top:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:#666;">
被过滤的重复记录${skippedDetails.length}
</div>
<div style="max-height:200px;overflow-y:auto;font-size:12px;background:#f9f9f9;border-radius:4px;padding:8px;">`;
// 按类型分组显示
const grouped = {};
skippedDetails.forEach(d => {
const key = d.type || '未知';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(d);
});
for (const [type, items] of Object.entries(grouped)) {
html += `<div style="margin-bottom:6px;"><strong>${type}</strong>${items.length} 条):</div>`;
items.slice(0, 10).forEach(d => {
html += `<div style="padding:2px 0;color:#555;">• ${d.variety} ${d.date} ${d.time} @ ${d.price}</div>`;
});
if (items.length > 10) {
html += `<div style="color:#999;">... 还有 ${items.length - 10} 条</div>`;
}
}
html += `</div></div>`;
}
html += `</div>`;
// 使用自定义弹窗
const modal = document.createElement('div');
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;';
modal.innerHTML = `<div style="background:white;border-radius:8px;max-width:600px;max-height:80vh;overflow-y:auto;">
${html}
<div style="padding:12px 16px;border-top:1px solid #eee;text-align:right;">
<button onclick="this.closest('div[style*=fixed]').remove()" style="padding:6px 16px;background:#007aff;color:white;border:none;border-radius:4px;cursor:pointer;">确定</button>
</div>
</div>`;
document.body.appendChild(modal);
}
async function trHandleBatchImport(e) { async function trHandleBatchImport(e) {
const files = e.target.files; const files = e.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@ -2375,9 +2422,27 @@ function trShowBatchImportResult(json) {
summaryEl.textContent = json.message; summaryEl.textContent = json.message;
detailsEl.innerHTML = data.details.map(d => { detailsEl.innerHTML = data.details.map(d => {
let icon = d.success ? (d.new_count > 0 ? '✅' : '⚠️') : '❌'; let icon = d.success ? (d.new_count > 0 ? '✅' : '⚠️') : '❌';
let skippedHtml = '';
if (d.skipped_details && d.skipped_details.length > 0) {
const grouped = {};
d.skipped_details.forEach(item => {
const key = item.type || '未知';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(item);
});
skippedHtml = `<div style="margin-top:6px;font-size:11px;color:#888;max-height:80px;overflow-y:auto;">`;
for (const [type, items] of Object.entries(grouped)) {
skippedHtml += `<div><strong>${type}</strong> ${items.length}条: `;
skippedHtml += items.slice(0, 5).map(item => `${item.variety}@${item.price}`).join(', ');
if (items.length > 5) skippedHtml += ` ...`;
skippedHtml += `</div>`;
}
skippedHtml += `</div>`;
}
return `<div style="padding:10px 12px;margin-bottom:8px;border-radius:6px;background:#F5F5F7;border-left:3px solid ${d.success ? '#34C759' : '#FF3B30'};"> return `<div style="padding:10px 12px;margin-bottom:8px;border-radius:6px;background:#F5F5F7;border-left:3px solid ${d.success ? '#34C759' : '#FF3B30'};">
<div style="font-weight:600;font-size:13px;">${icon} ${d.filename}</div> <div style="font-weight:600;font-size:13px;">${icon} ${d.filename}</div>
<div style="font-size:12px;color:var(--text-tertiary);margin-top:4px;">${d.message}</div> <div style="font-size:12px;color:var(--text-tertiary);margin-top:4px;">${d.message}</div>
${skippedHtml}
</div>`; </div>`;
}).join(''); }).join('');
modal.classList.add('show'); modal.classList.add('show');

Binary file not shown.

Binary file not shown.

@ -0,0 +1,14 @@
# 设计文档
## 1. 去重详情提示
- `save_to_db` 新增 `skipped_details` 列表,记录每条被跳过的记录:`{variety, date, time, price, type}`
- API 响应中包含此列表
- 前端弹窗中展示被跳过的记录详情
## 2. 批量导入修复
- 检查前端 FormData 传参方式
- 确保后端 `files` 参数正确接收
## 3. 期权盈亏修复
- 期权 `close_pnl` 改为使用 `权利金` 字段(带正负号:卖为正,买为负)
- `calc_daily_summary` 中期权盈亏公式:`pnl = amount - commission`amount 即权利金)

@ -0,0 +1,16 @@
# 交易复盘导入修复
## 动机
1. 去重过滤时只提示跳过了多少条,未说明具体是哪些记录被过滤
2. 批量导入功能失败
3. 期权交易盈亏计算错误close_pnl 硬编码为 0应使用权利金计算盈亏
## 目标
1. 去重时记录被过滤记录的详细信息(品种、日期、时间、价格),并在前端展示
2. 修复批量导入失败问题
3. 修复期权盈亏计算使用权利金amount作为期权盈亏来源
## 范围
- `app/services/trade_parser.py` — 去重详情收集、期权盈亏修复
- `app/api/trade_review.py` — 返回去重详情、批量导入修复
- `app/static/futures_analysis.js` — 前端展示去重详情

@ -0,0 +1,5 @@
# 任务列表
- [x] 1. 修复期权盈亏计算:`save_to_db` 中期权 `close_pnl` 改为使用权利金(带正负号)
- [x] 2. 收集去重详情:`save_to_db` 新增 `skipped_details` 列表,记录被跳过的记录详情
- [x] 3. 修复批量导入 + 前端展示去重详情
Loading…
Cancel
Save