130 lines
4.2 KiB
Python
130 lines
4.2 KiB
Python
from __future__ import annotations
|
|
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from src.quant_engine.exit_decisions import compute_timing_decision
|
|
|
|
|
|
class TestScoreParityV1(unittest.TestCase):
|
|
def test_pullback_wait_is_selected_for_borderline_entry_scores(self):
|
|
res = compute_timing_decision(
|
|
{
|
|
"priceStatus": "PRICE_OK",
|
|
"atr20": 100.0,
|
|
"entryModeGate": "PASS",
|
|
"entryMode": "PULLBACK",
|
|
"leaderTotal": 3,
|
|
"leaderGate": "PASS",
|
|
"flowCredit": 0.4,
|
|
"acGate": "CAUTION",
|
|
"ma20Slope": 1.0,
|
|
"disparity": 4.5,
|
|
"rsi14": 68.0,
|
|
"avgTradeValue5D": 100.0,
|
|
"spreadPct": 0.5,
|
|
}
|
|
)
|
|
self.assertEqual(res["action"], "BUY_PULLBACK_WAIT")
|
|
self.assertGreaterEqual(res["entry_score"], 60)
|
|
self.assertLess(res["entry_score"], 100)
|
|
|
|
def test_entry_score_boosts_with_leader_flow_and_clear_gate(self):
|
|
res = compute_timing_decision(
|
|
{
|
|
"priceStatus": "PRICE_OK",
|
|
"atr20": 100.0,
|
|
"entryModeGate": "PASS",
|
|
"entryMode": "BREAKOUT",
|
|
"leaderTotal": 4,
|
|
"leaderGate": "PASS",
|
|
"flowCredit": 0.8,
|
|
"acGate": "CLEAR",
|
|
"ma20Slope": 1.0,
|
|
"disparity": 2.0,
|
|
"rsi14": 55.0,
|
|
"avgTradeValue5D": 100.0,
|
|
"spreadPct": 0.5,
|
|
}
|
|
)
|
|
self.assertEqual(res["action"], "BUY_BREAKOUT_PILOT_ONLY")
|
|
self.assertGreaterEqual(res["entry_score"], 75)
|
|
self.assertLessEqual(res["exit_score"], 20)
|
|
|
|
def test_exit_review_is_selected_before_forced_exit_threshold(self):
|
|
res = compute_timing_decision(
|
|
{
|
|
"priceStatus": "PRICE_OK",
|
|
"atr20": 100.0,
|
|
"entryModeGate": "PASS",
|
|
"entryMode": "PULLBACK",
|
|
"leaderTotal": 3,
|
|
"leaderGate": "PASS",
|
|
"flowCredit": 0.6,
|
|
"acGate": "CAUTION",
|
|
"ma20Slope": -1.0,
|
|
"disparity": 8.5,
|
|
"rsi14": 70.0,
|
|
"avgTradeValue5D": 100.0,
|
|
"spreadPct": 0.5,
|
|
"rwPartial": 2,
|
|
}
|
|
)
|
|
self.assertEqual(res["action"], "EXIT_REVIEW")
|
|
self.assertGreaterEqual(res["exit_score"], 50)
|
|
self.assertLess(res["exit_score"], 75)
|
|
|
|
def test_data_missing_short_circuits_timing_action(self):
|
|
res = compute_timing_decision(
|
|
{
|
|
"priceStatus": "PRICE_OK",
|
|
"atr20": None,
|
|
"entryModeGate": "PASS",
|
|
"entryMode": "BREAKOUT",
|
|
"leaderTotal": 4,
|
|
"leaderGate": "PASS",
|
|
"flowCredit": 0.8,
|
|
"acGate": "CLEAR",
|
|
"ma20Slope": 1.0,
|
|
"disparity": 2.0,
|
|
"rsi14": 55.0,
|
|
"avgTradeValue5D": 100.0,
|
|
"spreadPct": 0.5,
|
|
}
|
|
)
|
|
self.assertEqual(res["action"], "OBSERVE_DATA_MISSING")
|
|
|
|
def test_exit_score_dominates_with_risk_off_and_time_stop(self):
|
|
res = compute_timing_decision(
|
|
{
|
|
"priceStatus": "PRICE_OK",
|
|
"atr20": 100.0,
|
|
"entryModeGate": "PASS",
|
|
"entryMode": "PULLBACK",
|
|
"leaderTotal": 3,
|
|
"leaderGate": "PASS",
|
|
"flowCredit": 0.6,
|
|
"acGate": "BLOCK",
|
|
"ma20Slope": -1.0,
|
|
"disparity": 13.0,
|
|
"rsi14": 80.0,
|
|
"avgTradeValue5D": 100.0,
|
|
"spreadPct": 0.5,
|
|
"rwPartial": 4,
|
|
"daysToTimeStop": 3,
|
|
"profitPct": 12.0,
|
|
}
|
|
)
|
|
self.assertEqual(res["action"], "STOP_OR_TIME_EXIT_READY")
|
|
self.assertGreaterEqual(res["exit_score"], 75)
|
|
self.assertGreaterEqual(res["entry_score"], 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|