146 lines
11 KiB
Python
146 lines
11 KiB
Python
|
|
"""开发机: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_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()
|