tradingSystem/scripts/test_batch44_units.py

217 lines
16 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_batch44_units.py。高股息来源,隔离禁网。"""
import unit_test_guard
import os,sys,copy,json
from datetime import datetime,timedelta
from types import SimpleNamespace
from unittest.mock import patch
from concurrent.futures import ThreadPoolExecutor
import unittest
sys.path.insert(0,os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
import test_batch43_units as base43
from app.services import dividend_feed as df,plan_recheck as rc,proposal_service as ps,portfolio,command_service,param_store
from app.core import plan_revision as pr,action_engine as ae,tradedays as td
from app.repo import pms_repo
from app.web import main,auth
CODE='300693.SZ'
REF='a'*64
def source():
now=datetime.now().astimezone()
decision={'id':'dividend_'+'b'*64,'source':'dividend','policy_version':'2026.10-1','cutoff_at':now.isoformat()}
c={'ts_code':CODE,'name':'合成公司','candidate_source':'dividend','src':'dividend','bucket':'dividend','verdict':'候选',
'decision':decision,'company_review':{'overall':'好','age_days':1},'company_review_text':'合成研究依据',
'review_ref':{'ref':'original-report'},'dividend_research':{'ref':REF,'conclusion':'有支持'},'rank':1,'selection_rank':1,
'risk':['需求可能下降'],'reasons':['高股息研究核验通过']}
return {'schema_version':1,'source':'dividend','data_date':str(td.prev_trade_day(now.date())),
'policy_version':'2026.10-1','checked_at':now.isoformat(),'decision':decision,'eligible_count':1,
'rows':[{'ts_code':CODE,'name':'合成公司','eligible':True,'reasons':[],'research_ref':REF,'candidate':c}]}
class DividendPathTest(unittest.TestCase):
decide=base43.AcceptancePathTest.decide
counts=base43.AcceptancePathTest.counts
def setUp(self):
base43.AcceptancePathTest.setUp(self)
self.data=source()
self.patch('app.services.param_store.get',side_effect=lambda k,d=None:'http://bridge' if k=='PMS_PLAN_API_BASE' else d)
self.http.side_effect=lambda url,**kw:self.response(url,kw)
self.patch('app.core.tradedays.calendar_degraded',return_value=False)
self.patch('app.core.tradedays.is_trade_day',return_value=True)
# Existing switch remains off: new source cannot bypass preparation/acceptance checks.
self.patch('app.services.param_store.get_bool',side_effect=lambda k,d=False:False if k=='PMS_PLAN_VERSION_RECHECK' else d)
c=df.candidates(record=False,recheck_code=CODE)['items'][0]
self.hard.update(candidate_source='dividend',dividend_ref=REF,dividend_policy_version='2026.10-1',plan_basis=c['plan_basis'])
self.save(self.hard)
def patch(self,target,**kw):
p=patch(target,**kw);r=p.start();self.addCleanup(p.stop);return r
def response(self,url,kw):
self.assertEqual(url,'http://bridge/research/dividend/candidates')
self.assertEqual(kw['timeout'],(1,3))
return SimpleNamespace(raise_for_status=lambda:None,json=lambda:copy.deepcopy(self.data))
def save(self,hard):
with self.engine.begin() as c:c.execute(text('UPDATE pms_proposal SET hard_numbers_json=:h WHERE proposal_id=:p'),{'h':pms_repo._dumps(hard),'p':self.pid})
def test_ready_accepts_without_research_and_retains_source(self):
self.assertTrue(self.decide()['ok']);self.assertEqual(self.counts(),(1,1))
with self.engine.connect() as c: row=c.execute(text('SELECT progress_json FROM pms_instruction')).scalar()
self.assertEqual(json.loads(row)['plan']['candidate_source'],'dividend')
self.assertEqual(json.loads(row)['plan']['dividend_ref'],REF)
def test_only_rank_and_age_change_do_not_reask(self):
self.data['rows'][0]['candidate'].update(rank=900,selection_rank=900)
self.data['rows'][0]['candidate']['company_review']['age_days']=9
self.assertTrue(self.decide()['ok'])
def test_company_research_change_closes_confirmation(self):
self.data['rows'][0]['research_ref']='c'*64
self.data['rows'][0]['candidate']['dividend_research']['ref']='c'*64
r=self.decide();self.assertTrue(r['pending']);self.assertEqual(self.counts(),(0,0))
def test_loss_of_eligibility_withdraws(self):
self.data['rows'][0].update(eligible=False,reasons=['资料不完整'])
self.data['rows'][0]['candidate']['verdict']='仅展示'
r=self.decide();self.assertTrue(r['withdrawn']);self.assertEqual(self.counts()[0],0)
self.assertEqual(pms_repo.get_proposal(self.pid)['status'],'EXPIRED')
def test_other_source_open_commitment_withdraws(self):
self.commitments['names'].add(CODE)
self.assertTrue(self.decide()['withdrawn']);self.assertEqual(self.counts()[0],0)
def test_unavailable_keeps_existing_prepared_suggestion(self):
import requests
self.http.side_effect=requests.Timeout('network')
self.assertTrue(self.decide()['retryable']);self.assertEqual(self.counts(),(0,0))
self.assertEqual(pms_repo.get_proposal(self.pid)['hard_numbers']['preparation']['status'],'ready')
def test_pending_not_accepted_when_legacy_switch_off(self):
self.hard['preparation']={'status':'pending','reason':'等待准备'};self.save(self.hard)
self.assertTrue(self.decide()['pending']);self.assertEqual(self.counts(),(0,0))
self.patch('app.services.tech_service.attach')
r=main.api_proposals(status='WAIT_USER',limit=100,include_archived=False)
self.assertFalse(r['data'][0]['accept_ready'])
def test_concurrent_accepts_use_one_transaction(self):
with ThreadPoolExecutor(2) as pool: rows=list(pool.map(lambda _:self.decide(),range(2)))
self.assertTrue(all(r['ok'] for r in rows),rows);self.assertEqual(self.counts(),(1,1))
def test_bad_contract_future_date_calendar_or_reference_refuses(self):
for change in (lambda d:d.update(data_date='2000-01-01'),lambda d:d['decision'].update(cutoff_at='2099-01-01T00:00:00+08:00'),lambda d:d['rows'][0]['candidate'].update(decision={}),lambda d:d['rows'][0]['candidate']['dividend_research'].update(ref='bad')):
self.data=source();change(self.data)
with self.assertRaises(df.PlanFeedError):df.read(code=CODE)
self.data=source()
with patch.object(td,'calendar_degraded',return_value=True):
with self.assertRaises(df.PlanFeedError):df.read(code=CODE)
def test_readonly_is_not_consumption_and_failed_record_blocks(self):
d=df.candidates(record=False,recheck_code=CODE);self.assertNotIn('error',d['consumption'])
with patch.object(pms_repo,'insert_plan_snapshot',side_effect=RuntimeError('save failed')):
d=df.candidates(record=True,recheck_code=CODE)
self.assertEqual(d['items'][0]['consumption_error'],'RuntimeError')
def test_snapshot_uses_source_and_compatible_consumption_kind(self):
with patch.object(pms_repo,'insert_plan_snapshot',return_value=1) as write:
d=df.candidates(record=True,recheck_code=CODE)
kw=write.call_args.kwargs;self.assertEqual(kw['meta']['source'],'dividend');self.assertEqual(kw['meta']['kind'],'rechecked')
self.assertEqual(kw['meta']['order'],[CODE]);self.assertEqual(kw['roster'][0]['plan_basis'],d['items'][0]['plan_basis'])
def test_first_background_prepare_dispatches_original_source(self):
h=copy.deepcopy(self.hard);h.update(preparation={'status':'pending','stage':'requested'},plan_basis=None)
self.save(h)
current=df.candidates(record=False,recheck_code=CODE)['items'][0]
fresh={'ts_code':CODE,'action':'OPEN','side':'buy','qty':500,'reason':'高股息建仓','price':34.18,
'hard_numbers':dict(self.hard,plan_basis=current['plan_basis'],consumption_id=current['consumption_id'])}
with patch.object(ps,'_scan_open',return_value=[fresh]) as scan, patch.object(ps,'_route_one',return_value={'verdict':{'verdict':'PASS','reason':'合成核验','confidence':0.8}}) as route, patch.object(pms_repo,'insert_plan_snapshot',return_value=1):
r=rc.refresh_one(pms_repo.get_proposal(self.pid))
self.assertTrue(r['ok'],r);self.assertEqual(scan.call_args.kwargs['candidate_source'],'dividend')
self.assertTrue(route.call_args.kwargs['review_only']);self.assertEqual(self.counts()[0],0)
self.assertEqual(pms_repo.get_proposal(self.pid)['hard_numbers']['preparation']['status'],'ready')
def test_legacy_off_still_prepares_only_dividend(self):
p=pms_repo.get_proposal(self.pid);other=copy.deepcopy(p);other['hard_numbers'].pop('candidate_source');other['proposal_id']='old'
with patch.object(pms_repo,'list_proposals',return_value=[p,other]),patch.object(rc,'refresh_one',return_value={'ok':True}) as call:
result=rc.refresh_pending()
self.assertEqual(len(result),1);self.assertEqual(call.call_args.args[0]['proposal_id'],self.pid)
class RequestTest(unittest.TestCase):
def setUp(self):
self.data=source();self.rows={}
self.mock('app.services.dividend_feed.read',side_effect=lambda **kw:copy.deepcopy(self.data))
self.mock('app.core.tradedays.is_trade_day',return_value=True)
self.mock('app.core.tradedays.calendar_degraded',return_value=False)
fixed=datetime.now().replace(hour=10,minute=0)
d=self.mock('app.services.dividend_feed.datetime');d.now.return_value=fixed
self.mock('app.services.param_store.get',side_effect=lambda k,d=None:d)
self.mock('app.services.param_store.get_bool',side_effect=lambda k,d=False:d)
self.mock('app.repo.pms_repo.get_proposal',side_effect=lambda pid:self.rows.get(pid))
self.insert=self.mock('app.repo.pms_repo.insert_proposal',side_effect=self.add)
self.mock('app.services.command_service.effective_stock_params',return_value={})
self.mock('app.services.portfolio.positions_view',return_value={'held':[]})
self.mock('app.services.execution_conflicts.buy_blocker',return_value=None)
self.mock('app.services.proposal_service._inflight_open_commitments',return_value={'names':set(),'amount':0,'basis':[]})
self.mock('app.services.proposal_service.proposal_expire_at',return_value=fixed.replace(hour=15))
self.mock('app.repo.pms_repo.insert_instruction',side_effect=AssertionError('请求不得生成指令'))
self.mock('app.services.proposal_service._route_one',side_effect=AssertionError('请求不得调用研判'))
def mock(self,key,**kw):
p=patch(key,**kw);v=p.start();self.addCleanup(p.stop);return v
def add(self,**kw):
if kw['proposal_id'] in self.rows:raise RuntimeError('duplicate')
self.rows[kw['proposal_id']]=dict(kw,status='WAIT_USER');return 1
def request(self):return df.request_preparation(CODE,REF,actor='test')
def test_request_has_zero_quantity_and_no_approval(self):
r=self.request();p=self.rows[r['proposal_id']]
self.assertEqual(p['qty'],0);self.assertEqual(p['hard_numbers']['preparation']['stage'],'requested')
self.assertEqual(p['hard_numbers']['requested_by'],'test')
def test_repeated_request_returns_existing_without_overwrite(self):
a=self.request();b=self.request();self.assertEqual(a['proposal_id'],b['proposal_id']);self.assertEqual(self.insert.call_count,1)
def test_terminal_proposal_not_revived(self):
p=self.request();self.rows[p['proposal_id']]['status']='DECLINED'
with self.assertRaises(ValueError):self.request()
def test_missing_support_or_changed_reference_does_not_queue(self):
self.data['rows'][0]['eligible']=False
with self.assertRaises(ValueError):self.request()
self.data['rows'][0]['eligible']=True
with self.assertRaises(ValueError):df.request_preparation(CODE,'old',actor='test')
self.assertEqual(self.insert.call_count,0)
def test_held_black_selling_inflight_or_failure_block(self):
cases=[('app.services.portfolio.positions_view',{'held':[{'ts_code':CODE}]}),
('app.services.command_service.effective_stock_params',{CODE:{'black':True}}),
('app.services.execution_conflicts.buy_blocker','正在清仓'),
('app.services.proposal_service._inflight_open_commitments',{'names':{CODE}})]
for target,value in cases:
with patch(target,return_value=value):
with self.assertRaises(ValueError):self.request()
with patch.object(ps,'_inflight_open_commitments',side_effect=RuntimeError('db failure')):
with self.assertRaises(RuntimeError):self.request()
self.assertEqual(self.insert.call_count,0)
def test_duplicate_insert_race_reuses_winner(self):
def raced(**kw):self.add(**kw);raise RuntimeError('duplicate')
self.insert.side_effect=raced
r=self.request();self.assertTrue(r['already_requested']);self.assertEqual(len(self.rows),1)
def test_api_authorization_requires_login_not_admin(self):
self.assertFalse(auth.is_public_path('/api/dividend/proposals'))
self.assertEqual(auth.authz_decision('POST','/api/dividend/proposals'),'any')
def test_original_basis_unchanged(self):
r={'score':1,'decision':{'id':'x'}}
self.assertNotIn('candidate_source',pr.basis(r));self.assertNotIn('dividend_ref',pr.basis(r))
class AssemblyTest(unittest.TestCase):
def test_real_scan_uses_existing_sizer_and_preserves_dividend_basis(self):
from contextlib import ExitStack
from test_batch20_units import params,caps
data=source()
view={'held':[],'params':{'max_names':20,'scale':2000000,'portfolio_cap':0.7},
'totals':{'names_count':0,'portfolio_mv':0,'cash_source':portfolio.CASH_WS,'cash_avail':500000}}
p=params(consensus_route=False,plan_by_advice=False)
settings=[(df,'read',data),(ps,'_macro_gate',{}),(param_store,'get','plan_api'),
(portfolio,'caps_ctx',caps()),(ps,'_inflight_open_commitments',{'names':set(),'amount':0,'basis':[]}),
(ps,'_buy_signals_today',{}),(ps,'_scan_params',p),(ps.market,'get_prices',{CODE:10}),
(ps.market,'get_ma5',10),(ps.market,'day_snapshot',{}),(ps.industry,'get_many',{CODE:'电子'}),
(pms_repo,'insert_plan_snapshot',1)]
with ExitStack() as stack:
for module,name,value in settings:stack.enter_context(patch.object(module,name,return_value=value))
out={'skipped':[]};rows=ps._scan_open(view,p,{}, {},{},out,review_code=CODE,exclude_proposal='own',candidate_source='dividend')
self.assertEqual(len(rows),1,out);c=rows[0]
expected,_=ae.eval_open(dict(data['rows'][0]['candidate'],price=10,sector='电子'),p,caps(),500000)
self.assertEqual(c['qty'],expected['qty']);self.assertEqual(c['hard_numbers']['dividend_ref'],REF)
self.assertEqual(c['hard_numbers']['plan_basis']['candidate_source'],'dividend');self.assertIn('高股息建仓',c['reason'])
def test_strict_inflight_read_cannot_hide_db_failure(self):
with patch.object(pms_repo,'list_instructions',side_effect=RuntimeError('db')):
with self.assertRaises(RuntimeError):ps._inflight_open_commitments({'held':[]},strict=True)
def test_normal_source_duplicate_does_not_overwrite_new_request(self):
c={'ts_code':CODE,'action':'OPEN','qty':500,'reason':'normal','hard_numbers':{}}
with patch.object(param_store,'get_int',return_value=24),patch.object(param_store,'get_bool',return_value=False),patch.object(ps,'_apply_advice_tier'),patch.object(pms_repo,'insert_proposal',side_effect=RuntimeError('duplicate')) as put,patch.object(pms_repo,'get_proposal',return_value={'status':'WAIT_USER'}):
pid=ps._make_proposal(c,10,{'verdict':'PASS'})
self.assertEqual(put.call_count,1);self.assertTrue(pid.endswith('_OPEN'))
if __name__=='__main__':unittest.main()