tradingSystem/scripts/test_batch42_units.py

257 lines
16 KiB
Python
Raw Normal View History

"""开发机:python scripts/unit_test_guard.py scripts/test_batch42_units.py。配置隔离且禁网。"""
import unit_test_guard
import os
import sys
import copy
import unittest
from datetime import datetime, timedelta
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import plan_recheck as rc, plan_feed as pf, proposal_service as ps
from app.repo import pms_repo
from app.core import plan_revision as pr
CODE='600183.SH'
def proposal(**kw):
h={'preparation':{'status':'ready'},'proposal_revision':'shown','consumption_id':'saved',
'decision':{'id':'prepared'},'plan_basis':{'risk':[]},'price':10,'sector':'电子','prepared_judgement':{'verdict':'PASS','reason':'原研究','degraded':False}}
p={'proposal_id':'p1','ts_code':CODE,'action':'OPEN','qty':100,'hard_numbers':h,
'hard_numbers_json':pms_repo._dumps(h),'expire_at':datetime.now()+timedelta(hours=1)}
p.update(kw)
return p
def current(**kw):
r={'ok':True,'view':{'held':[]},'params':{},'stock':{},'commitments':{},
'provenance':{'decision':{'id':'new'},'consumption_id':'unwritten','version_check':{'status':'passed'}},
'research_changed':False,'params_changed':False,'portfolio_changed':False}
r.update(kw)
return r
class PreparedProposalTest(unittest.TestCase):
def test_accept_ready_is_model_free_and_does_not_write_consumption(self):
with patch.object(rc,'current_basis',return_value=current()) as basis, \
patch.object(rc,'fast_checks',return_value=None), \
patch.object(ps,'_scan_open',side_effect=AssertionError('采纳不能扫描重算')), \
patch.object(ps,'_route_one',side_effect=AssertionError('采纳不能研判')), \
patch.object(pms_repo,'insert_plan_snapshot',side_effect=AssertionError('采纳不能写消费快照')):
out=rc.before_accept(proposal(),'shown')
self.assertTrue(out['ok'])
self.assertFalse(basis.call_args.kwargs['record'])
self.assertEqual(out['proposal']['hard_numbers']['consumption_id'],'saved')
self.assertEqual(out['proposal']['hard_numbers']['decision']['id'],'prepared')
self.assertEqual(out['proposal']['hard_numbers']['version_check']['checked_decision']['id'],'new')
def test_cash_or_other_position_change_can_accept_existing_quantity(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,material=True,portfolio_changed=True)), \
patch.object(rc,'fast_checks',return_value=None), \
patch.object(ps,'_route_one',side_effect=AssertionError('资金变化不问模型')):
out=rc.before_accept(proposal(),'shown')
self.assertTrue(out['ok'])
self.assertEqual(out['proposal']['qty'],100)
def test_research_change_marks_pending_without_model(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,research_changed=True)), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_route_one',side_effect=AssertionError('只能后台研判')):
out=rc.before_accept(proposal(),'shown')
self.assertTrue(out['pending'])
self.assertEqual(out['proposal']['hard_numbers']['preparation']['status'],'pending')
def test_pending_or_legacy_proposal_cannot_accept(self):
for h in ({},{'preparation':{'status':'pending','reason':'研究更新中'}}):
with patch.object(rc,'current_basis',side_effect=AssertionError('尚未准备不远程取数')):
out=rc.before_accept(proposal(hard_numbers=h),'shown')
self.assertTrue(out['pending'])
def test_expired_proposal_does_not_read_or_accept(self):
with patch.object(rc,'current_basis',side_effect=AssertionError('已过期')):
out=rc.before_accept(proposal(expire_at=datetime.now()-timedelta(seconds=1)),'shown')
self.assertFalse(out['ok'])
def test_stale_content_is_returned_without_recalculation(self):
with patch.object(rc,'current_basis',side_effect=AssertionError('旧页面不远程取数')):
out=rc.before_accept(proposal(),'old')
self.assertFalse(out['ok'])
self.assertEqual(out['proposal']['qty'],100)
def test_source_failure_is_closed_without_discarding_proposal(self):
with patch.object(rc,'current_basis',side_effect=RuntimeError('offline')), \
patch.object(pms_repo,'record_proposal_check',return_value=1):
out=rc.before_accept(proposal(),'shown')
self.assertFalse(out['ok'])
self.assertTrue(out['pending'])
self.assertEqual(out['proposal']['qty'],100)
self.assertEqual(out['proposal']['hard_numbers']['preparation']['status'],'pending')
def test_pause_or_money_block_is_saved_with_specific_reason(self):
with patch.object(rc,'current_basis',return_value=current()), \
patch.object(rc,'fast_checks',return_value='可用资金不足'), \
patch.object(pms_repo,'record_proposal_check',return_value=1):
out=rc.before_accept(proposal(),'shown')
self.assertEqual(out['error'],'可用资金不足')
self.assertTrue(out['pending'])
def test_prepared_research_not_reasked_for_portfolio_or_param_change(self):
old={'plan_basis':{'risk':[]},'portfolio_context_hash':'a','scan_params_hash':'a'}
self.assertFalse(pr.needs_new_judgement(dict(old,portfolio_context_hash='b',scan_params_hash='b'),old))
self.assertTrue(pr.needs_new_judgement(dict(old,plan_basis={'risk':['变化']}),old))
def test_read_current_plan_has_short_timeout_and_no_snapshot(self):
params={'base':'http://fixture','path':'/plan','timeout':30,'stale_tdays':1}
with patch.object(pf,'_params',return_value=params), patch.object(pf,'build_query',return_value={}), \
patch.object(pf,'fetch',return_value={'date':'2026-10-19'}) as fetch, \
patch.object(pf,'assert_fresh') as fresh, \
patch.object(pf,'_snapshot_quiet',side_effect=AssertionError('只读取')):
pf.read_current_plan()
self.assertEqual(fetch.call_args.kwargs['timeout'],3)
fresh.assert_called_once()
def test_new_proposal_not_ready_when_record_failed(self):
with patch.object(rc,'current_basis',return_value={'ok':False,'error':'建议保存未完成','stage':'record'}):
h=rc.prepare_new({'consumption_id':'c'},CODE,{'verdict':'PASS'})
self.assertEqual(h['preparation']['status'],'pending')
self.assertIn('保存',h['preparation']['reason'])
def test_new_proposal_ready_only_with_recorded_consumption(self):
with patch.object(rc,'current_basis',return_value=current()):
self.assertEqual(rc.prepare_new({},CODE,{'verdict':'PASS'})['preparation']['status'],'pending')
self.assertEqual(rc.prepare_new({'consumption_id':'c'},CODE,{'verdict':'PASS'})['preparation']['status'],'ready')
def _background(self, changed, *, route=None):
p=proposal()
c={'ts_code':CODE,'action':'OPEN','qty':100,'hard_numbers':{'consumption_id':'c','price':10,'plan_basis':{'risk':[]}}}
with patch.object(rc,'current_basis',side_effect=[current(ok=False,material=True,**changed),current()]), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_scan_open',return_value=[c]), \
patch.object(ps,'_route_one',return_value={'verdict':{'verdict':'PASS','reason':'原研究'}}) as routed, \
patch.object(pms_repo,'publish_prepared_proposal',return_value=1), \
patch.object(rc,'fast_checks',return_value='原数量不再满足资金条件'), patch.object(ps.param_store,'get_int',return_value=0):
out=rc.refresh_one(p)
return out,routed
def test_background_portfolio_change_reuses_judgement(self):
out,route=self._background({'portfolio_changed':True})
self.assertTrue(out['ok'])
self.assertEqual(route.call_args.kwargs['prepared_verdict']['reason'],'原研究')
self.assertFalse(route.call_args.args[0]['_plan_recheck'])
self.assertEqual(out['proposal']['hard_numbers']['proposal_revision'],'shown')
def test_background_research_change_uses_original_judge_pipeline(self):
out,route=self._background({'research_changed':True})
self.assertTrue(out['ok'])
self.assertIsNone(route.call_args.kwargs['prepared_verdict'])
self.assertTrue(route.call_args.args[0]['_plan_recheck'])
self.assertNotEqual(out['proposal']['hard_numbers']['proposal_revision'],'shown')
def test_active_update_lease_prevents_duplicate_judgement(self):
p=proposal();p['hard_numbers']['preparation']['lease_until']=(datetime.now().astimezone()+timedelta(seconds=100)).isoformat()
with patch.object(rc,'current_basis',side_effect=AssertionError('租约内不能重评')):
self.assertFalse(rc.refresh_one(p)['ok'])
def test_budget_exhausted_returns_to_pending_without_model(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,research_changed=True)), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_judge_budget_left',return_value=False), \
patch.object(ps,'_scan_open',side_effect=AssertionError('预算不足不扫描')):
out=rc.refresh_one(proposal(),deadline=0)
self.assertTrue(out['pending'])
self.assertNotIn('lease_until',out['proposal']['hard_numbers']['preparation'])
def test_background_cas_loser_never_calls_model(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,research_changed=True)), \
patch.object(pms_repo,'record_proposal_check',return_value=0), \
patch.object(ps,'_scan_open',side_effect=AssertionError('失去所有权')):
self.assertFalse(rc.refresh_one(proposal())['ok'])
def test_sell_and_strategy_proposals_keep_existing_path(self):
with patch.object(rc,'current_basis',side_effect=AssertionError('无选股重评')):
self.assertTrue(rc.before_accept(proposal(action='EXIT'),None)['ok'])
self.assertTrue(rc.before_accept(proposal(hard_numbers={'kind':'strategy'}),None)['ok'])
def test_publication_and_audit_rollback_and_concurrent_accept(self):
from sqlalchemy import create_engine,text
from app.db import session
engine=create_engine('sqlite:///:memory:');self.addCleanup(engine.dispose)
p=proposal();h=rc.ready_hard({'consumption_id':'c','price':10},{'verdict':'PASS'})
c={'qty':200,'hard_numbers':h};v={'verdict':'PASS'}
with engine.begin() as db:
db.execute(text('CREATE TABLE pms_proposal(proposal_id TEXT,status TEXT,qty INTEGER,hard_numbers_json TEXT,judge_verdict TEXT,judge_reason TEXT,decided_at TEXT)'))
db.execute(text("INSERT INTO pms_proposal VALUES ('p1','WAIT_USER',100,:old,NULL,NULL,NULL)"),{'old':p['hard_numbers_json']})
with patch.object(session,'get_engine',return_value=engine),patch.object(session,'_check_cooldown'):
with self.assertRaises(Exception):pms_repo.publish_prepared_proposal(p,c,v)
with engine.begin() as db:
self.assertEqual(db.execute(text('SELECT qty FROM pms_proposal')).scalar(),100)
db.execute(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)'))
self.assertEqual(pms_repo.publish_prepared_proposal(p,c,v),1)
self.assertEqual(pms_repo.publish_prepared_proposal(p,c,v),0)
self.assertEqual(pms_repo.decide_proposal('p1','ACCEPTED',expected_hard=p['hard_numbers_json']),0)
with engine.begin() as db:
self.assertEqual(db.execute(text('SELECT COUNT(*) FROM pms_action_ledger')).scalar(),1)
def test_quantity_changed_requires_new_confirmation_identity(self):
h={'plan_basis':{'risk':[]}};v={'verdict':'PASS'}
self.assertNotEqual(pr.confirmation_revision(100,h,v),pr.confirmation_revision(200,h,v))
self.assertEqual(pr.confirmation_revision(100,h,v),pr.confirmation_revision(100,dict(h,decision={'id':'v2'},research_rank=99),v))
def test_real_fast_checks_capacity_and_funds_without_changing_quantity(self):
from app.services import market,portfolio,param_store,execution_conflicts
p=proposal()
view={'held':[],'params':{'max_names':5,'portfolio_cap':0.7,'scale':10000},
'totals':{'names_count':1,'portfolio_mv':1000,'cash_source':portfolio.CASH_WS,'cash_avail':2000}}
check=current(view=view,commitments={'names':set(),'amount':0})
with patch.object(execution_conflicts,'buy_blocker',return_value=None), \
patch.object(param_store,'get',return_value='propose_only'), \
patch.object(param_store,'get_int',return_value=0), \
patch.object(param_store,'get_bool',return_value=True), \
patch.object(market,'get_price',return_value=10), \
patch.object(market,'day_snapshot',return_value={}), \
patch.object(market,'get_ma5',return_value=10), \
patch.object(ps,'check_candidate_rules',return_value={'passed':True}) as gate:
self.assertIsNone(rc.fast_checks(p,check))
view['totals']['cash_avail']=900
self.assertIn('资金不足',rc.fast_checks(p,check))
view['totals']['cash_avail']=2000
check['commitments']['names']={'a','b','c','d'}
self.assertIn('名额不足',rc.fast_checks(p,check))
self.assertEqual(p['qty'],100)
gate.assert_called_once()
def test_route_reuses_prepared_verdict_without_judge(self):
from app.services import judge,param_store
c={'ts_code':CODE,'action':'OPEN','side':'buy','qty':100,'price':10,'reason':'原依据','hard_numbers':{}}
with patch.object(ps,'check_candidate_rules',return_value={'passed':True}), \
patch.object(ps,'_pos_of',return_value={}), \
patch.object(ps,'_self_decide',return_value={}), \
patch.object(ps,'_apply_advice_tier'), \
patch.object(judge,'request',side_effect=AssertionError('不许重复调用模型')), \
patch.object(param_store,'get_bool',return_value=False):
out=ps._route_one(c,{}, {},{},False,datetime.now(),True,{'autonomy':'propose_only'},
review_only=True,prepared_verdict={'verdict':'PASS','reason':'原结论'})
self.assertEqual(out['verdict']['reason'],'原结论')
def test_background_failure_stays_pending_and_does_not_publish(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,portfolio_changed=True)), \
patch.object(rc,'fast_checks',return_value='资金不足'), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_scan_open',return_value=[]), \
patch.object(pms_repo,'publish_prepared_proposal',side_effect=AssertionError('无结果不能发布')):
out=rc.refresh_one(proposal())
self.assertFalse(out['ok'])
self.assertEqual(out['proposal']['hard_numbers']['preparation']['status'],'pending')
def test_prepare_final_source_failure_cannot_publish(self):
c={'ts_code':CODE,'qty':100,'hard_numbers':{'consumption_id':'c'}}
with patch.object(rc,'current_basis',side_effect=[current(ok=False,research_changed=True),{'ok':False,'error':'研究更新中'}]), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_scan_open',return_value=[c]), \
patch.object(ps,'_route_one',return_value={'verdict':{'verdict':'PASS'}}), \
patch.object(ps.param_store,'get_int',return_value=0), \
patch.object(pms_repo,'publish_prepared_proposal',side_effect=AssertionError('旧研究不能发布')):
out=rc.refresh_one(proposal())
self.assertTrue(out['pending'])
if __name__=='__main__':unittest.main()