tradingSystem/app/core/ledger_repair.py

237 lines
15 KiB
Python
Raw Normal View History

# -*- 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))