# -*- coding: utf-8 -*-
"""
第五批模块单测 (实机运行, 零外部依赖)
======================================
运行: 在 tradingSystem 仓库根目录执行  python scripts/test_batch5_units.py
覆盖: signal_rules 两条信号流的解析 (含两条流置信度尺度不同这个坑) 与消化口径。
"""
import json
import os
import sys
import traceback

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from app.core import signal_rules as sr                                        # noqa: E402

RESULTS = []


def case(name):
    def deco(fn):
        RESULTS.append((name, fn))
        return fn
    return deco


PRM = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
POS = {"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 6000, "price": 10.0}


# ================================================================ 解析
@case("解析·盘中流 (db2): 扁平字段 + component_scores 内嵌 JSON, 置信度本就是 0~1")
def _():
    s = sr.parse_intraday({"ts_code": "600000.SH", "action": "buy", "confidence": "0.83",
                           "component_scores": json.dumps({"minute_qrs": 2.4}),
                           "suggested_price": "10.25"}, msg_id="1-1")
    assert s["source"] == sr.SRC_INTRADAY and s["ts_code"] == "600000.SH"
    assert s["action"] == "BUY" and abs(s["confidence"] - 0.83) < 1e-9
    assert abs(s["minute_qrs"] - 2.4) < 1e-9 and s["suggested_price"] == 10.25
    # component_scores 给成 dict 或坏 JSON 都不能炸
    assert sr.parse_intraday({"component_scores": {"minute_qrs": 3}})["minute_qrs"] == 3.0
    assert sr.parse_intraday({"component_scores": "{坏"})["minute_qrs"] == 0.0


@case("解析·风控流 (db3): 外层套 data JSON, 置信度是 0~100 要归一")
def _():
    inner = {"ts_code": "600000.SH", "action": "SELL", "confidence": 88,
             "dominant_signal": "破位", "llm_reason": "跌破关键支撑且量能背离"}
    s = sr.parse_risk_sell({"data": json.dumps(inner, ensure_ascii=False)}, msg_id="2-1")
    assert s["source"] == sr.SRC_RISK_SELL and s["action"] == "SELL"
    assert abs(s["confidence"] - 0.88) < 1e-9, s          # 88 → 0.88, 两条流尺度不同
    assert s["dominant_signal"] == "破位" and "支撑" in s["reason"]
    # 已经是 0~1 的也不会被再除一次
    assert abs(sr.parse_risk_sell({"data": json.dumps({"ts_code": "x", "action": "SELL",
                                                       "confidence": 0.9})})["confidence"]
               - 0.9) < 1e-9
    # 坏 JSON → 明确标记, 不抛异常
    bad = sr.parse_risk_sell({"data": "{不是JSON"})
    assert bad["ts_code"] == "" and "解析失败" in bad["parse_error"]


# ================================================================ 消化
@case("消化·高置信风控卖出 → 直接清仓 (减持不设确认门槛)")
def _():
    s = sr.parse_risk_sell({"data": json.dumps({"ts_code": "600000.SH", "action": "SELL",
                                                "confidence": 90, "llm_reason": "逻辑走坏"})})
    d = sr.digest(s, POS, PRM)
    assert d["action"] == sr.ACT_EXIT and d["qty"] == 6000, d
    assert "清仓" in d["reason"] and "逻辑走坏" in d["reason"], d
    assert d["hard_numbers"]["confidence"] == 0.9


@case("消化·中等置信 → 落提议待确认 (按比例减)")
def _():
    s = sr.parse_risk_sell({"data": json.dumps({"ts_code": "600000.SH", "action": "SELL",
                                                "confidence": 80})})
    d = sr.digest(s, POS, PRM)
    assert d["action"] == sr.ACT_PROPOSE and d["qty"] == 2000, d   # 6000 的 1/3, 整百
    assert "待确认" in d["reason"]


@case("消化·低置信 / 未持有 / 缺代码 一律不动")
def _():
    low = sr.parse_risk_sell({"data": json.dumps({"ts_code": "600000.SH", "action": "SELL",
                                                  "confidence": 60})})
    assert sr.digest(low, POS, PRM)["action"] == sr.ACT_IGNORE
    hi = sr.parse_risk_sell({"data": json.dumps({"ts_code": "600000.SH", "action": "SELL",
                                                 "confidence": 95})})
    assert sr.digest(hi, {"total_qty": 0}, PRM)["action"] == sr.ACT_IGNORE
    assert sr.digest(hi, None, PRM)["action"] == sr.ACT_IGNORE
    assert sr.digest({"ts_code": "", "action": "SELL", "confidence": 0.95},
                     POS, PRM)["action"] == sr.ACT_IGNORE


@case("消化·BUY/HOLD 信号只留痕不买 (买什么买多少由 PMS 自己决定)")
def _():
    # 2026-08-06: BUY 从 ACT_RECORD 改判 ACT_NOTE_BUY。**「不买」这条口径一个字没变** ——
    # 变的只是留痕范围: RECORD 只给持仓票写账本, 而未持仓的票正是新建仓关心的那一批,
    # 从前它们连一行痕迹都没有。NOTE_BUY 持没持仓都写。HOLD 等其余类型仍走 RECORD。
    b = sr.parse_intraday({"ts_code": "600000.SH", "action": "BUY", "confidence": "0.95"})
    d = sr.digest(b, POS, PRM)
    assert d["action"] == sr.ACT_NOTE_BUY and d["qty"] == 0, d
    assert "只留痕" in d["reason"] and "买不买由动作引擎" in d["reason"], d
    h = sr.parse_intraday({"ts_code": "600000.SH", "action": "HOLD", "confidence": "0.99"})
    assert sr.digest(h, POS, PRM)["action"] == sr.ACT_RECORD
    assert "不据此买入" in sr.digest(h, POS, PRM)["reason"]


@case("消化·盘中流里的 SELL 也照样消化 (两条流同一套口径)")
def _():
    s = sr.parse_intraday({"ts_code": "600000.SH", "action": "SELL", "confidence": "0.92"})
    d = sr.digest(s, POS, PRM)
    assert d["action"] == sr.ACT_EXIT and d["qty"] == 6000, d


@case("去重键·同票同源同动作当日只算一次")
def _():
    s = {"source": sr.SRC_RISK_SELL, "ts_code": "600000.SH", "action": "SELL"}
    k1 = sr.dedup_key(s, 20260728)
    assert k1 == sr.dedup_key(dict(s), 20260728)
    assert k1 != sr.dedup_key(s, 20260729)
    assert k1 != sr.dedup_key({**s, "source": sr.SRC_INTRADAY}, 20260728)


# ---------------------------------------------------------------- runner
def main():
    passed, failed = 0, 0
    for name, fn in RESULTS:
        try:
            fn()
            print(f"  PASS  {name}")
            passed += 1
        except Exception:
            print(f"  FAIL  {name}")
            traceback.print_exc()
            failed += 1
    print("-" * 60)
    if failed:
        print(f"FAILED: {failed} / {passed + failed}")
        sys.exit(1)
    print(f"ALL PASS ({passed} cases)")


if __name__ == "__main__":
    main()
