tradingSystem/scripts/test_batch43_units.py

153 lines
12 KiB
Python
Raw Normal View History

"""开发机:python scripts/unit_test_guard.py scripts/test_batch43_units.py。隔离禁网,真实SQL事务。"""
import unit_test_guard
import os,sys,copy,tempfile,json
from datetime import datetime,timedelta
from types import SimpleNamespace
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
import unittest
sys.path.insert(0,os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine,text,event
from app.db import session
from app.repo import pms_repo
from app.services import plan_feed as pf,plan_recheck as rc,proposal_service as ps,portfolio,command_service,param_store,market,execution_conflicts
from app.core import plan_revision as pr,tradedays as td
from app.web import main
import requests
CODE='300693.SZ'
class AcceptancePathTest(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
self.engine=create_engine('sqlite:///'+self.tmp.name+'/state.sqlite',connect_args={'check_same_thread':False})
self.addCleanup(self.engine.dispose)
def use(target,**kw):
p=patch(target,**kw);v=p.start();self.addCleanup(p.stop);return v
use('app.db.session.get_engine',return_value=self.engine);use('app.db.session._check_cooldown')
use('app.services.param_store.get',side_effect=lambda k,d=None:d)
use('app.services.param_store.get_int',side_effect=lambda k,d=0:d)
use('app.services.param_store.get_float',side_effect=lambda k,d=0:d)
use('app.services.param_store.get_list',side_effect=lambda k,d=None:d or [])
use('app.services.param_store.get_bool',side_effect=lambda k,d=False:True if k=='PMS_PLAN_VERSION_RECHECK' else d)
use('app.web.main._oplog');use('app.web.main._actor',return_value='test')
self.params=pf._params();self.params.update(base='http://bridge',tiers=['强传导'])
use('app.services.plan_feed._params',return_value=self.params)
self.view={'held':[],'params':{'max_names':40,'scale':2000000,'portfolio_cap':0.7},
'totals':{'names_count':0,'portfolio_mv':0,'cash_source':portfolio.CASH_WS,'cash_avail':100000}}
self.commitments={'names':set(),'amount':0,'basis':[]}
self.scan={'open_route_by_verdict':True}
use('app.services.portfolio.positions_view',return_value=self.view)
use('app.services.command_service.effective_stock_params',return_value={})
use('app.services.proposal_service._scan_params',return_value=self.scan)
use('app.services.proposal_service._inflight_open_commitments',return_value=self.commitments)
use('app.services.execution_conflicts.buy_blocker',return_value=None)
use('app.services.market.get_price',return_value=34.18)
use('app.services.market.day_snapshot',return_value={});use('app.services.market.get_ma5',return_value=34)
use('app.services.proposal_service.check_candidate_rules',return_value={'passed':True})
use('app.core.tradedays.window_deadline',return_value='2026-10-23')
use('app.services.proposal_service._scan_open',side_effect=AssertionError('不能重扫'))
use('app.services.proposal_service._route_one',side_effect=AssertionError('不能重问模型'))
use('app.repo.pms_repo.insert_plan_snapshot',side_effect=AssertionError('采纳不能写新消费'))
self.raw={'date':datetime.now().date().isoformat(),'schema_version':2,'decision':{'id':'live'},
'check_code':'SZ300693','found':True,'refresh_status':{'pending_codes':[]},
'main':[{'code':'SZ300693','name':'盛弘股份','score':230,'tier':'强传导','rank':555,
'verdict':'候选','risk':[],'company_review_text':'研究依据'}],'observe':[]}
def response(url,**kw):
self.assertEqual(url,'http://bridge/plan/check');self.assertEqual(kw['params'],{'code':CODE})
return SimpleNamespace(raise_for_status=lambda:None,json=lambda:copy.deepcopy(self.raw))
self.http=use('requests.get',side_effect=response)
selected=pf.candidates(record=False,recheck_code=CODE,plan_override=pf.parse_plan(self.raw),route_by_verdict=True)
h={'price':34.18,'sector':'电气设备','preparation':{'status':'ready'},'proposal_revision':'shown',
'consumption_id':'already-saved','decision':{'id':'prepared'},'plan_basis':pr.basis(selected['items'][0]),
'scan_params_hash':pr.fingerprint(self.scan),'portfolio_context_hash':pr.portfolio_context(self.view,self.commitments),
'prepared_judgement':{'verdict':'UNAVAILABLE','reason':'原人工确认依据','degraded':True}}
self.hard=h;self.pid='PRP_20261021_300693SZ_OPEN'
ddl=[
'CREATE TABLE pms_proposal(id INTEGER PRIMARY KEY,proposal_id TEXT UNIQUE,ts_code TEXT,action TEXT,qty INTEGER,hard_numbers_json TEXT,status TEXT,expire_at TEXT,decided_at TEXT,judge_verdict TEXT,judge_reason TEXT,archived_at TEXT)',
'CREATE TABLE pms_action_ledger(ts_code TEXT,decided_at TEXT,action TEXT,arbiter TEXT,verdict TEXT,price_at REAL,hard_numbers_json TEXT,failed_checks_json TEXT,reason TEXT,ref_id TEXT,outcome_scored INTEGER)',
'CREATE TABLE pms_instruction(id INTEGER PRIMARY KEY,instruction_id TEXT UNIQUE,origin_type TEXT,origin_id TEXT,ts_code TEXT,action TEXT,side TEXT,qty INTEGER,limit_price REAL,window_tdays INTEGER,status TEXT,exec_qty INTEGER,progress_json TEXT,created_at TEXT,updated_at TEXT)']
with self.engine.begin() as c:
for sql in ddl:c.execute(text(sql))
c.execute(text("INSERT INTO pms_proposal(proposal_id,ts_code,action,qty,hard_numbers_json,status,expire_at) VALUES (:pid,:code,'OPEN',500,:hn,'WAIT_USER',:expiry)"),
{'pid':self.pid,'code':CODE,'hn':pms_repo._dumps(h),'expiry':datetime.now()+timedelta(hours=1)})
def decide(self,revision='shown'):
return main.api_decide(None,self.pid,{'decision':'ACCEPTED','reason':'用户确认','proposal_revision':revision})
def counts(self):
with self.engine.connect() as c:return tuple(c.execute(text('SELECT COUNT(*) FROM '+t)).scalar() for t in ('pms_instruction','pms_action_ledger'))
def test_full_api_to_sql_accepts_prepared_quantity_and_pairs_original_source(self):
r=self.decide();self.assertTrue(r['ok'],r);self.assertEqual(self.counts(),(1,1))
with self.engine.connect() as c:
row=dict(c.execute(text('SELECT * FROM pms_instruction')).mappings().one())
self.assertEqual(row['qty'],500);p=json.loads(row['progress_json'])['plan']
self.assertEqual(p['consumption_id'],'already-saved');self.assertEqual(p['decision']['id'],'prepared')
self.assertEqual(p['version_check']['checked_decision']['id'],'live')
def test_transport_timeout_keeps_ready_and_retry_uses_same_confirmation(self):
self.http.side_effect=requests.exceptions.ReadTimeout('3 seconds')
r=self.decide();self.assertFalse(r['ok']);self.assertTrue(r['retryable']);self.assertEqual(self.counts(),(0,0))
self.assertEqual(pms_repo.get_proposal(self.pid)['hard_numbers']['preparation']['status'],'ready')
self.http.side_effect=lambda *a,**k:SimpleNamespace(raise_for_status=lambda:None,json=lambda:self.raw)
self.assertTrue(self.decide()['ok']);self.assertEqual(self.counts(),(1,1))
def test_response_lost_retry_returns_exact_existing_instruction(self):
a=self.decide();self.http.side_effect=AssertionError('重试已采纳不再查来源')
b=self.decide();self.assertTrue(b['already_accepted']);self.assertEqual(a['instruction_id'],b['instruction_id'])
self.assertEqual(self.counts(),(1,1))
def test_two_concurrent_confirmations_create_one_instruction_and_audit(self):
with ThreadPoolExecutor(2) as pool:r=list(pool.map(lambda _:self.decide(),range(2)))
self.assertTrue(all(x['ok'] for x in r),r);self.assertEqual(len({x['instruction_id'] for x in r}),1)
self.assertEqual(self.counts(),(1,1))
def test_instruction_or_audit_failure_rolls_back_confirmation(self):
for table in ('pms_instruction','pms_action_ledger'):
def fail(conn,cursor,statement,params,context,many):
if statement.startswith('INSERT INTO '+table):raise RuntimeError('故障注入')
event.listen(self.engine,'before_cursor_execute',fail)
try:r=self.decide()
finally:event.remove(self.engine,'before_cursor_execute',fail)
self.assertFalse(r['ok']);self.assertEqual(self.counts(),(0,0))
self.assertEqual(pms_repo.get_proposal(self.pid)['status'],'WAIT_USER')
self.assertTrue(self.decide()['ok'])
def test_stale_confirmation_cannot_accept(self):
self.assertFalse(self.decide('old')['ok']);self.assertEqual(self.counts(),(0,0))
def test_unknown_shape_or_wrong_stock_does_not_withdraw_or_accept(self):
for bad in ({},dict(self.raw,check_code='SH600000'),dict(self.raw,found=False),dict(self.raw,main=None)):
self.http.side_effect=lambda *a,**k:SimpleNamespace(raise_for_status=lambda:None,json=lambda:bad)
self.assertFalse(self.decide()['ok']);self.assertEqual(self.counts(),(0,0))
self.assertEqual(pms_repo.get_proposal(self.pid)['status'],'WAIT_USER')
def test_known_research_pending_is_not_transport_retry(self):
self.raw['refresh_status']['pending_codes']=['SZ300693']
r=self.decide();self.assertTrue(r['pending']);self.assertEqual(self.counts(),(0,0))
self.assertEqual(pms_repo.get_proposal(self.pid)['hard_numbers']['preparation']['status'],'pending')
def test_missing_qualification_withdraws(self):
self.raw.update(found=False,main=[])
r=self.decide();self.assertTrue(r['withdrawn'],r);self.assertEqual(self.counts(),(0,1))
self.assertEqual(pms_repo.get_proposal(self.pid)['status'],'EXPIRED')
def test_material_change_waits_for_background_not_accept(self):
self.raw['main'][0]['risk']=['重大风险变化']
self.assertTrue(self.decide()['pending']);self.assertEqual(self.counts(),(0,0))
def test_existing_quantity_still_requires_funds(self):
self.view['totals']['cash_avail']=100
r=self.decide();self.assertFalse(r['ok']);self.assertIn('资金不足',r['error']);self.assertEqual(self.counts(),(0,0))
def test_ready_portfolio_price_changes_keep_confirmation_when_quantity_fits(self):
self.view['totals']['cash_avail']=90000
before=pms_repo.get_proposal(self.pid)
self.assertTrue(rc.refresh_one(before)['ok'])
self.assertEqual(pms_repo.get_proposal(self.pid)['hard_numbers_json'],before['hard_numbers_json'])
self.assertEqual(self.counts(),(0,0))
self.assertTrue(self.decide()['ok'])
def test_source_recovery_restores_original_content_without_scan(self):
p=pms_repo.get_proposal(self.pid);h=dict(p['hard_numbers'],preparation={'status':'pending','stage':'source'})
pms_repo.record_proposal_check(p,h)
self.assertTrue(rc.refresh_one(pms_repo.get_proposal(self.pid))['ok'])
after=pms_repo.get_proposal(self.pid)
self.assertEqual(after['qty'],500);self.assertEqual(after['hard_numbers']['proposal_revision'],'shown')
self.assertTrue(self.decide()['ok']);self.assertEqual(self.counts(),(1,2))
def test_expiry_during_checks_prevents_commit(self):
original=rc.fast_checks
def expire(p,c):
session.execute("UPDATE pms_proposal SET expire_at=:at WHERE proposal_id=:pid",{'at':datetime.now()-timedelta(seconds=1),'pid':self.pid})
return original(p,c)
with patch.object(rc,'fast_checks',side_effect=expire):r=self.decide()
self.assertFalse(r['ok']);self.assertEqual(self.counts(),(0,0))
if __name__=='__main__':unittest.main()