2026-10-15 10:27:12 +08:00
|
|
|
|
"""本地:python scripts/unit_test_guard.py scripts/test_batch38_units.py。禁止联网。"""
|
|
|
|
|
|
import unit_test_guard # noqa: F401
|
|
|
|
|
|
import copy
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import unittest
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
from app.services import plan_feed as pf
|
|
|
|
|
|
from app.core import plan_revision as pr
|
|
|
|
|
|
from app.core import action_engine as ae
|
|
|
|
|
|
from test_batch20_units import params, caps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def selected():
|
|
|
|
|
|
plan = pf.parse_plan({'date': '2026-10-14', 'rank_axis': 'quality', 'decision': {'id': 'v1'},
|
|
|
|
|
|
'main': [{'code': 'SH600001', 'name': '甲', 'rank': 1, 'score': 210, 'verdict': '候选'},
|
|
|
|
|
|
{'code': 'SH600002', 'name': '乙', 'rank': 2, 'score': 250, 'verdict': '候选'}]})
|
|
|
|
|
|
return pf.select_candidates(plan, rank_by_upstream=True, theme_cap=1)['items']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DailyPlanTest(unittest.TestCase):
|
|
|
|
|
|
def test_judge_cache_requires_same_material_basis(self):
|
|
|
|
|
|
old = {'plan_basis': {'risk': []}, 'scan_params_hash': 'p', 'portfolio_context_hash': 'c'}
|
|
|
|
|
|
ranked = dict(old, decision={'id': 'new'}, research_rank=99)
|
|
|
|
|
|
self.assertFalse(pr.needs_new_judgement(ranked, old))
|
|
|
|
|
|
self.assertTrue(pr.needs_new_judgement(dict(old, plan_basis={'risk': ['变化']}), old))
|
|
|
|
|
|
self.assertTrue(pr.needs_new_judgement(old, None))
|
|
|
|
|
|
|
|
|
|
|
|
def test_portfolio_quantity_basis_detects_new_commitments(self):
|
|
|
|
|
|
view = {'held': [{'ts_code': '600001.SH', 'total_qty': 100, 'sector': '电子', 'price': 10}],
|
|
|
|
|
|
'totals': {'cash_source': 'ws', 'cash_avail': 10000}, 'sector_ready': True}
|
|
|
|
|
|
commitments = {'names': [], 'amount': 0}
|
|
|
|
|
|
original = pr.portfolio_context(view, commitments)
|
|
|
|
|
|
view['held'][0]['price'] = 11
|
|
|
|
|
|
self.assertEqual(original, pr.portfolio_context(view, commitments))
|
|
|
|
|
|
commitments.update(names=['600002.SH'], amount=1000)
|
|
|
|
|
|
self.assertNotEqual(original, pr.portfolio_context(view, commitments))
|
|
|
|
|
|
|
2026-10-16 10:41:27 +08:00
|
|
|
|
def test_commitment_quotes_do_not_change_quantity_basis(self):
|
|
|
|
|
|
view = {'held': [], 'totals': {'cash_source': 'ws', 'cash_avail': 10000}}
|
|
|
|
|
|
old = {'names': {'600002.SH'}, 'amount': 1000,
|
|
|
|
|
|
'basis': [('instruction', 'i1', '600002.SH', 100, 0)]}
|
|
|
|
|
|
changed = dict(old, amount=1100)
|
|
|
|
|
|
self.assertEqual(pr.portfolio_context(view, old), pr.portfolio_context(view, changed))
|
|
|
|
|
|
for basis in [[('instruction', 'i2', '600002.SH', 100, 0)],
|
|
|
|
|
|
[('instruction', 'i1', '600002.SH', 200, 0)],
|
|
|
|
|
|
[('instruction', 'i1', '600002.SH', 100, 50)], []]:
|
|
|
|
|
|
self.assertNotEqual(pr.portfolio_context(view, old),
|
|
|
|
|
|
pr.portfolio_context(view, dict(old, basis=basis)))
|
|
|
|
|
|
double = dict(old, basis=old['basis'] + [('proposal', 'p1', '600003.SH', 100, 0)])
|
|
|
|
|
|
self.assertEqual(pr.portfolio_context(view, double),
|
|
|
|
|
|
pr.portfolio_context(view, dict(double, basis=list(reversed(double['basis'])))))
|
|
|
|
|
|
self.assertNotEqual(pr.portfolio_context(view, old), pr.portfolio_context(view, old, {'black': True}))
|
|
|
|
|
|
changed_view = copy.deepcopy(view)
|
|
|
|
|
|
changed_view['totals']['cash_avail'] = 9000
|
|
|
|
|
|
self.assertNotEqual(pr.portfolio_context(view, old), pr.portfolio_context(changed_view, old))
|
|
|
|
|
|
legacy = {k: v for k, v in old.items() if k != 'basis'}
|
|
|
|
|
|
self.assertNotEqual(pr.portfolio_context(view, legacy),
|
|
|
|
|
|
pr.portfolio_context(view, dict(legacy, amount=1100)))
|
|
|
|
|
|
|
|
|
|
|
|
def test_inflight_basis_is_stable_and_preserves_amount_rules(self):
|
|
|
|
|
|
from app.services import proposal_service as ps
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
ins = [{'instruction_id': 'i1', 'action': 'OPEN', 'ts_code': '600002.SH', 'qty': 100, 'exec_qty': 0},
|
|
|
|
|
|
{'instruction_id': 'held', 'action': 'OPEN', 'ts_code': '600003.SH', 'qty': 100}]
|
|
|
|
|
|
proposals = [{'proposal_id': 'own', 'action': 'OPEN', 'ts_code': '600001.SH', 'qty': 100},
|
|
|
|
|
|
{'proposal_id': 'p2', 'action': 'OPEN', 'ts_code': '600002.SH', 'qty': 200,
|
|
|
|
|
|
'hard_numbers': {'price': 9}}]
|
|
|
|
|
|
view = {'held': [{'ts_code': '600003.SH'}]}
|
|
|
|
|
|
with patch.object(pms_repo, 'list_instructions', return_value=ins), \
|
|
|
|
|
|
patch.object(pms_repo, 'list_proposals', return_value=proposals), \
|
|
|
|
|
|
patch.object(ps.market, 'get_prices', side_effect=[{'600002.SH': 10}, {'600002.SH': 11}, {}]):
|
|
|
|
|
|
old = ps._inflight_open_commitments(view, exclude_proposal='own')
|
|
|
|
|
|
new = ps._inflight_open_commitments(view, exclude_proposal='own')
|
|
|
|
|
|
missing_price = ps._inflight_open_commitments(view, exclude_proposal='own')
|
|
|
|
|
|
self.assertEqual(old['names'], {'600002.SH'})
|
|
|
|
|
|
self.assertEqual(old['amount'], 1000)
|
|
|
|
|
|
self.assertEqual(new['amount'], 1100)
|
|
|
|
|
|
self.assertEqual(missing_price['amount'], 0) # 原首条指令优先去重口径保持。
|
|
|
|
|
|
self.assertEqual(old['basis'], new['basis'])
|
|
|
|
|
|
self.assertEqual(old['basis'], [('instruction', 'i1', '600002.SH', 100, 0),
|
|
|
|
|
|
('proposal', 'p2', '600002.SH', 200, 0)])
|
|
|
|
|
|
with patch.object(pms_repo, 'list_instructions', return_value=[]), \
|
|
|
|
|
|
patch.object(pms_repo, 'list_proposals', return_value=[]), \
|
|
|
|
|
|
patch.object(ps.market, 'get_prices', side_effect=AssertionError('无在途不读行情')):
|
|
|
|
|
|
self.assertEqual(ps._inflight_open_commitments(view)['basis'], [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_quote_only_recheck_keeps_confirmation_without_model(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc, proposal_service as ps, portfolio, command_service
|
|
|
|
|
|
row = dict(selected()[0], consumption_id='test-consumption')
|
|
|
|
|
|
view = {'held': [], 'totals': {'cash_source': 'ws', 'cash_avail': 10000}}
|
|
|
|
|
|
old = {'names': {'600002.SH'}, 'amount': 1000,
|
|
|
|
|
|
'basis': [('instruction', 'i1', '600002.SH', 100, 0)]}
|
|
|
|
|
|
hard = {'plan_basis': pr.basis(row), 'scan_params_hash': pr.fingerprint({}),
|
2026-10-20 11:31:02 +08:00
|
|
|
|
'portfolio_context_hash': pr.portfolio_context(view, old), 'proposal_revision': 'same', 'consumption_id': 'saved', 'preparation': {'status': 'ready'}}
|
2026-10-16 10:41:27 +08:00
|
|
|
|
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': row['ts_code'], 'hard_numbers': hard}
|
2026-10-20 11:31:02 +08:00
|
|
|
|
with patch.object(rc, 'fast_checks', return_value=None), patch.object(pf, 'read_current_plan', return_value={}), \
|
|
|
|
|
|
patch.object(portfolio, 'positions_view', return_value=view), \
|
2026-10-16 10:41:27 +08:00
|
|
|
|
patch.object(command_service, 'effective_stock_params', return_value={}), \
|
|
|
|
|
|
patch.object(ps, '_scan_params', return_value={}), \
|
|
|
|
|
|
patch.object(pf, 'candidates', return_value={'items': [row], 'decision': row['decision']}), \
|
|
|
|
|
|
patch.object(ps, '_inflight_open_commitments', return_value=dict(old, amount=1100)), \
|
|
|
|
|
|
patch.object(ps, '_scan_open', side_effect=AssertionError('报价变化不得重算')) as scan, \
|
|
|
|
|
|
patch.object(ps, '_route_one', side_effect=AssertionError('报价变化不得调用研判')) as route:
|
|
|
|
|
|
out = rc.before_accept(p, 'same')
|
|
|
|
|
|
self.assertTrue(out['ok'], out)
|
|
|
|
|
|
self.assertEqual(out['proposal']['hard_numbers']['version_check']['status'], 'passed')
|
|
|
|
|
|
scan.assert_not_called()
|
|
|
|
|
|
route.assert_not_called()
|
|
|
|
|
|
|
2026-10-15 10:27:12 +08:00
|
|
|
|
def test_recheck_ignores_rank_cutoffs_but_keeps_qualification(self):
|
|
|
|
|
|
raw = {'date': '2026-10-14', 'rank_axis': 'quality', 'decision': {'id': 'v2'},
|
|
|
|
|
|
'main': [{'code': 'SH600001', 'rank': 1, 'score': 230, 'tier': '强传导', 'evidence': {'theme': '电子'}},
|
|
|
|
|
|
{'code': 'SH600002', 'rank': 2, 'score': 210, 'tier': '强传导', 'evidence': {'theme': '电子'}}]}
|
|
|
|
|
|
p = dict(top_n=1, tiers=['强传导'], include_observe=False, min_score=0,
|
|
|
|
|
|
min_sources=0, min_upside=0, theme_cap_local=1, exclude_st=False,
|
|
|
|
|
|
rank_by_quality=True, rank_by_upstream=True, observe_if_fund_bull=False)
|
|
|
|
|
|
with patch.object(pf, '_params', return_value=p), patch.object(pf, 'get_plan', return_value=pf.parse_plan(raw)):
|
|
|
|
|
|
self.assertEqual([x['ts_code'] for x in pf.candidates(record=False)['items']], ['600001.SH'])
|
|
|
|
|
|
fresh = pf.candidates(record=False, recheck_code='600002.SH')
|
|
|
|
|
|
self.assertEqual([x['ts_code'] for x in fresh['items']], ['600002.SH'])
|
|
|
|
|
|
self.assertEqual(fresh['consumption']['kind'], 'rechecked')
|
|
|
|
|
|
self.assertEqual(pf.candidates(record=False, recheck_code='600002.SH', black=['600002.SH'])['items'], [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_snapshot_retention_never_deletes_by_row_count(self):
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
with patch.object(pms_repo, 'execute', side_effect=AssertionError('不得按行数删除历史')):
|
|
|
|
|
|
self.assertEqual(pms_repo.prune_plan_snapshots(2), 0)
|
|
|
|
|
|
|
|
|
|
|
|
def test_material_change_replaces_qty_and_never_accepts_old_confirmation(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc, proposal_service as ps, param_store
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': '600001.SH', 'qty': 300,
|
|
|
|
|
|
'hard_numbers_json': '{}', 'hard_numbers': {'proposal_revision': 'old'}}
|
|
|
|
|
|
c = {'qty': 100, 'ts_code': p['ts_code'], 'hard_numbers': {'price': 10, 'plan_basis': {}}}
|
|
|
|
|
|
check = {'material': True, 'view': {'held': []}, 'params': {}, 'stock': {}}
|
2026-10-20 11:31:02 +08:00
|
|
|
|
with patch.object(rc, 'current_basis', side_effect=[check, {'ok':True,'provenance':{}}]), \
|
|
|
|
|
|
patch.object(pms_repo, 'record_proposal_check', return_value=1), \
|
2026-10-15 10:27:12 +08:00
|
|
|
|
patch.object(ps, '_scan_open', return_value=[c]) as scan, \
|
|
|
|
|
|
patch.object(ps, '_route_one', return_value={'verdict': {'verdict': 'PASS'}}) as route, \
|
|
|
|
|
|
patch.object(param_store, 'get_int', return_value=0), \
|
2026-10-20 11:31:02 +08:00
|
|
|
|
patch.object(pms_repo, 'publish_prepared_proposal', return_value=1) as revise, \
|
2026-10-15 10:27:12 +08:00
|
|
|
|
patch.object(pms_repo, 'insert_ledger', return_value=1):
|
2026-10-20 11:31:02 +08:00
|
|
|
|
out = rc.refresh_one(p)
|
|
|
|
|
|
self.assertTrue(out['ok'])
|
2026-10-15 10:27:12 +08:00
|
|
|
|
self.assertEqual(out['proposal']['qty'], 100)
|
|
|
|
|
|
self.assertNotEqual(out['proposal']['hard_numbers']['proposal_revision'], 'old')
|
|
|
|
|
|
self.assertEqual(scan.call_args.kwargs['exclude_proposal'], 'p1')
|
|
|
|
|
|
self.assertTrue(route.call_args.kwargs['review_only'])
|
|
|
|
|
|
self.assertTrue(c['_plan_recheck'])
|
|
|
|
|
|
|
2026-10-16 13:56:08 +08:00
|
|
|
|
def test_rejected_recheck_preserves_reason_without_accepting_or_upgrading_basis(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc, proposal_service as ps, param_store
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': '600001.SH', 'qty': 300,
|
|
|
|
|
|
'hard_numbers_json': '{}', 'hard_numbers': {'proposal_revision': 'old', 'plan_basis': {'old': True}}}
|
|
|
|
|
|
c = {'qty': 100, 'ts_code': p['ts_code'], 'hard_numbers': {'price': 10, 'plan_basis': {'new': True}}}
|
|
|
|
|
|
def reject(*args, **kw):
|
|
|
|
|
|
args[7]['rejected'].append({'ts_code': p['ts_code'], 'by': 'judge', 'failed': ['资金净流出,短期看空']})
|
2026-10-20 11:31:02 +08:00
|
|
|
|
with patch.object(pms_repo, 'record_proposal_check', return_value=1), \
|
|
|
|
|
|
patch.object(rc, 'current_basis', return_value={'material': True, 'view': {'held': []}, 'params': {}, 'stock': {}}), \
|
2026-10-16 13:56:08 +08:00
|
|
|
|
patch.object(ps, '_scan_open', return_value=[c]), patch.object(ps, '_route_one', side_effect=reject), \
|
|
|
|
|
|
patch.object(param_store, 'get_int', return_value=0), \
|
2026-10-16 14:36:31 +08:00
|
|
|
|
patch.object(pms_repo, 'withdraw_proposal', return_value=1) as record, \
|
2026-10-20 11:31:02 +08:00
|
|
|
|
patch.object(pms_repo, 'publish_prepared_proposal', side_effect=AssertionError('不得升级原批准依据')), \
|
2026-10-16 13:56:08 +08:00
|
|
|
|
patch.object(pms_repo, 'insert_instruction', side_effect=AssertionError('不得生成指令')):
|
2026-10-20 11:31:02 +08:00
|
|
|
|
out = rc.refresh_one(p)
|
2026-10-16 13:56:08 +08:00
|
|
|
|
self.assertFalse(out['ok'])
|
|
|
|
|
|
self.assertIn('最新研判不建议建仓:资金净流出,短期看空', out['error'])
|
|
|
|
|
|
hard = record.call_args.args[1]
|
|
|
|
|
|
self.assertEqual(hard['plan_basis'], {'old': True})
|
|
|
|
|
|
self.assertEqual(hard['proposal_revision'], 'old')
|
|
|
|
|
|
self.assertEqual(out['proposal']['qty'], 300)
|
|
|
|
|
|
self.assertEqual(hard['last_accept_check']['stage'], 'judge')
|
|
|
|
|
|
|
|
|
|
|
|
def test_blocked_recheck_distinguishes_rule_strategy_and_scan(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
p = {'proposal_id': 'p1', 'ts_code': '600001.SH', 'hard_numbers': {}}
|
|
|
|
|
|
examples = [({'rejected': [{'by': 'rule', 'failed': ['CASH: 可用资金不足']}]}, '交易条件不满足:可用资金不足'),
|
|
|
|
|
|
({'rejected': [{'by': 'system', 'why': '逻辑存疑'}]}, '当前策略不建议建仓:逻辑存疑'),
|
|
|
|
|
|
({'skipped': [{'why': '没有可用名额'}]}, '暂不满足建仓条件:没有可用名额')]
|
2026-10-16 14:36:31 +08:00
|
|
|
|
with patch.object(pms_repo, 'record_proposal_check', return_value=1), patch.object(pms_repo, 'withdraw_proposal', return_value=1):
|
2026-10-16 13:56:08 +08:00
|
|
|
|
for details, reason in examples:
|
|
|
|
|
|
out = rc._failed_recheck(p, details)
|
2026-10-16 14:36:31 +08:00
|
|
|
|
self.assertEqual(out['error'].removeprefix('提议已撤下。'), reason)
|
2026-10-16 13:56:08 +08:00
|
|
|
|
self.assertFalse(out['ok'])
|
|
|
|
|
|
|
|
|
|
|
|
def test_failed_recheck_does_not_overwrite_concurrent_proposal(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
with patch.object(pms_repo, 'record_proposal_check', return_value=0):
|
|
|
|
|
|
out = rc._failed_recheck({'proposal_id': 'p1', 'ts_code': '600001.SH'}, {})
|
|
|
|
|
|
self.assertFalse(out['ok'])
|
|
|
|
|
|
self.assertNotIn('proposal', out)
|
|
|
|
|
|
self.assertIn('已被更新', out['error'])
|
|
|
|
|
|
|
|
|
|
|
|
def test_record_check_is_conditional_and_keeps_quantity_status_and_verdict(self):
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
db = sqlite3.connect(':memory:')
|
|
|
|
|
|
self.addCleanup(db.close)
|
|
|
|
|
|
db.execute('CREATE TABLE pms_proposal(proposal_id TEXT, status TEXT, qty INTEGER, judge_verdict TEXT, hard_numbers_json TEXT)')
|
|
|
|
|
|
db.execute("INSERT INTO pms_proposal VALUES ('p1','WAIT_USER',300,'UNAVAILABLE','old')")
|
|
|
|
|
|
with patch.object(pms_repo, 'execute', side_effect=lambda q, p: db.execute(q,p).rowcount):
|
|
|
|
|
|
old = {'proposal_id':'p1','hard_numbers_json':'old'}
|
|
|
|
|
|
self.assertEqual(pms_repo.record_proposal_check(old, {'check':'blocked'}), 1)
|
|
|
|
|
|
self.assertEqual(pms_repo.record_proposal_check(old, {'check':'stale'}), 0)
|
|
|
|
|
|
self.assertEqual(db.execute('SELECT qty,status,judge_verdict FROM pms_proposal').fetchone(), (300,'WAIT_USER','UNAVAILABLE'))
|
|
|
|
|
|
|
|
|
|
|
|
def test_previous_rejection_cannot_reuse_old_approval_when_context_returns(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc, proposal_service as ps
|
|
|
|
|
|
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': '600001.SH',
|
|
|
|
|
|
'hard_numbers': {'proposal_revision': 'old', 'last_accept_check': {'status': 'blocked'}}}
|
2026-10-20 11:31:02 +08:00
|
|
|
|
with patch.object(rc, '_mark_pending', side_effect=lambda p,*a: p), \
|
|
|
|
|
|
patch.object(__import__('app.repo.pms_repo', fromlist=['record_proposal_check']), 'record_proposal_check', return_value=1), \
|
|
|
|
|
|
patch.object(rc, 'current_basis', return_value={'ok': True, 'material': False,
|
2026-10-16 13:56:08 +08:00
|
|
|
|
'view': {'held': []}, 'params': {}, 'stock': {}}), \
|
|
|
|
|
|
patch.object(ps, '_scan_open', return_value=[]) as scan, \
|
|
|
|
|
|
patch.object(rc, '_failed_recheck', return_value={'ok': False}) as blocked:
|
2026-10-20 11:31:02 +08:00
|
|
|
|
self.assertFalse(rc.refresh_one(p)['ok'])
|
2026-10-16 13:56:08 +08:00
|
|
|
|
scan.assert_called_once()
|
|
|
|
|
|
blocked.assert_called_once()
|
|
|
|
|
|
|
2026-10-16 14:36:31 +08:00
|
|
|
|
def test_definitive_rejection_withdraws_and_ignores_scan_context(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
p = {'proposal_id':'p1','ts_code':'600001.SH','action':'OPEN','qty':100,'hard_numbers':{}}
|
|
|
|
|
|
out = {'rejected':[{'ts_code':p['ts_code'],'by':'judge','failed':['资金流出。']}],
|
|
|
|
|
|
'skipped':[{'why':"在途建仓已占3个名额 ['SH600002']"}]}
|
|
|
|
|
|
with patch.object(pms_repo, 'withdraw_proposal', return_value=1, create=True) as withdraw, \
|
|
|
|
|
|
patch.object(pms_repo, 'record_proposal_check', side_effect=AssertionError('明确拒绝必须撤下')):
|
|
|
|
|
|
result = rc._failed_recheck(p,out)
|
|
|
|
|
|
self.assertTrue(result['withdrawn'])
|
|
|
|
|
|
self.assertEqual(result['proposal']['status'],'EXPIRED')
|
|
|
|
|
|
self.assertNotIn('在途',result['error'])
|
|
|
|
|
|
self.assertEqual(withdraw.call_args.args[1]['last_accept_check']['status'],'withdrawn')
|
|
|
|
|
|
|
|
|
|
|
|
def test_withdrawal_transaction_is_atomic_and_idempotent(self):
|
|
|
|
|
|
from sqlalchemy import create_engine, text
|
|
|
|
|
|
from app.db import session
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
engine=create_engine('sqlite:///:memory:')
|
|
|
|
|
|
self.addCleanup(engine.dispose)
|
|
|
|
|
|
with engine.begin() as db:
|
|
|
|
|
|
db.execute(text('CREATE TABLE pms_proposal(proposal_id TEXT, status TEXT, decided_at TEXT, qty INTEGER, hard_numbers_json TEXT)'))
|
|
|
|
|
|
db.execute(text("INSERT INTO pms_proposal VALUES ('p1','WAIT_USER',NULL,100,'old')"))
|
|
|
|
|
|
old={'proposal_id':'p1','ts_code':'600001.SH','action':'OPEN','hard_numbers_json':'old'}
|
|
|
|
|
|
hard={'last_accept_check':{'stage':'judge','at':'2026-10-16T14:20:00+08:00','message':'资金流出','status':'withdrawn'}}
|
|
|
|
|
|
with patch.object(session,'get_engine',return_value=engine), patch.object(session,'_check_cooldown'):
|
|
|
|
|
|
with self.assertRaises(Exception):
|
|
|
|
|
|
pms_repo.withdraw_proposal(old,hard) # 账本不存在,状态必须一起回滚。
|
|
|
|
|
|
with engine.begin() as db:
|
|
|
|
|
|
self.assertEqual(db.execute(text('SELECT status FROM pms_proposal')).scalar(),'WAIT_USER')
|
|
|
|
|
|
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.withdraw_proposal(old,hard),1)
|
|
|
|
|
|
self.assertEqual(pms_repo.withdraw_proposal(old,hard),0)
|
|
|
|
|
|
self.assertEqual(pms_repo.decide_proposal('p1','ACCEPTED',expected_hard='old'),0)
|
|
|
|
|
|
with engine.begin() as db:
|
|
|
|
|
|
self.assertEqual(tuple(db.execute(text('SELECT status,qty FROM pms_proposal')).one()),('EXPIRED',100))
|
|
|
|
|
|
self.assertEqual(db.execute(text('SELECT COUNT(*) FROM pms_action_ledger')).scalar(),1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_recorded_rejections_cleanup_is_scoped_and_model_free(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
def prop(i,action='OPEN',stage='judge'):
|
|
|
|
|
|
return {'proposal_id':i,'ts_code':'600001.SH','action':action,'status':'WAIT_USER',
|
|
|
|
|
|
'hard_numbers':{'last_accept_check':{'status':'blocked','stage':stage,
|
|
|
|
|
|
'details':{'rejected':[{'by':stage,'failed':['原拒绝']} ]}}}}
|
|
|
|
|
|
rows=[prop('yes'),prop('sell','EXIT'),prop('rule',stage='rule'),prop('data',stage='scan')]
|
|
|
|
|
|
with patch.object(pms_repo,'list_proposals',return_value=rows), \
|
|
|
|
|
|
patch.object(rc,'_failed_recheck',return_value={'withdrawn':True}) as fail:
|
|
|
|
|
|
self.assertEqual(rc.withdraw_recorded_rejections(),1)
|
|
|
|
|
|
self.assertEqual(fail.call_args.args[0]['proposal_id'],'yes')
|
|
|
|
|
|
|
|
|
|
|
|
def test_unavailable_and_scan_notes_do_not_withdraw(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
p={'proposal_id':'p1','ts_code':'600001.SH','hard_numbers':{}}
|
|
|
|
|
|
with patch.object(pms_repo,'record_proposal_check',return_value=1), \
|
|
|
|
|
|
patch.object(pms_repo,'withdraw_proposal',side_effect=AssertionError('暂时失败保留'),create=True):
|
|
|
|
|
|
for detail in ({'skipped':[{'why':'候选池取不到'}]},
|
|
|
|
|
|
{'rejected':[{'by':'rule','failed':['PRICE: 读不到行情']}]},
|
|
|
|
|
|
{'skipped':[{'informational':True,'why':'在途占用'}]}):
|
|
|
|
|
|
out=rc._failed_recheck(p,detail)
|
|
|
|
|
|
self.assertFalse(out.get('withdrawn'))
|
|
|
|
|
|
self.assertNotIn('在途占用',out['error'])
|
|
|
|
|
|
|
2026-10-20 11:31:02 +08:00
|
|
|
|
def test_browser_wait_is_independent_of_judge_timeout(self):
|
2026-10-16 13:56:08 +08:00
|
|
|
|
from app.web import main
|
|
|
|
|
|
with patch.object(main.param_store, 'get_int', return_value=240):
|
|
|
|
|
|
out = main.api_decide_reasons()
|
2026-10-20 11:31:02 +08:00
|
|
|
|
self.assertEqual(out['request_timeout_ms'], 30000)
|
2026-10-16 13:56:08 +08:00
|
|
|
|
self.assertTrue(out['accept'])
|
|
|
|
|
|
|
2026-10-16 14:36:31 +08:00
|
|
|
|
def test_missing_candidate_during_pending_revision_is_not_withdrawn(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc, proposal_service as ps, portfolio, command_service
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
with patch.object(portfolio,'positions_view',return_value={'held':[]}), \
|
|
|
|
|
|
patch.object(command_service,'effective_stock_params',return_value={}), \
|
|
|
|
|
|
patch.object(ps,'_scan_params',return_value={}), \
|
|
|
|
|
|
patch.object(pf,'candidates',return_value={'items':[],'decision':{'id':'v2'},
|
|
|
|
|
|
'refresh_status':{'pending_codes':['600001.SH']}}):
|
|
|
|
|
|
out=rc.current_basis({},'600001.SH')
|
|
|
|
|
|
self.assertFalse(out['ok'])
|
|
|
|
|
|
self.assertFalse(out.get('withdraw'))
|
|
|
|
|
|
|
|
|
|
|
|
def test_verified_qualification_loss_withdraws_without_judgement(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc, proposal_service as ps
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
p={'proposal_id':'p1','ts_code':'600001.SH','action':'OPEN','hard_numbers':{}}
|
|
|
|
|
|
with patch.object(rc,'current_basis',return_value={'ok':False,'material':True,
|
|
|
|
|
|
'withdraw':True,'error':'已不满足候选资格'}), \
|
|
|
|
|
|
patch.object(pms_repo,'withdraw_proposal',return_value=1), \
|
|
|
|
|
|
patch.object(ps,'_route_one',side_effect=AssertionError('无需新研判')):
|
2026-10-20 11:31:02 +08:00
|
|
|
|
out=rc.refresh_one(p)
|
2026-10-16 14:36:31 +08:00
|
|
|
|
self.assertTrue(out['withdrawn'])
|
|
|
|
|
|
|
2026-10-15 10:27:12 +08:00
|
|
|
|
def test_version_unavailable_and_qualification_loss_preserve_proposal(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc
|
|
|
|
|
|
p = {'action': 'OPEN', 'ts_code': '600001.SH', 'hard_numbers': {}}
|
|
|
|
|
|
for check in ({'ok': False, 'error': '版本不可用'},
|
|
|
|
|
|
{'ok': False, 'material': True, 'error': '资格失效'}):
|
|
|
|
|
|
with patch.object(rc, 'current_basis', return_value=check):
|
|
|
|
|
|
self.assertFalse(rc.before_accept(p, None)['ok'])
|
|
|
|
|
|
|
|
|
|
|
|
def test_compare_and_set_rejects_stale_and_duplicate_acceptance(self):
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
import threading
|
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
|
db = sqlite3.connect(':memory:', check_same_thread=False)
|
|
|
|
|
|
self.addCleanup(db.close)
|
|
|
|
|
|
db.execute('CREATE TABLE pms_proposal(proposal_id TEXT, status TEXT, decided_at TEXT, hard_numbers_json TEXT)')
|
|
|
|
|
|
db.execute("INSERT INTO pms_proposal VALUES ('p1', 'WAIT_USER', NULL, 'new')")
|
|
|
|
|
|
lock = threading.Lock()
|
|
|
|
|
|
def execute(sql, params):
|
|
|
|
|
|
with lock:
|
|
|
|
|
|
n = db.execute(sql, params).rowcount
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
return n
|
|
|
|
|
|
with patch.object(pms_repo, 'execute', execute):
|
|
|
|
|
|
self.assertEqual(pms_repo.decide_proposal('p1', 'ACCEPTED', expected_hard='old'), 0)
|
|
|
|
|
|
with ThreadPoolExecutor(2) as pool:
|
|
|
|
|
|
results = list(pool.map(lambda _: pms_repo.decide_proposal('p1', 'ACCEPTED', expected_hard='new'), range(2)))
|
|
|
|
|
|
self.assertEqual(sum(results), 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_rank_survives_to_single_slot(self):
|
|
|
|
|
|
rows = [dict(r, price=10) for r in selected()]
|
|
|
|
|
|
out = ae.scan_open(candidates=rows, params=params(open_signal_priority=False),
|
|
|
|
|
|
caps=caps(), room_amt=1_000_000, slots=1)
|
|
|
|
|
|
self.assertEqual(out['candidates'][0]['ts_code'], '600001.SH')
|
|
|
|
|
|
self.assertEqual(out['candidates'][0]['hard_numbers']['execution_rank'], 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_signal_priority_has_reason(self):
|
|
|
|
|
|
rows = [dict(r, price=10) for r in selected()]
|
|
|
|
|
|
rows[1]['sig_buy'] = {'reason': '盘中转多'}
|
|
|
|
|
|
out = ae.scan_open(candidates=rows, params=params(open_signal_priority=True),
|
|
|
|
|
|
caps=caps(), room_amt=1_000_000, slots=1)
|
|
|
|
|
|
self.assertEqual(out['candidates'][0]['ts_code'], '600002.SH')
|
|
|
|
|
|
self.assertEqual(out['candidates'][0]['hard_numbers']['priority_reason'], '盘中转多信号优先')
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_themes_do_not_form_sector(self):
|
|
|
|
|
|
self.assertEqual(len(selected()), 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_rank_only_change_keeps_proposal(self):
|
|
|
|
|
|
row = selected()[0]
|
|
|
|
|
|
old = pr.basis(row)
|
|
|
|
|
|
row.update(selection_rank=20, research_rank=100, decision={'id': 'v2'})
|
|
|
|
|
|
self.assertEqual(pr.compare(old, row, row['ts_code']), 'unchanged')
|
|
|
|
|
|
row['risk'] = ['材料存在矛盾']
|
|
|
|
|
|
self.assertEqual(pr.compare(old, row, row['ts_code']), 'material')
|
|
|
|
|
|
|
|
|
|
|
|
def test_pending_or_unverifiable_does_not_pass(self):
|
|
|
|
|
|
row = selected()[0]
|
|
|
|
|
|
old = pr.basis(row)
|
|
|
|
|
|
row['refresh_status'] = {'pending_codes': [row['ts_code']]}
|
|
|
|
|
|
self.assertEqual(pr.compare(old, row, row['ts_code']), 'unavailable')
|
|
|
|
|
|
row.pop('refresh_status')
|
|
|
|
|
|
row['decision'] = None
|
|
|
|
|
|
self.assertEqual(pr.compare(old, row, row['ts_code']), 'unavailable')
|
|
|
|
|
|
|
|
|
|
|
|
def test_stale_human_confirmation_rejected(self):
|
|
|
|
|
|
from app.services import plan_recheck as rc
|
2026-10-20 11:31:02 +08:00
|
|
|
|
p = {'action': 'OPEN', 'ts_code': '600001.SH', 'hard_numbers': {'proposal_revision': 'new', 'consumption_id': 'saved', 'preparation': {'status':'ready'}}}
|
|
|
|
|
|
with patch.object(rc, 'fast_checks', return_value=None), \
|
|
|
|
|
|
patch.object(rc, 'current_basis', return_value={'ok': True, 'provenance': {}, 'view': {'held':[]}}):
|
2026-10-15 10:27:12 +08:00
|
|
|
|
self.assertFalse(rc.before_accept(p, 'old')['ok'])
|
|
|
|
|
|
self.assertTrue(rc.before_accept(p, 'new')['ok'])
|
|
|
|
|
|
|
|
|
|
|
|
def test_received_and_consumed_fingerprint_include_context(self):
|
|
|
|
|
|
old = {'decision': {'id': 'v1'}, 'params': {'top_n': 2}, 'order': ['A', 'B']}
|
|
|
|
|
|
new = copy.deepcopy(old)
|
|
|
|
|
|
new['params']['top_n'] = 1
|
|
|
|
|
|
self.assertNotEqual(pr.fingerprint(old), pr.fingerprint(new))
|
|
|
|
|
|
self.assertEqual(old['params']['top_n'], 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_pending_prefix_codes_normalize(self):
|
|
|
|
|
|
p = pf.parse_plan({'date': '2026-10-14', 'main': [{'code': 'SH600001', 'score': 1}],
|
|
|
|
|
|
'refresh_status': {'pending_codes': ['SH600001']}})
|
|
|
|
|
|
self.assertEqual(p['refresh_status']['pending_codes'], ['600001.SH'])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
|
unittest.main()
|