tradingSystem/app/core/ledger_repair.py

237 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""逐笔历史修复的离线编译器。只有完整证据才生成写回计划,不猜基线。"""
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, timezone, timedelta
from decimal import Decimal, ROUND_HALF_UP
import hashlib
import json
from app.core.cushion import sell_allocation, cushion_state
D = lambda x: Decimal(str(x or 0))
TERMINAL = {'FILLED', 'CANCELLED', 'EXPIRED', 'REJECTED', 'ABORTED', 'SEND_FAILED'}
def canonical(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), default=str)
def digest(value):
return hashlib.sha256(canonical(value).encode()).hexdigest()
def rounded(value, places=3):
return str(D(value).quantize(Decimal(10) ** -places, rounding=ROUND_HALF_UP))
def net_lot_cost(lots):
"""核销均价只有三位小数;盈亏金额保留了逐笔核销金额,不能反乘均价丢精度。"""
return sum((D(l['qty']) * D(l['open_price']) -
(D(l['realized_pnl']) if l.get('realized_pnl') is not None else
D(l.get('closed_qty')) * (D(l.get('close_avg_price')) - D(l['open_price']))))
for l in lots)
def need(condition, message):
if not condition:
raise ValueError(message)
def compile_repair(backup, codes, *, scale=2000000, solid=0.03):
"""返回原行、目标列、逐笔分配和不能还原事项。输入、配置相同则结果完全一致。"""
need(backup.get('instance') == '155-sim', '仅接受已确认的 155 模拟仓备份')
need(codes and len(set(codes)) == len(codes), '股票范围为空或重复')
tables = backup['tables']
run_id = 'HIST_' + digest({'backup': backup, 'codes': sorted(codes),
'scale': scale, 'solid': solid})[:24]
out = {'run_id': run_id, 'backup_digest': digest(backup), 'codes': sorted(codes),
'changes': [], 'summaries': [], 'allocations': [], 'daily': {}, 'unknowns': [
'手续费原文为空时无法恢复实际净盈亏,以下盈亏均为不含费用的批次盈亏。',
'缺少完整盘中行情及当时账本,安全垫历史峰值保留原值,不声明已还原。',
'历史日报与全组合净值保留原始记录,受污染期间不得作为已修复的真实绩效。']}
snapshots = {x['ts_code']: x for x in backup['position_snapshot']['payload']['items']}
all_trades = []
for r in tables['pms_qmt_inbox']:
p = json.loads(r['payload_json'])
if p.get('ts_code') in codes:
all_trades.append(dict(r, payload=p))
by_no = set()
for r in all_trades:
p = r['payload']
need(p.get('trade_no') and p['trade_no'] not in by_no, '重复或缺失成交号')
by_no.add(p['trade_no'])
def change(table, row, fields):
out['changes'].append({'table': table, 'key': {'id': row['id']},
'before': row, 'fields': fields})
for code in sorted(codes):
positions = [x for x in tables['pms_position'] if x['ts_code'] == code]
need(len(positions) == 1 and code in snapshots, f'{code} 持仓事实缺失')
pos, fact = positions[0], snapshots[code]
lots = [x for x in tables['pms_lot'] if x['ts_code'] == code]
need(lots, f'{code} 无可追溯批次基线')
start = min(str(l['open_date']) for l in lots)
orders = {x['instruction_id']: x for x in tables['pms_qmt_order'] if x['ts_code'] == code}
instructions = {x['instruction_id']: x for x in tables['pms_instruction'] if x['ts_code'] == code}
trades = sorted([x for x in all_trades if x['payload']['ts_code'] == code],
key=lambda x: (int(x['payload']['trade_time']), int(x['seq'])))
before_qty = 0
current = []
for r in trades:
p = r['payload']
day = datetime.fromtimestamp(int(p['trade_time']) / 1000, timezone(timedelta(hours=8))).date().isoformat()
if day < start:
before_qty += int(p['qty']) * (1 if p['side'] == 'buy' else -1)
else:
current.append(r)
need(before_qty == 0, f'{code} 起点前未平仓,不能假设零基线')
need(current, f'{code} 缺少逐笔历史')
parents = defaultdict(list)
for iid, o in orders.items():
if str(o['created_at'])[:10] < start:
continue
need(o['status'] in TERMINAL, f'{iid} 仍有在途委托')
ts = [x for x in current if x['payload']['instruction_id'] == iid]
need(sum(int(x['payload']['qty']) for x in ts) == int(o['cum_qty']), f'{iid} 漏笔或累计量冲突')
for r in current:
p = r['payload']; o = orders.get(p['instruction_id'])
need(o and o['parent_id'] in instructions, f"{p['trade_no']} 缺出口或父指令")
need(o['side'] == p['side'] and o['broker_order_id'] == p['broker_order_id'], '成交身份冲突')
need(int(r['processed']) == 1 and int(p['qty']) > 0 and D(p['price']) > 0, '未完成原始入账或坏成交')
need(D(p.get('amount')) == int(p['qty']) * D(p['price']), '逐笔金额不一致')
parents[o['parent_id']].append(r)
fills = [x for x in backup['table_fills'] if x['ts_code'] == code and x['done_time'][:10] >= start]
for f in fills:
owners = [o for o in orders.values() if o['instruction_id'] == f['order_id'] or
(f.get('raw', {}).get('qmt_order_id') and o['broker_order_id'] == f['raw']['qmt_order_id'])]
need(len(owners) == 1 and owners[0]['side'] == f['side'], '存在无法解释的外部成交')
used, state, matched = set(), {}, {}
for r in current:
p = r['payload']; o = orders[p['instruction_id']]
if p['side'] != 'buy':
continue
matches = [l for l in lots if l['instruction_id'] == o['parent_id'] and
int(l['qty']) + int(l['closed_qty']) == int(p['qty']) and
D(l['open_price']) == D(p['price']) and str(l['created_at']) == str(r['processed_at'])]
need(len(matches) == 1 and matches[0]['id'] not in used, f"{p['trade_no']} 原批次无法唯一对应")
l = matches[0]; used.add(l['id']); matched[r['seq']] = l['id']
state[l['id']] = dict(l, qty=0, closed_qty=0, close_avg_price=None,
realized_pnl=Decimal(0), sell_amount=Decimal(0))
groups = defaultdict(list); void = []
for l in lots:
if l['id'] in used:
continue
void.append(l['id'])
if l['lot_type'] != 'RECON':
groups[(str(l['created_at']), str(l['open_price']))].append(l)
matched_fills = set()
for (created, price), group in groups.items():
qty = sum(int(l['qty']) + int(l['closed_qty']) for l in group)
fs = [f for f in fills if f['side'] == 'buy' and f['qty'] == qty and D(f['price']) == D(price)
and f['done_time'][:10] == created[:10] and all(
not l['instruction_id'] or l['instruction_id'] == orders[f['order_id']]['parent_id']
for l in group)]
need(len(fs) == 1 and fs[0]['order_id'] not in matched_fills, '非真实买入批次缺重复汇总单证据')
matched_fills.add(fs[0]['order_id'])
daily = {}; buy_cash = D(0); sell_cash = D(0)
for r in current:
p = r['payload']; qty = int(p['qty']); px = D(p['price'])
if p['side'] == 'buy':
state[matched[r['seq']]]['qty'] = qty
buy_cash += qty * px
else:
active = sorted((l for l in state.values() if l['qty'] > 0), key=lambda l: (str(l['open_date']), l['id']))
allocations = sell_allocation([dict(lot_id=l['id'], lot_type=l['lot_type'], qty=l['qty'],
open_date=int(str(l['open_date']).replace('-', ''))) for l in active], qty)
for a in allocations:
l = state[a['lot_id']]; q = a['qty']
l['qty'] -= q; l['closed_qty'] += q; l['sell_amount'] += q * px
l['realized_pnl'] += q * (px - D(l['open_price']))
out['allocations'].append(dict(ts_code=code, seq=r['seq'], trade_no=p['trade_no'],
lot_id=l['id'], qty=q, price=str(px), realized_pnl=str(q * (px - D(l['open_price'])))))
sell_cash += qty * px
day = str(r['received_at'])[:10]
q = sum(l['qty'] for l in state.values())
daily[day] = {'qty': q, 'buy_amount': str(buy_cash), 'sell_amount': str(sell_cash),
'realized_pnl': str(sum(l['realized_pnl'] for l in state.values())),
'avg_cost': rounded((buy_cash-sell_cash)/q) if q else None}
total = sum(l['qty'] for l in state.values())
need(total == int(fact['total_qty']), f'{code} 重建数量与 QMT 不符')
need(net_lot_cost(list(state.values())) == buy_cash - sell_cash, '成本与逐笔资金不守恒')
pnl = sum(l['realized_pnl'] for l in state.values())
for old in lots:
l = state.get(old['id'])
fields = {'qty': 0, 'closed_qty': 0, 'close_avg_price': None, 'realized_pnl': '0.00',
'status': 'CLOSED', 'note': f'{run_id} 原重复/RECON批次作废;原值见备份'}
if l:
fields.update(qty=l['qty'], closed_qty=l['closed_qty'],
close_avg_price=rounded(l['sell_amount']/l['closed_qty']) if l['closed_qty'] else None,
realized_pnl=rounded(l['realized_pnl'], 2), status='OPEN' if l['qty'] else 'CLOSED',
note=f'{run_id} 按逐笔重建;原值见备份')
change('pms_lot', old, fields)
by_type = defaultdict(int)
for l in state.values():
by_type[l['lot_type']] += l['qty']
avg = max(D(0), (buy_cash - sell_cash)/total) if total else None
px = D(fact['market_price']); cp = px/avg-1 if avg else None
# 保留原有 T+1 限制:当日买入不能因模拟 QMT 可用数过宽而全部放开。
today = backup['at'][:10]
day_open = daily[max((d for d in daily if d < today), default=min(daily))]['qty'] if min(daily) < today else 0
today_sell = sum(int(x['payload']['qty']) for x in current if x['received_at'][:10] == today and x['payload']['side']=='sell')
avail = min(int(fact['avail_qty']), max(0, day_open-today_sell), total)
fields = dict(total_qty=total, avail_qty=avail, base_qty=by_type['BASE']+by_type['RECON'],
add_qty=by_type['ADD'], dca_qty=by_type['DCA'], fill_qty=by_type['FILL'], t0_qty=by_type['T0'],
avg_cost=rounded(avg) if avg else None,
realized_t_profit=rounded(sum(l['realized_pnl'] for l in state.values() if l['lot_type']=='T0'),2),
cushion_pct=rounded(cp,4) if cp is not None else None,
cushion_state=cushion_state(float(cp) if cp is not None else None,solid),
pct_of_scale=rounded(total*px/D(scale),4) if scale else None,
status=('HOLDING' if pos['status']=='CLOSED' and total else pos['status']))
change('pms_position', pos, fields)
progress_rows = []
for pid, ts in parents.items():
ins = instructions[pid]; qty = sum(x['payload']['qty'] for x in ts)
amount = sum(D(x['payload']['amount']) for x in ts)
progress = json.loads(ins['progress_json'] or '{}')
evidence = {'run_id': run_id, 'old_exec_qty': ins['exec_qty'], 'old_status': ins['status'],
'exec_qty': qty, 'exec_amount': str(amount), 'remaining_qty': max(0, ins['qty']-qty),
'overfill_qty': max(0,qty-ins['qty']), 'seqs': [x['seq'] for x in ts]}
status = ins['status']
if status == 'CONFIRMED' and qty < ins['qty']:
status = 'CANCELLED'
evidence['hold_reason'] = '历史修复暂停剩余执行;子单均已终态,不自动续单'
progress['historical_repair'] = evidence
for child in progress.get('children', []):
child_ts = [x for x in ts if x['payload']['instruction_id'] == child.get('ref')]
child['actual_exec_qty'] = sum(x['payload']['qty'] for x in child_ts)
child['actual_exec_amount'] = str(sum(D(x['payload']['amount']) for x in child_ts))
change('pms_instruction', ins, dict(exec_qty=qty, exec_avg_price=rounded(amount/qty),
status=status, progress_json=canonical(progress)))
progress_rows.append(dict(instruction_id=pid, **evidence, new_status=status))
need(not [x for x in tables['pms_plan'] if x['ts_code']==code], '命令方案须另作完整重建,当前工具不猜命令进度')
# 活跃网格账的剩余档位来自同一批真实批次,已撤销策略不恢复。
for strategy in tables['pms_strategy']:
if strategy['ts_code'] != code or strategy['status'] != 'ACTIVE':
continue
need(strategy['type']=='GRID', '活跃非网格策略需要专门核验')
st = json.loads(strategy['state_json'] or '{}')
need(not st.get('pending'), '网格策略仍有待结算指令')
levels = defaultdict(lambda: [0,D(0)])
for l in state.values():
ins = instructions[l['instruction_id']]
if ins.get('origin_id') != strategy['strategy_id'] or not l['qty']:
continue
prog = json.loads(ins['progress_json'] or '{}'); leg = prog.get('leg','')
need(leg.startswith('grid_buy:'), '策略批次缺网格档位来源')
level = leg.split(':')[1]; levels[level][0] += l['qty']; levels[level][1] += l['qty']*D(l['open_price'])
st['filled_levels'] = {k:dict(qty=v[0],price=float(v[1]/v[0])) for k,v in levels.items()}
st['invested'] = float(sum(v[1] for v in levels.values()))
st['historical_repair'] = {'run_id':run_id,'source':'真实剩余批次'}
change('pms_strategy',strategy,dict(state_json=canonical(st)))
out['daily'][code] = daily
out['summaries'].append(dict(ts_code=code,start=start,trades=len(current),real_lots=len(used),
void_lots=void,old_qty=pos['total_qty'],qty=total,avg_cost=fields['avg_cost'],avail_qty=avail,
old_avg_cost=pos['avg_cost'],realized_pnl=rounded(pnl,2),buy_amount=str(buy_cash),sell_amount=str(sell_cash),
instructions=progress_rows))
return json.loads(canonical(out))