217 lines
16 KiB
Python
217 lines
16 KiB
Python
|
|
"""开发机: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()
|
|||
|
|
|