237 lines
9.1 KiB
Python
237 lines
9.1 KiB
Python
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 == []
|