tradingSystem/scripts/test_batch40_units.py

175 lines
10 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 -*-
"""本地:python scripts/unit_test_guard.py scripts/test_batch40_units.py。历史修复离线回归。"""
import unit_test_guard # noqa
import sys
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
import json
import unittest
from copy import deepcopy
from contextlib import contextmanager
from unittest.mock import patch
from app.core.ledger_repair import compile_repair, net_lot_cost, D
from app.db import session
CODE='600001.SH'
def fixture():
ins=dict(id=1,instruction_id='P',ts_code=CODE,qty=200,exec_qty=200,status='CONFIRMED',
progress_json='{"children":[{"ref":"P_D01"}]}',origin_id=None,updated_at='2026-10-15 10:00:00')
order=dict(instruction_id='P_D01',parent_id='P',ts_code=CODE,side='buy',broker_order_id='B',
status='FILLED',cum_qty=100,created_at='2026-10-15 10:00:00')
lot=dict(id=1,ts_code=CODE,instruction_id='P',qty=100,closed_qty=0,open_price='10.000',
open_date='2026-10-15',created_at='2026-10-15 10:01:00',lot_type='BASE',realized_pnl='0.00')
pos=dict(id=1,ts_code=CODE,total_qty=200,avg_cost='10.000',status='HOLDING')
payload=dict(ts_code=CODE,instruction_id='P_D01',trade_no='T',broker_order_id='B',side='buy',
qty=100,price=10,amount=1000,trade_time=1792029600000)
trade=dict(seq=1,payload_json=json.dumps(payload),processed=1,received_at='2026-10-15 10:00:00',
processed_at='2026-10-15 10:01:00')
tables=dict(pms_position=[pos],pms_instruction=[ins],pms_qmt_order=[order],pms_lot=[lot,dict(lot,id=2,
instruction_id=None,created_at='2026-10-15 10:02:00')],pms_qmt_inbox=[trade],pms_plan=[],pms_strategy=[])
return dict(instance='155-sim',at='2026-10-15T12:00:00+08:00',tables=tables,
position_snapshot={'payload':{'items':[dict(ts_code=CODE,total_qty=100,avail_qty=100,market_price=11)]}},
table_fills=[dict(order_id='P_D01',ts_code=CODE,side='buy',qty=100,price=10,done_time='2026-10-15 10:00:00')])
class CoreTests(unittest.TestCase):
def plan(self,b=None):return compile_repair(b or fixture(),[CODE])
def test_quantity_cost_progress_and_terminal_hold(self):
p=self.plan();s=p['summaries'][0]
self.assertEqual((s['qty'],s['avg_cost'],s['avail_qty']),(100,'10.000',0))
self.assertEqual(s['void_lots'],[2]);self.assertEqual(s['instructions'][0]['new_status'],'CANCELLED')
self.assertEqual(s['instructions'][0]['exec_qty'],100)
def test_determinism(self):self.assertEqual(self.plan(),self.plan())
def test_missing_trade_blocks(self):
b=fixture();b['tables']['pms_qmt_inbox']=[]
with self.assertRaises(ValueError):self.plan(b)
def test_duplicate_trade_blocks(self):
b=fixture();b['tables']['pms_qmt_inbox']*=2
with self.assertRaises(ValueError):self.plan(b)
def test_wrong_broker_blocks(self):
b=fixture();b['tables']['pms_qmt_order'][0]['broker_order_id']='X'
with self.assertRaises(ValueError):self.plan(b)
def test_missing_summary_evidence_blocks(self):
b=fixture();b['table_fills']=[]
with self.assertRaises(ValueError):self.plan(b)
def test_ambiguous_lot_blocks(self):
b=fixture();b['tables']['pms_lot'][1].update(instruction_id='P',created_at='2026-10-15 10:01:00')
with self.assertRaises(ValueError):self.plan(b)
def test_position_disagreement_blocks(self):
b=fixture();b['position_snapshot']['payload']['items'][0]['total_qty']=200
with self.assertRaises(ValueError):self.plan(b)
def test_pending_order_blocks(self):
b=fixture();b['tables']['pms_qmt_order'][0]['status']='PARTIAL'
with self.assertRaises(ValueError):self.plan(b)
def test_active_grid_cost_and_qty_follow_actual_lots(self):
b=fixture();t=b['tables'];t['pms_instruction'][0].update(origin_id='GRID',progress_json='{"leg":"grid_buy:3"}')
t['pms_lot'][0]['lot_type']='ADD'
t['pms_strategy']=[dict(id=1,ts_code=CODE,strategy_id='GRID',type='GRID',status='ACTIVE',
state_json='{"pending":null,"filled_levels":{"3":{"qty":200,"price":10}},"invested":2000}')]
p=self.plan(b);row=next(c for c in p['changes'] if c['table']=='pms_strategy')
st=json.loads(row['fields']['state_json'])
self.assertEqual(st['filled_levels']['3']['qty'],100);self.assertEqual(st['invested'],1000)
def test_missing_baseline_is_not_assumed_zero(self):
b=fixture();p=json.loads(b['tables']['pms_qmt_inbox'][0]['payload_json'])
p.update(trade_no='OLD',trade_time=p['trade_time']-86400000)
b['tables']['pms_qmt_inbox'].append(dict(b['tables']['pms_qmt_inbox'][0],seq=0,payload_json=json.dumps(p)))
with self.assertRaises(ValueError):self.plan(b)
def test_existing_live_parent_stays_live(self):
b=fixture();b['tables']['pms_instruction'][0]['status']='DISPATCHED'
self.assertEqual(self.plan(b)['summaries'][0]['instructions'][0]['new_status'],'DISPATCHED')
def test_real_overfill_is_not_erased(self):
b=fixture();b['tables']['pms_instruction'][0]['qty']=50
s=self.plan(b)['summaries'][0]['instructions'][0]
self.assertEqual((s['exec_qty'],s['overfill_qty']),(100,50))
def test_new_fill_invalidates_fixed_historical_average(self):
import sqlite3
from app.repo import pms_repo
with sqlite3.connect(':memory:') as db:
db.execute('CREATE TABLE pms_instruction (instruction_id TEXT, exec_qty INTEGER, exec_avg_price REAL, updated_at TEXT, progress_json TEXT)')
db.execute("INSERT INTO pms_instruction VALUES ('P',100,10,'old','fixed evidence')")
with patch.object(pms_repo,'execute',side_effect=lambda sql,params:db.execute(sql,params).rowcount):
pms_repo.add_instruction_exec('P',100)
row=db.execute('SELECT exec_qty,exec_avg_price,progress_json FROM pms_instruction').fetchone()
self.assertEqual(row,(200,None,'fixed evidence'))
def test_money_precision_does_not_multiply_rounded_average(self):
lots=[dict(qty=1,closed_qty=3,open_price='10.000',close_avg_price='10.003',realized_pnl='0.01')]
self.assertEqual(net_lot_cost(lots),D('9.99'))
def test_sell_rebuilds_pnl_and_remaining_cost(self):
b=fixture();t=b['tables'];o=dict(t['pms_qmt_order'][0],instruction_id='S_D01',parent_id='S',side='sell',cum_qty=40)
t['pms_qmt_order'].append(o);t['pms_instruction'].append(dict(t['pms_instruction'][0],id=2,instruction_id='S',qty=40))
p=json.loads(t['pms_qmt_inbox'][0]['payload_json']);p.update(instruction_id='S_D01',trade_no='T2',side='sell',qty=40,price=11,amount=440,trade_time=p['trade_time']+60000)
t['pms_qmt_inbox'].append(dict(t['pms_qmt_inbox'][0],seq=2,payload_json=json.dumps(p)))
b['position_snapshot']['payload']['items'][0]['total_qty']=60
s=self.plan(b)['summaries'][0]
self.assertEqual((s['qty'],s['realized_pnl'],s['avg_cost']),(60,'40.00','9.333'))
class Result:
def __init__(self,rows):self.rows=rows
def mappings(self):return self
def fetchall(self):return self.rows
class Engine:
def __init__(self):self.rows={1:{'id':1,'qty':100,'ts_code':CODE},2:{'id':2,'qty':200,'ts_code':CODE}};self.audit=[];self.fail=False
@contextmanager
def begin(self):
old=deepcopy((self.rows,self.audit))
try:yield self
except Exception:
self.rows,self.audit=old
raise
def execute(self,sql,params):
s=str(sql);session.assert_single_table(s)
if s.startswith('SELECT id FROM pms_op_log'):
return Result([{'id':1}] if any(x['op']=='history_repair_summary' and x['ref']==params['r'] for x in self.audit) else [])
if s.startswith('SELECT * FROM pms_lot'):return Result([deepcopy(self.rows[params['id']])])
if s.startswith('UPDATE'):
if self.fail and params['row_id']==2:raise RuntimeError('interrupted')
self.rows[params['row_id']].update({k:v for k,v in params.items() if k!='row_id'})
return Result([])
if s.startswith('INSERT INTO pms_op_log'):self.audit.append(deepcopy(params));return Result([])
raise AssertionError(s)
class TransactionTests(unittest.TestCase):
def setUp(self):
self.engine=Engine();self.plan=dict(run_id='TEST',changes=[dict(table='pms_lot',key={'id':i},before=deepcopy(r),fields={'qty':0}) for i,r in self.engine.rows.items()])
self.p=patch.object(session,'get_engine',return_value=self.engine);self.p.start();self.addCleanup(self.p.stop)
def test_success_and_idempotency(self):
self.assertEqual(session.apply_audited_repair(self.plan,[])['changed_rows'],2)
self.assertTrue(session.apply_audited_repair(self.plan,[])['already_applied'])
self.assertEqual(len(self.engine.audit),3)
def test_failure_rolls_back_all_rows_and_audit(self):
self.engine.fail=True
with self.assertRaises(RuntimeError):session.apply_audited_repair(self.plan,[])
self.assertEqual(self.engine.rows[1]['qty'],100);self.assertEqual(self.engine.audit,[])
def test_changed_before_image_rejects(self):
self.engine.rows[2]['qty']=201
with self.assertRaises(ValueError):session.apply_audited_repair(self.plan,[])
self.assertEqual(self.engine.rows[1]['qty'],100)
def test_forbidden_table_rejected(self):
self.plan['changes'][0]['table']='real_pms_lot'
with self.assertRaises(ValueError):session.apply_audited_repair(self.plan,[])
def test_audited_inverse_restores_original_values(self):
for row in self.engine.rows.values():row['updated_at']='2026-10-15 09:00:00'
for item in self.plan['changes']:item['before']=deepcopy(self.engine.rows[item['key']['id']])
original=deepcopy(self.engine.rows)
session.apply_audited_repair(self.plan,[])
changes=[]
for row in self.engine.audit:
if row['op']!='history_repair_row':continue
item=json.loads(row['params'])
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'}}))
session.apply_audited_repair(dict(run_id='TEST',changes=changes),[],rollback=True)
self.assertEqual(self.engine.rows,original)
def test_single_table_guard_still_applies(self):
with self.assertRaises(session.MultiTableSQL):
session.apply_audited_repair(self.plan,[dict(name='bad',sql='SELECT * FROM pms_lot JOIN pms_position ON 1=1',expected=[])])
if __name__=='__main__':unittest.main()