tradingSystem/scripts/test_batch38_units.py

237 lines
14 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'}
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': row['ts_code'], 'hard_numbers': hard}
with 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', return_value=check), \
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, 'revise_pending_proposal', return_value=1) as revise, \
patch.object(pms_repo, 'insert_ledger', return_value=1):
out = rc.before_accept(p, 'old')
self.assertFalse(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_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'}}
with patch.object(rc, 'current_basis', return_value={'ok': True, 'provenance': {}}):
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()