tradingSystem/scripts/test_batch38_units.py

407 lines
26 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_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))
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({}),
'portfolio_context_hash': pr.portfolio_context(view, old), 'proposal_revision': 'same', 'consumption_id': 'saved', 'preparation': {'status': 'ready'}}
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': row['ts_code'], 'hard_numbers': hard}
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), \
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()
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': {}}
with patch.object(rc, 'current_basis', side_effect=[check, {'ok':True,'provenance':{}}]), \
patch.object(pms_repo, 'record_proposal_check', return_value=1), \
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), \
patch.object(pms_repo, 'publish_prepared_proposal', return_value=1) as revise, \
patch.object(pms_repo, 'insert_ledger', return_value=1):
out = rc.refresh_one(p)
self.assertTrue(out['ok'])
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'])
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': ['资金净流出,短期看空']})
with patch.object(pms_repo, 'record_proposal_check', return_value=1), \
patch.object(rc, 'current_basis', return_value={'material': True, 'view': {'held': []}, 'params': {}, 'stock': {}}), \
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), \
patch.object(pms_repo, 'withdraw_proposal', return_value=1) as record, \
patch.object(pms_repo, 'publish_prepared_proposal', side_effect=AssertionError('不得升级原批准依据')), \
patch.object(pms_repo, 'insert_instruction', side_effect=AssertionError('不得生成指令')):
out = rc.refresh_one(p)
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': '没有可用名额'}]}, '暂不满足建仓条件:没有可用名额')]
with patch.object(pms_repo, 'record_proposal_check', return_value=1), patch.object(pms_repo, 'withdraw_proposal', return_value=1):
for details, reason in examples:
out = rc._failed_recheck(p, details)
self.assertEqual(out['error'].removeprefix('提议已撤下。'), reason)
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'}}}
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,
'view': {'held': []}, 'params': {}, 'stock': {}}), \
patch.object(ps, '_scan_open', return_value=[]) as scan, \
patch.object(rc, '_failed_recheck', return_value={'ok': False}) as blocked:
self.assertFalse(rc.refresh_one(p)['ok'])
scan.assert_called_once()
blocked.assert_called_once()
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'])
def test_browser_wait_is_independent_of_judge_timeout(self):
from app.web import main
with patch.object(main.param_store, 'get_int', return_value=240):
out = main.api_decide_reasons()
self.assertEqual(out['request_timeout_ms'], 30000)
self.assertTrue(out['accept'])
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('无需新研判')):
out=rc.refresh_one(p)
self.assertTrue(out['withdrawn'])
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
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':[]}}):
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()