tradingSystem/scripts/repair_ledger_history.py

96 lines
5.8 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""155 容器:python scripts/repair_ledger_history.py backup --codes ... > 主机备份.json
预演:python scripts/repair_ledger_history.py preview --codes ... < 备份.json
写入:停止 worker/beat 后,apply --backup-sha256 <文件哈希> --codes ... < 同一备份
回退:rollback --run-id HIST_xxx,仅原值未再变化时可回退。审计和原证据均保留。
脚本无模型调用、无交易下发,所有 SQL 经单表守卫;不得在开发机连接数据库。
"""
import argparse
import hashlib
import json
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
from app.core.ledger_repair import compile_repair, canonical
def backup(codes):
from app.db.session import fetch_all, TABLE_PREFIX
from app.repo import qmt_repo as q, pms_repo as p, downstream_repo as ds
from app.services import param_store as ps
if TABLE_PREFIX:
raise ValueError('仅允许模拟仓命名空间')
out = dict(schema=1,at=datetime.now().isoformat()+'+08:00',instance='155-sim',tables={})
for table in ['pms_position','pms_lot','pms_instruction','pms_plan','pms_command','pms_qmt_order',
'pms_cash_flow','pms_strategy','pms_proposal','pms_daily_report','pms_nav_daily']:
out['tables'][table] = fetch_all('SELECT * FROM '+table+' ORDER BY '+('ymd' if table=='pms_nav_daily' else 'id'))
params={f'c{i}':c for i,c in enumerate(codes)}; where='ts_code IN ('+','.join(':'+k for k in params)+')'
for table in ['pms_action_ledger','pms_op_log']:
out['tables'][table]=fetch_all('SELECT * FROM '+table+' WHERE '+where+' ORDER BY id',params)
rows=[];seq=0
while True:
page=fetch_all("SELECT * FROM pms_qmt_inbox WHERE msg_type='trade' AND seq>:s ORDER BY seq LIMIT 1000",{'s':seq})
if not page:break
rows.extend(page);seq=page[-1]['seq']
out['tables']['pms_qmt_inbox']=rows
out['position_snapshot']=q.latest_snapshot('positions')
out['funds_snapshot']=q.latest_snapshot('funds')
out['table_fills']=ds.fetch_filled_orders(since_time='2000-01-01 00:00:00',limit=100000)
if len(out['table_fills'])>=100000:raise ValueError('成交表截断,拒绝备份')
out['replay_cursor']=p.get_param('PMS_REPLAY_CURSOR')
out['runtime_rows']=fetch_all("SELECT * FROM pms_runtime_param WHERE param_key IN ('PMS_TOTAL_SCALE','PMS_CUSHION_SOLID','PMS_DISPATCH_MODE','PMS_PLAN_VERSION_RECHECK','PMS_REPLAY_CURSOR') ORDER BY param_key")
out['effective']={k:ps.get(k) for k in ['PMS_TOTAL_SCALE','PMS_CUSHION_SOLID','PMS_DISPATCH_MODE','PMS_PLAN_VERSION_RECHECK']}
return out
def guards_for(b,codes):
guards=[]
params={f'c{i}':c for i,c in enumerate(codes)};where='ts_code IN ('+','.join(':'+k for k in params)+')'
for table in ['pms_position','pms_lot','pms_instruction','pms_qmt_order','pms_strategy','pms_plan']:
guards.append(dict(name=table,sql='SELECT * FROM '+table+' WHERE '+where+' ORDER BY id FOR UPDATE',
params=params,expected=[x for x in b['tables'][table] if x['ts_code'] in codes]))
guards.append(dict(name='全部逐笔证据',sql="SELECT * FROM pms_qmt_inbox WHERE msg_type='trade' ORDER BY seq FOR UPDATE",
expected=b['tables']['pms_qmt_inbox']))
guards.append(dict(name='运行参数及回放游标',sql="SELECT * FROM pms_runtime_param WHERE param_key IN ('PMS_TOTAL_SCALE','PMS_CUSHION_SOLID','PMS_DISPATCH_MODE','PMS_PLAN_VERSION_RECHECK','PMS_REPLAY_CURSOR') ORDER BY param_key FOR UPDATE", expected=b['runtime_rows']))
return guards
def main():
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('mode',choices=['backup','preview','apply','rollback'])
ap.add_argument('--codes',nargs='+');ap.add_argument('--backup-sha256');ap.add_argument('--run-id')
args=ap.parse_args()
if args.mode=='backup':
if not args.codes:raise ValueError('缺股票范围')
print(canonical(backup(args.codes)));return
if args.mode=='rollback':
from app.db.session import fetch_all, apply_audited_repair, TABLE_PREFIX
if TABLE_PREFIX or not args.run_id:raise ValueError('回退实例或批号错误')
rows=fetch_all("SELECT * FROM pms_op_log WHERE ref=:r AND op='history_repair_row' ORDER BY id",{'r':args.run_id})
if not rows:raise ValueError('无修复审计')
changes=[]
for row in rows:
item=json.loads(row['params_json'])
changes.append(dict(table=item['table'],key=item['key'],before=item['after'],
fields={k:item['before'][k] for k in set(item['fields'])|{'updated_at'}}))
print(canonical(apply_audited_repair(dict(run_id=args.run_id,changes=changes),[],rollback=True)));return
raw=sys.stdin.buffer.read();b=json.loads(raw)
if not args.codes:raise ValueError('缺股票范围')
effective=b.get('effective',{})
plan=compile_repair(b,args.codes,scale=float(effective.get('PMS_TOTAL_SCALE',2000000)),
solid=float(effective.get('PMS_CUSHION_SOLID',0.03)))
if args.mode=='preview':print(canonical(plan));return
if not args.backup_sha256 or hashlib.sha256(raw).hexdigest()!=args.backup_sha256:
raise ValueError('完整备份文件哈希不符')
from app.db.session import apply_audited_repair, TABLE_PREFIX
if TABLE_PREFIX:raise ValueError('拒绝真实仓')
age=(datetime.now()-datetime.fromisoformat(b['at']).replace(tzinfo=None)).total_seconds()
if not 0<=age<900:raise ValueError('备份超过15分钟,须重新备份和预演')
if not b['position_snapshot'] or b['position_snapshot']['age_sec']>900:
raise ValueError('QMT持仓快照过旧')
print(canonical(apply_audited_repair(plan,guards_for(b,args.codes))))
if __name__=='__main__':main()