import os import sys from datetime import datetime import pytest from fastapi.testclient import TestClient PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if PROJECT_DIR not in sys.path: sys.path.insert(0, PROJECT_DIR) from app.db import altcoin_db from app.web import web_server @pytest.fixture def temp_db(monkeypatch, tmp_path): db_path = tmp_path / "altcoin_monitor.db" monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path)) monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db) monkeypatch.setattr(web_server, "get_stats", altcoin_db.get_stats) monkeypatch.setattr(web_server, "get_active_recommendations", altcoin_db.get_active_recommendations) monkeypatch.setattr(web_server, "get_active_recommendations_deduped", altcoin_db.get_active_recommendations_deduped) altcoin_db.init_db() return db_path def _insert_recommendation(db_path, **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='["🟢 15min即刻入场信号"]', 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='{"entry_price":100,"entry_action":"可即刻买入","entry_trigger_confirmed":true,"risk_reward_ok":true,"stop_loss":95,"tp1":110,"rr1":2}', action_status='持有', direction='多头启动', strategy_version='v1.2', ) defaults.update(kwargs) conn = altcoin_db.sqlite3.connect(str(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, strategy_version ) 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'], defaults['strategy_version'], ), ) conn.commit() conn.close() def test_active_api_only_returns_actionable_recommendations(temp_db): _insert_recommendation( temp_db, symbol='BUY/USDT', action_status='可即刻买入', entry_plan_json='{"entry_action": "可即刻买入"}' ) _insert_recommendation( temp_db, symbol='WAIT/USDT', action_status='等回踩', entry_plan_json='{"entry_action": "等回踩"}' ) _insert_recommendation( temp_db, symbol='OBS/USDT', action_status='持有', entry_plan_json='{"entry_action": "继续观察"}' ) _insert_recommendation( temp_db, symbol='INV/USDT', action_status='衰减', entry_plan_json='{"entry_action": "可即刻买入"}' ) client = TestClient(web_server.app) resp = client.get('/api/recommendations/active') assert resp.status_code == 200 rows = resp.json() assert {row['symbol'] for row in rows} == {'WAIT/USDT', 'BUY/USDT'} assert {row['execution_status'] for row in rows} == {'buy_now', 'wait_pullback'} buy = next(row for row in rows if row['symbol'] == 'BUY/USDT') wait = next(row for row in rows if row['symbol'] == 'WAIT/USDT') assert buy['discovery_state'] == '加速' assert buy['trade_stage'] == 'buy_now' assert buy['is_executable_now'] is True assert wait['trade_stage'] == 'wait_pullback' assert wait['is_trade_candidate'] is True meta = altcoin_db.get_active_recommendations_deduped(actionable_only=False, with_meta=True) assert meta['summary']['executable_now'] == 1 assert meta['summary']['planned_entry'] == 1 assert meta['summary']['watch_pool'] == 2 def test_stats_only_count_actionable_active_recommendations(temp_db): _insert_recommendation( temp_db, symbol='BUY/USDT', action_status='可即刻买入', entry_plan_json='{"entry_action": "可即刻买入"}', pnl_pct=5.0, max_pnl_pct=5.0, ) _insert_recommendation( temp_db, symbol='WAIT/USDT', action_status='等回踩', entry_plan_json='{"entry_action": "等回踩"}', pnl_pct=1.0, ) _insert_recommendation( temp_db, symbol='OBS/USDT', action_status='持有', entry_plan_json='{"entry_action": "继续观察"}', pnl_pct=9.0, max_pnl_pct=9.0, ) _insert_recommendation( temp_db, symbol='INV/USDT', action_status='衰减', entry_plan_json='{"entry_action": "可即刻买入"}', pnl_pct=-6.0, max_drawdown_pct=-6.0, ) stats = altcoin_db.get_stats() assert stats['active_count'] == 2 assert stats['raw_active_count'] == 4 assert stats['active_pnl_sum'] == 0 assert stats['active_avg_pnl'] == 0 assert stats['active_success_count'] == 0 assert stats['active_failed_count'] == 0 assert stats['active_pending_count'] == 0 def test_link_can_be_able_to_buy_without_signal_degradation_when_context_is_consistent(temp_db): _insert_recommendation( temp_db, symbol='LINK/USDT', action_status='可即刻买入', entry_plan_json='{"entry_price": 9.74, "entry_action": "可即刻买入", "entry_trigger_confirmed": true, "risk_reward_ok": true, "rr1": 1.6, "stop_loss": 9.253, "tp1": 10.5192, "opportunity_level": "short_swing", "opportunity_level_label": "短波段", "max_action": "buy_now"}', signals='["🟢 15min即刻入场信号", "1H 量价齐飞K(量3.7x)"]', current_price=9.74, entry_price=9.74, max_price=9.74, min_price=9.74, rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), ) rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False) target = next(r for r in rows if r['symbol'] == 'LINK/USDT') assert target['execution_status'] == 'buy_now' assert target['action_status'] == '可即刻买入' assert target['opportunity_level'] == 'short_swing' assert target['observe_reason'] != '信号偏弱' def test_stats_api_exposes_separate_live_and_history_sections(temp_db): _insert_recommendation( temp_db, symbol='BUY/USDT', action_status='可即刻买入', entry_plan_json='{"entry_action": "可即刻买入"}', pnl_pct=5.0, max_pnl_pct=5.0, ) _insert_recommendation( temp_db, symbol='WAIT/USDT', action_status='等回踩', entry_plan_json='{"entry_action": "等回踩"}', pnl_pct=1.0, ) _insert_recommendation( temp_db, symbol='OBS/USDT', action_status='持有', entry_plan_json='{"entry_action": "继续观察"}', pnl_pct=9.0, max_pnl_pct=9.0, ) _insert_recommendation( temp_db, symbol='INV/USDT', action_status='衰减', entry_plan_json='{"entry_action": "可即刻买入"}', pnl_pct=-6.0, max_drawdown_pct=-6.0, ) client = TestClient(web_server.app) resp = client.get('/api/stats') assert resp.status_code == 200 stats = resp.json() live = stats['live_overview'] assert live['actionable_count'] == 2 assert live['executed_trade_count'] == 0 assert live['executed_pnl_sum'] == 0 assert live['executed_avg_pnl'] == 0 assert live['actionable_pnl_sum'] == 0 assert live['actionable_avg_pnl'] == 0 assert live['actionable_success_count'] == 0 assert live['actionable_failed_count'] == 0 assert live['actionable_pending_count'] == 0 assert live['raw_active_count'] == 4 assert stats['history_overview']['success_count'] == 0 assert stats['history_overview']['failed_count'] == 0 assert 'pending_count' not in stats['history_overview'] assert stats['history_overview']['recommendation_success_rate'] == pytest.approx(0.0) def test_history_overview_only_counts_real_tp_and_stopout_results(temp_db): _insert_recommendation( temp_db, symbol='TP1/USDT', status='hit_tp1', action_status='可即刻买入', entry_plan_json='{"entry_action": "可即刻买入"}', pnl_pct=7.0, max_pnl_pct=7.0, ) _insert_recommendation( temp_db, symbol='TP2/USDT', status='hit_tp2', action_status='等回踩', entry_plan_json='{"entry_action": "等回踩"}', pnl_pct=12.0, max_pnl_pct=12.0, ) _insert_recommendation( temp_db, symbol='STOP/USDT', status='stopped_out', action_status='可即刻买入', entry_plan_json='{"entry_action": "可即刻买入"}', pnl_pct=-4.0, max_drawdown_pct=-6.0, ) _insert_recommendation( temp_db, symbol='MAXONLY/USDT', status='active', action_status='持有', entry_plan_json='{"entry_action": "继续观察"}', pnl_pct=1.5, max_pnl_pct=9.0, ) _insert_recommendation( temp_db, symbol='DDONLY/USDT', status='active', action_status='持有', entry_plan_json='{"entry_action": "继续观察"}', pnl_pct=-1.0, max_drawdown_pct=-7.0, ) client = TestClient(web_server.app) resp = client.get('/api/stats') assert resp.status_code == 200 stats = resp.json() assert stats['history_overview']['success_count'] == 2 assert stats['history_overview']['failed_count'] == 1 assert stats['history_overview']['recommendation_success_rate'] == pytest.approx(66.7) assert 'pending_count' not in stats['history_overview'] def test_history_avg_pnl_only_uses_real_tp_and_stopout_samples(temp_db): _insert_recommendation( temp_db, symbol='TP1AVG/USDT', status='hit_tp1', action_status='可即刻买入', entry_plan_json='{"entry_action": "可即刻买入"}', pnl_pct=6.0, max_pnl_pct=6.0, ) _insert_recommendation( temp_db, symbol='STOPAVG/USDT', status='stopped_out', action_status='可即刻买入', entry_plan_json='{"entry_action": "可即刻买入"}', pnl_pct=-4.0, max_drawdown_pct=-6.0, ) _insert_recommendation( temp_db, symbol='EXPIREDAVG/USDT', status='expired', action_status='持有', entry_plan_json='{"entry_action": "继续观察"}', pnl_pct=30.0, max_pnl_pct=30.0, ) client = TestClient(web_server.app) resp = client.get('/api/stats') assert resp.status_code == 200 stats = resp.json() assert stats['history_overview']['avg_pnl_pct'] == pytest.approx(1.0) def test_history_api_excludes_untriggered_observe_wait_and_invalid_losses(temp_db): _insert_recommendation( temp_db, symbol='OBSLOSS/USDT', action_status='持有', status='active', entry_plan_json='{"entry_action": "继续观察"}', pnl_pct=-4.0, max_drawdown_pct=-6.0, ) _insert_recommendation( temp_db, symbol='WAITLOSS/USDT', action_status='等回踩', status='active', entry_plan_json='{"entry_action": "等回踩", "entry_price": 95}', pnl_pct=-5.0, max_drawdown_pct=-7.0, ) _insert_recommendation( temp_db, symbol='INVLOSS/USDT', action_status='衰减', status='active', entry_plan_json='{"entry_action": "继续观察"}', pnl_pct=-6.0, max_drawdown_pct=-8.0, ) page = altcoin_db.get_all_recommendations(limit=20, decision_only=True, with_meta=True) assert page['items'] == [] assert page['total'] == 0 assert page['summary']['total'] == 0 assert page['summary']['failure_count'] == 0 assert page['summary']['total_pnl'] == 0 def test_version_filter_labels_use_plan_not_executable_to_avoid_wait_pullback_confusion(temp_db): _insert_recommendation( temp_db, symbol='BUY/USDT', action_status='可即刻买入', entry_plan_json='{"entry_action": "可即刻买入"}', strategy_version='v1.7.3', ) _insert_recommendation( temp_db, symbol='WAIT/USDT', action_status='等回踩', entry_plan_json='{"entry_action": "等回踩"}', strategy_version='v1.7.3', ) client = TestClient(web_server.app) versions = client.get('/api/versions?view=active').json() v173 = next(v for v in versions if v['version'] == 'v1.7.3') assert v173['count'] == 2 monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr(web_server, "_require_active_subscription", lambda altcoin_session='': ({"id": 1}, {"plan_code": "test"})) try: html = client.get('/app').text finally: monkeypatch.undo() # v1.7.7: 新看板没有 watch tab,使用 version dropdown assert 'version-select' in html or '全部版本' in html assert '实时推荐' in html assert '历史推荐' in html # v1.7.7: 新看板版本下拉用 (${v.count}) 格式 + 默认选中最新版本 assert 'v.count' in html