343 lines
11 KiB
Python
343 lines
11 KiB
Python
import os
|
||
import sys
|
||
|
||
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='2026-04-30T10:00:00',
|
||
rec_state='加速',
|
||
rec_score=8,
|
||
entry_price=100.0,
|
||
stop_loss=95.0,
|
||
tp1=110.0,
|
||
tp2=118.0,
|
||
sector='AI',
|
||
signals='[]',
|
||
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='2026-04-30T10:05:00',
|
||
entry_plan_json='{}',
|
||
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'}
|
||
|
||
|
||
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'] == 5.0
|
||
assert stats['active_avg_pnl'] == 5.0
|
||
assert stats['active_success_count'] == 1
|
||
assert stats['active_failed_count'] == 0
|
||
assert stats['active_pending_count'] == 0
|
||
|
||
|
||
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'] == 1
|
||
assert live['executed_pnl_sum'] == 5.0
|
||
assert live['executed_avg_pnl'] == 5.0
|
||
assert live['actionable_pnl_sum'] == 5.0
|
||
assert live['actionable_avg_pnl'] == 5.0
|
||
assert live['actionable_success_count'] == 1
|
||
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_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
|