import json import os import sqlite3 import sys from datetime import datetime, timedelta import pytest 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.config import config_loader from app.db import altcoin_db from app.services import review_engine @pytest.fixture def temp_review_env(monkeypatch, tmp_path): db_path = tmp_path / "review.db" rules_path = tmp_path / "rules.yaml" monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path)) monkeypatch.setattr(config_loader, "RULES_PATH", str(rules_path)) monkeypatch.setattr(review_engine, "get_conn", altcoin_db.get_conn) config_loader._cache = None config_loader._cache_mtime = None rules_path.write_text( """ strategy: mode: long_only direction: 多头启动 confirm: {} screener: {} tracker: {} signal_weights: {} review: hit_threshold_pct: 5.0 fail_threshold_pct: -3.0 missed_explosion_pct: 20.0 reverse_analysis: {} learned_rules: [] meta: strategy_version: v-test """.strip(), encoding="utf-8", ) altcoin_db.init_db() return db_path def _insert_rec(db_path, **kwargs): defaults = dict( symbol="REV/USDT", rec_time="2026-05-10T00:00:00", rec_state="爆发", rec_score=80, entry_price=100.0, stop_loss=95.0, tp1=110.0, tp2=120.0, sector="", signals=json.dumps(["1H 量价齐飞K(量3.7x)", "15min 回踩确认"], ensure_ascii=False), signal_codes_json=json.dumps(["vp_fly_1h_current", "pullback_15m_confirm"], ensure_ascii=False), signal_labels_json=json.dumps(["1H 量价齐飞K(量3.7x)", "15min 回踩确认"], ensure_ascii=False), is_meme=0, status="active", current_price=80.0, max_price=160.0, min_price=70.0, pnl_pct=-20.0, max_pnl_pct=60.0, max_drawdown_pct=-30.0, hit_tp1_time="", hit_tp2_time="", stopped_out_time="", expired_time="", last_track_time="2026-05-13T00:00:00", entry_plan_json=json.dumps({"entry_action": "可即刻买入", "entry_price": 100.0}, ensure_ascii=False), action_status="可即刻买入", execution_status="buy_now", display_bucket="realtime", lifecycle_state="buyable", entry_triggered=1, direction="多头启动", ) defaults.update(kwargs) conn = sqlite3.connect(db_path) cols = ",".join(defaults.keys()) placeholders = ",".join(["?"] * len(defaults)) cur = conn.execute(f"INSERT INTO recommendation ({cols}) VALUES ({placeholders})", tuple(defaults.values())) conn.commit() conn.close() return cur.lastrowid def test_reviewable_recommendations_exclude_untriggered_watch_pool(temp_review_env): db_path = str(temp_review_env) _insert_rec( db_path, symbol="WAIT/USDT", action_status="等回踩", execution_status="wait_pullback", display_bucket="watch_pool", entry_triggered=0, ) _insert_rec(db_path, symbol="BUY/USDT") rows = review_engine._get_reviewable_recommendations(datetime.fromisoformat("2026-05-12T01:00:00")) assert [row["symbol"] for row in rows] == ["BUY/USDT"] def test_review_uses_48h_price_tracking_window_not_current_price(temp_review_env): db_path = str(temp_review_env) rec_id = _insert_rec(db_path) conn = sqlite3.connect(db_path) for hours, price in [(1, 101.0), (5, 106.0), (47, 103.0), (60, 150.0)]: ts = datetime.fromisoformat("2026-05-10T00:00:00") + timedelta(hours=hours) conn.execute( "INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct) VALUES (?, 'REV/USDT', ?, ?, 0)", (rec_id, ts.isoformat(), price), ) conn.commit() conn.row_factory = sqlite3.Row rec = dict(conn.execute("SELECT * FROM recommendation WHERE id=?", (rec_id,)).fetchone()) conn.close() review = review_engine.review_recommendation(rec) assert review["outcome"] == "爆发" assert review["pnl_48h"] == 3.0 assert review["max_pnl_48h"] == 6.0 assert review["window_metrics"]["source"] == "price_tracking" def test_review_updates_signal_performance_by_code_not_label(temp_review_env): db_path = str(temp_review_env) rec_id = _insert_rec(db_path) conn = sqlite3.connect(db_path) conn.execute( "INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct) VALUES (?, 'REV/USDT', '2026-05-10T01:00:00', 106.0, 6.0)", (rec_id,), ) conn.commit() conn.row_factory = sqlite3.Row rec = dict(conn.execute("SELECT * FROM recommendation WHERE id=?", (rec_id,)).fetchone()) conn.close() review_engine.review_recommendation(rec) conn = sqlite3.connect(db_path) rows = conn.execute("SELECT signal_type,total_count,hit_count FROM signal_performance ORDER BY signal_type").fetchall() conn.close() stats = {row[0]: (row[1], row[2]) for row in rows} assert stats["vp_fly_1h_current"] == (1, 1) assert "1H 量价齐飞K(量3.7x)" not in stats def test_daily_factor_weight_governance_promotes_and_eliminates(monkeypatch, temp_review_env): changes = [] monkeypatch.setattr(review_engine, "update_signal_weight", lambda signal, weight: changes.append((signal, weight))) monkeypatch.setattr(review_engine, "get_signal_weights", lambda: { "good_factor": {"category": "前瞻", "total_count": 12, "hit_rate": 70, "avg_pnl": 4.2, "weight": 1.0}, "bad_factor": {"category": "前瞻", "total_count": 20, "hit_rate": 5, "avg_pnl": -2.0, "weight": 1.2}, "thin_factor": {"category": "PA", "total_count": 2, "hit_rate": 100, "avg_pnl": 6.0, "weight": 1.0}, }) result = review_engine._apply_daily_factor_weight_governance() assert ("good_factor", 2.8) in changes assert ("bad_factor", 0.0) in changes assert all(signal != "thin_factor" for signal, _ in changes) actions = {item["signal"]: item["action"] for item in result} assert actions["good_factor"] == "升权" assert actions["bad_factor"] == "淘汰" def test_review_without_tracking_is_not_counted_as_signal_performance(temp_review_env): db_path = str(temp_review_env) rec_id = _insert_rec(db_path) conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row rec = dict(conn.execute("SELECT * FROM recommendation WHERE id=?", (rec_id,)).fetchone()) conn.close() review = review_engine.review_recommendation(rec) assert review["outcome"] == "样本不足" assert review["skipped"] is True assert review["window_metrics"]["source"] == "insufficient_tracking" conn = sqlite3.connect(db_path) rows = conn.execute("SELECT signal_type,total_count FROM signal_performance").fetchall() review_rows = conn.execute("SELECT outcome, lesson FROM review_log WHERE rec_id=?", (rec_id,)).fetchall() conn.close() assert rows == [] assert review_rows[0][0] == "样本不足" assert "不进入推荐绩效/信号权重" in review_rows[0][1] def test_run_review_does_not_double_upsert_reverse_rules(monkeypatch, temp_review_env): calls = [] monkeypatch.setattr(review_engine, "_get_reviewable_recommendations", lambda now=None: []) monkeypatch.setattr(review_engine, "adjust_signal_weights", lambda: []) monkeypatch.setattr(review_engine, "_deprecate_low_performance_signals", lambda: []) monkeypatch.setattr(review_engine, "scan_missed_explosions", lambda: []) monkeypatch.setattr(review_engine, "_extract_rules_from_review", lambda: []) monkeypatch.setattr(review_engine, "get_signal_weights", lambda: {}) monkeypatch.setattr(review_engine, "_compute_effect_summary", lambda now, lookback_days=7: {"hit_rate_pct": 0, "avg_pnl": 0}) monkeypatch.setattr(review_engine, "_scan_stable_fiat_pollution", lambda now, lookback_days=7: {}) monkeypatch.setattr(review_engine, "_build_dual_attribution", lambda results, effect_summary: { "success_analysis": {"sample_count": 0, "top_success_factors": []}, "failure_analysis": {"sample_count": 0, "failure_types": []}, "candidate_rules": [], "release_decision": "hold", "release_reason": "样本不足", "confidence_level": "low", "promotion_state": "research_only", }) monkeypatch.setattr(review_engine, "refresh_strategy_candidate_performance", lambda: []) monkeypatch.setattr(review_engine, "_release_candidate_rules_if_ready", lambda dual, effect: { "released": False, "release_decision": "hold", "release_reason": "样本不足", "released_rules": [], "new_version": "", }) monkeypatch.setattr(review_engine.reverse_analysis, "run_reverse_analysis", lambda: { "new_rules": [{"candidate_id": 77, "description": "逆向候选", "type": "bonus", "conditions": {"has_ignition_point": True}}], }) monkeypatch.setattr(review_engine, "upsert_strategy_rule_candidate", lambda **kwargs: calls.append(kwargs) or 88) result = review_engine.run_review(push_enabled=False) assert result["reverse_analysis"]["new_rules"][0]["candidate_id"] == 77 assert calls == []