tradingSystem/scripts/test_batch43_units.py

153 lines
12 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.

"""开发机: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()