366 lines
16 KiB
Python
366 lines
16 KiB
Python
import json
|
||
import os
|
||
import sqlite3
|
||
import tempfile
|
||
import unittest
|
||
from datetime import datetime
|
||
from unittest.mock import patch
|
||
|
||
from app.db import altcoin_db
|
||
|
||
|
||
class RecommendationExecutionStatusTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmpdir = tempfile.TemporaryDirectory()
|
||
self.db_path = os.path.join(self.tmpdir.name, 'test_altcoin.db')
|
||
self.db_patch = patch.object(altcoin_db, 'DB_PATH', self.db_path)
|
||
self.db_patch.start()
|
||
altcoin_db.init_db()
|
||
|
||
def tearDown(self):
|
||
self.db_patch.stop()
|
||
self.tmpdir.cleanup()
|
||
|
||
def _insert_rec(self, **kwargs):
|
||
defaults = dict(
|
||
symbol='AAA/USDT',
|
||
rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||
rec_state='加速',
|
||
rec_score=40,
|
||
entry_price=100.0,
|
||
stop_loss=95.0,
|
||
tp1=110.0,
|
||
tp2=118.0,
|
||
sector='AI',
|
||
signals=json.dumps(['🟢 15min即刻入场信号'], ensure_ascii=False),
|
||
is_meme=0,
|
||
status='active',
|
||
current_price=100.0,
|
||
max_price=104.0,
|
||
min_price=98.0,
|
||
pnl_pct=0.0,
|
||
max_pnl_pct=4.0,
|
||
max_drawdown_pct=-1.0,
|
||
hit_tp1_time='',
|
||
hit_tp2_time='',
|
||
stopped_out_time='',
|
||
expired_time='',
|
||
last_track_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 100.0,
|
||
'entry_action': '可即刻买入',
|
||
'risk_reward_ok': True,
|
||
'stop_loss': 95.0,
|
||
'stop_pct': -5.0,
|
||
'tp1': 110.0,
|
||
'tp2': 118.0,
|
||
'rr1': 2.0,
|
||
'rr2': 3.6,
|
||
'entry_trigger_confirmed': True,
|
||
}, ensure_ascii=False),
|
||
action_status='可即刻买入',
|
||
direction='多头启动',
|
||
)
|
||
defaults.update(kwargs)
|
||
conn = sqlite3.connect(self.db_path)
|
||
conn.execute(
|
||
'''
|
||
INSERT INTO recommendation (
|
||
symbol, rec_time, rec_state, rec_score, entry_price, stop_loss, tp1, tp2,
|
||
sector, signals, is_meme, status, current_price, max_price, min_price,
|
||
pnl_pct, max_pnl_pct, max_drawdown_pct, hit_tp1_time, hit_tp2_time,
|
||
stopped_out_time, expired_time, last_track_time, entry_plan_json,
|
||
action_status, direction
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
''',
|
||
(
|
||
defaults['symbol'], defaults['rec_time'], defaults['rec_state'], defaults['rec_score'],
|
||
defaults['entry_price'], defaults['stop_loss'], defaults['tp1'], defaults['tp2'],
|
||
defaults['sector'], defaults['signals'], defaults['is_meme'], defaults['status'],
|
||
defaults['current_price'], defaults['max_price'], defaults['min_price'], defaults['pnl_pct'],
|
||
defaults['max_pnl_pct'], defaults['max_drawdown_pct'], defaults['hit_tp1_time'], defaults['hit_tp2_time'],
|
||
defaults['stopped_out_time'], defaults['expired_time'], defaults['last_track_time'], defaults['entry_plan_json'],
|
||
defaults['action_status'], defaults['direction'],
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
def _get_active(self):
|
||
rows = altcoin_db.get_active_recommendations_deduped()
|
||
self.assertGreaterEqual(len(rows), 1)
|
||
return next(r for r in rows if r['symbol'] == 'AAA/USDT')
|
||
|
||
def test_buy_now_status_for_immediate_entry(self):
|
||
self._insert_rec()
|
||
row = self._get_active()
|
||
self.assertEqual(row['execution_status'], 'buy_now')
|
||
self.assertEqual(row['execution_label'], '🟢 现在可买')
|
||
self.assertEqual(row['initial_action'], '可即刻买入')
|
||
self.assertIn('推荐时就是可即刻买入', row['execution_reason'])
|
||
|
||
def test_low_score_buy_now_downgrades_before_dashboard(self):
|
||
self._insert_rec(symbol='DBG/USDT', rec_score=8)
|
||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||
row = next(r for r in rows if r['symbol'] == 'DBG/USDT')
|
||
self.assertEqual(row['execution_status'], 'observe')
|
||
self.assertEqual(row['action_status'], '观察')
|
||
self.assertIn('信号不足', row['execution_reason'])
|
||
self.assertIn('entry_quality_gate', row['entry_plan'])
|
||
|
||
def test_stale_buy_now_downgrades_before_dashboard(self):
|
||
self._insert_rec(rec_time='2026-04-29T10:00:00', last_track_time='2026-04-29T10:05:00')
|
||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||
row = next(r for r in rows if r['symbol'] == 'AAA/USDT')
|
||
self.assertNotEqual(row['execution_status'], 'buy_now')
|
||
self.assertIn(row['action_status'], ('观察', '等回踩'))
|
||
self.assertTrue(row.get('entry_window_alert'))
|
||
|
||
def test_wait_pullback_status_for_wait_action(self):
|
||
self._insert_rec(
|
||
symbol='BBB/USDT',
|
||
action_status='等回踩',
|
||
current_price=104.0,
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 100.0,
|
||
'entry_action': '等回踩',
|
||
'risk_reward_ok': True,
|
||
}, ensure_ascii=False),
|
||
)
|
||
rows = altcoin_db.get_active_recommendations_deduped()
|
||
target = next(r for r in rows if r['symbol'] == 'BBB/USDT')
|
||
self.assertEqual(target['execution_status'], 'wait_pullback')
|
||
self.assertEqual(target['execution_label'], '🟡 等回踩,不追高')
|
||
self.assertIn('等待回踩', target['execution_reason'])
|
||
|
||
def test_wait_pullback_downbreak_degrades_to_observe(self):
|
||
self._insert_rec(
|
||
symbol='BBB/USDT',
|
||
action_status='等回踩',
|
||
current_price=98.0,
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 100.0,
|
||
'entry_action': '等回踩',
|
||
'risk_reward_ok': True,
|
||
'stop_loss': 95.0,
|
||
'tp1': 110.0,
|
||
'rr1': 2.0,
|
||
}, ensure_ascii=False),
|
||
)
|
||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||
target = next(r for r in rows if r['symbol'] == 'BBB/USDT')
|
||
self.assertEqual(target['action_status'], '观察')
|
||
self.assertEqual(target['execution_status'], 'observe')
|
||
self.assertIn('回踩参考已下破', target['execution_reason'])
|
||
|
||
def test_wait_pullback_at_reference_promotes_to_buy_now_when_rr_ok(self):
|
||
self._insert_rec(
|
||
symbol='BBB/USDT',
|
||
action_status='等回踩',
|
||
current_price=99.9,
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 100.0,
|
||
'entry_action': '等回踩',
|
||
'risk_reward_ok': True,
|
||
'stop_loss': 95.0,
|
||
'tp1': 110.0,
|
||
'rr1': 2.0,
|
||
}, ensure_ascii=False),
|
||
)
|
||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||
target = next(r for r in rows if r['symbol'] == 'BBB/USDT')
|
||
self.assertEqual(target['action_status'], '可即刻买入')
|
||
self.assertEqual(target['execution_status'], 'buy_now')
|
||
self.assertTrue(target['entry_plan']['entry_trigger_confirmed'])
|
||
|
||
def test_invalid_status_for_decay(self):
|
||
self._insert_rec(
|
||
symbol='CCC/USDT',
|
||
action_status='衰减',
|
||
current_price=107.0,
|
||
max_pnl_pct=8.0,
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 100.0,
|
||
'entry_action': '可即刻买入',
|
||
'risk_reward_ok': True,
|
||
}, ensure_ascii=False),
|
||
)
|
||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||
target = next(r for r in rows if r['symbol'] == 'CCC/USDT')
|
||
self.assertEqual(target['execution_status'], 'invalid')
|
||
self.assertEqual(target['execution_label'], '🔴 已失效,勿追')
|
||
self.assertIn('趋势衰减', target['execution_reason'])
|
||
|
||
def test_completed_status_for_take_profit(self):
|
||
self._insert_rec(
|
||
symbol='DDD/USDT',
|
||
status='hit_tp1',
|
||
action_status='止盈1',
|
||
current_price=111.0,
|
||
pnl_pct=11.0,
|
||
max_pnl_pct=12.0,
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 100.0,
|
||
'entry_action': '可即刻买入',
|
||
'risk_reward_ok': True,
|
||
}, ensure_ascii=False),
|
||
)
|
||
all_rows = altcoin_db.get_all_recommendations(limit=10)
|
||
target = next(r for r in all_rows if r['symbol'] == 'DDD/USDT')
|
||
self.assertEqual(target['execution_status'], 'completed')
|
||
self.assertEqual(target['execution_label'], '✅ 已兑现,仅观察')
|
||
self.assertIn('止盈', target['execution_reason'])
|
||
|
||
stats = altcoin_db.get_stats()
|
||
self.assertEqual(stats['active_count'], 0)
|
||
self.assertIsNone(stats['leaderboard']['top_gainer'])
|
||
|
||
def test_create_recommendation_skips_duplicate_symbol_state_within_window(self):
|
||
with patch.object(altcoin_db, 'get_meta', return_value={'strategy_version': 'v1.2'}), \
|
||
patch.object(altcoin_db, 'datetime') as mock_datetime:
|
||
mock_datetime.now.return_value = unittest.mock.Mock(isoformat=lambda: '2026-04-30T10:00:00')
|
||
|
||
rec_id_1 = altcoin_db.create_recommendation(
|
||
symbol='AAA/USDT', rec_state='加速', rec_score=8, entry_price=100.0,
|
||
sector='AI', signals=['4H 连续4K多头加速'], is_meme=0, direction='多头启动'
|
||
)
|
||
rec_id_2 = altcoin_db.create_recommendation(
|
||
symbol='AAA/USDT', rec_state='加速', rec_score=9, entry_price=101.0,
|
||
sector='AI', signals=['4H 连续4K多头加速'], is_meme=0, direction='多头启动'
|
||
)
|
||
|
||
self.assertEqual(rec_id_1, rec_id_2)
|
||
|
||
conn = sqlite3.connect(self.db_path)
|
||
count = conn.execute("SELECT COUNT(*) FROM recommendation WHERE symbol='AAA/USDT'").fetchone()[0]
|
||
conn.close()
|
||
self.assertEqual(count, 1)
|
||
|
||
def test_create_recommendation_allows_new_state_transition_within_window(self):
|
||
with patch.object(altcoin_db, 'get_meta', return_value={'strategy_version': 'v1.2'}), \
|
||
patch.object(altcoin_db, 'datetime') as mock_datetime:
|
||
mock_datetime.now.return_value = unittest.mock.Mock(isoformat=lambda: '2026-04-30T10:00:00')
|
||
|
||
rec_id_1 = altcoin_db.create_recommendation(
|
||
symbol='AAA/USDT', rec_state='蓄力', rec_score=5, entry_price=100.0,
|
||
sector='AI', signals=['4H 3静K蓄力'], is_meme=0, direction='多头启动'
|
||
)
|
||
rec_id_2 = altcoin_db.create_recommendation(
|
||
symbol='AAA/USDT', rec_state='加速', rec_score=9, entry_price=103.0,
|
||
sector='AI', signals=['4H 连续4K多头加速'], is_meme=0, direction='多头启动'
|
||
)
|
||
|
||
self.assertNotEqual(rec_id_1, rec_id_2)
|
||
|
||
conn = sqlite3.connect(self.db_path)
|
||
count = conn.execute("SELECT COUNT(*) FROM recommendation WHERE symbol='AAA/USDT'").fetchone()[0]
|
||
conn.close()
|
||
self.assertEqual(count, 2)
|
||
def test_risk_reward_false_blocks_buy_now(self):
|
||
self._insert_rec(
|
||
symbol='EEE/USDT',
|
||
action_status='可即刻买入',
|
||
current_price=0.0758,
|
||
entry_price=0.0758,
|
||
signals=json.dumps(['1H 起爆点↑(强度56×)', '⚠️ 等回踩降权(-3分)'], ensure_ascii=False),
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 0.072,
|
||
'entry_action': '等回踩',
|
||
'risk_reward_ok': False,
|
||
'rr1': 0.4,
|
||
'stop_loss': 0.07,
|
||
}, ensure_ascii=False),
|
||
)
|
||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||
target = next(r for r in rows if r['symbol'] == 'EEE/USDT')
|
||
self.assertNotEqual(target['execution_status'], 'buy_now')
|
||
self.assertIn(target['action_status'], ('等回踩', '观察'))
|
||
self.assertIn('entry_quality_gate', target['entry_plan'])
|
||
|
||
def test_update_action_status_refuses_buy_now_when_rr_bad(self):
|
||
self._insert_rec(
|
||
symbol='FFF/USDT',
|
||
action_status='持有',
|
||
current_price=0.0758,
|
||
entry_price=0.0758,
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 0.072,
|
||
'entry_action': '等回踩',
|
||
'risk_reward_ok': False,
|
||
'rr1': 0.4,
|
||
}, ensure_ascii=False),
|
||
)
|
||
conn = sqlite3.connect(self.db_path)
|
||
rec_id = conn.execute("SELECT id FROM recommendation WHERE symbol='FFF/USDT'").fetchone()[0]
|
||
conn.close()
|
||
|
||
altcoin_db.update_recommendation_action_status(rec_id, '可即刻买入')
|
||
conn = sqlite3.connect(self.db_path)
|
||
row = conn.execute("SELECT action_status, entry_plan_json FROM recommendation WHERE id=?", (rec_id,)).fetchone()
|
||
conn.close()
|
||
self.assertNotEqual(row[0], '可即刻买入')
|
||
self.assertIn(row[0], ('等回踩', '观察'))
|
||
self.assertIn('entry_quality_gate', json.loads(row[1]))
|
||
|
||
def test_create_recommendation_stores_stable_signal_codes(self):
|
||
rec_id = altcoin_db.create_recommendation(
|
||
symbol='SIG/USDT',
|
||
rec_state='爆发',
|
||
rec_score=12,
|
||
entry_price=1.0,
|
||
signals=['1H 量价齐飞K(量3.7x)', '1H 量价齐飞K(量9.1x)', '15min 回踩确认'],
|
||
entry_plan={'entry_action': '可即刻买入', 'entry_price': 1.0, 'risk_reward_ok': True, 'rr1': 2.0},
|
||
direction='多头启动',
|
||
)
|
||
conn = sqlite3.connect(self.db_path)
|
||
row = conn.execute("SELECT signal_codes_json, signal_labels_json FROM recommendation WHERE id=?", (rec_id,)).fetchone()
|
||
conn.close()
|
||
codes = json.loads(row[0])
|
||
labels = json.loads(row[1])
|
||
self.assertEqual(codes.count('vp_fly_1h_current'), 1)
|
||
self.assertIn('pullback_15m_confirm', codes)
|
||
self.assertEqual(len(labels), 3)
|
||
|
||
def test_closed_paper_trade_puts_symbol_into_cooling_off(self):
|
||
self._insert_rec(
|
||
symbol='HYPER/USDT',
|
||
action_status='等回踩',
|
||
current_price=0.1257,
|
||
entry_plan_json=json.dumps({
|
||
'entry_price': 0.109065,
|
||
'entry_action': '等回踩',
|
||
'stop_loss': 0.118914,
|
||
'tp1': 0.135879,
|
||
'risk_reward_ok': True,
|
||
'rr1': 1.5,
|
||
}, ensure_ascii=False),
|
||
)
|
||
conn = sqlite3.connect(self.db_path)
|
||
conn.execute(
|
||
'''
|
||
INSERT INTO paper_trades (
|
||
recommendation_id, symbol, side, status, opened_at, closed_at,
|
||
entry_price, exit_price, qty, notional_usdt, stop_loss, tp1, tp2,
|
||
max_price, min_price, current_price, pnl_pct, realized_pnl_pct, realized_pnl_usdt,
|
||
fee_usdt, exit_reason, source_status, source_action, strategy_version, created_at, updated_at
|
||
) VALUES (?, ?, 'long', 'closed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
''',
|
||
(
|
||
1, 'HYPER/USDT', '2026-05-18T14:41:09', '2026-05-18T15:11:03',
|
||
0.11275635, 0.1141429, 44343.40061557508, 5000.0, 0.10906, 0.118714, 0.12165,
|
||
0.1163, 0.1125, 0.1141429, 1.2297, 1.2297, 51.485, 10.0, 'trailing_stop',
|
||
'buy_now', '可即刻买入', 'v-test', '2026-05-18T14:41:09', '2026-05-18T15:11:03'
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||
target = next(r for r in rows if r['symbol'] == 'HYPER/USDT')
|
||
self.assertIn(target['action_status'], ('观察', '等回踩', '止盈1', '止盈2', '止损', '跟踪止盈'))
|
||
self.assertFalse(target['execution_status'] == 'buy_now' and target['entry_triggered'] == 1)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|