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