feat(kis-collection): finalize sqlite migration, add fallback resilience, and update WBS documentation

This commit is contained in:
2026-06-22 18:34:56 +09:00
parent c576138829
commit 6c549b7bdc
48 changed files with 34610 additions and 24883 deletions
+116
View File
@@ -0,0 +1,116 @@
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_sell_decision
from src.quant_engine.exit_decisions import compute_stop_action_ladder
from src.quant_engine.exit_decisions import normalize_tick
class TestPriceQtyParityV1(unittest.TestCase):
def test_tp1_price_and_qty_parity(self):
res = compute_sell_decision({"close": 10000, "profitPct": 10.0, "tp1Price": 11000})
self.assertEqual(res["action"], "TAKE_PROFIT_TIER1")
self.assertEqual(res["ratio_pct"], 25)
self.assertEqual(res["price_basis"], "TAKE_PROFIT_TIER1_PRICE")
self.assertEqual(res["limit_price"], 11000)
self.assertEqual(res["order_type"], "LIMIT_SELL")
def test_tp2_price_and_fallback_parity(self):
res_fallback = compute_sell_decision({"close": 10000, "profitPct": 50.0, "tp2Price": None})
self.assertEqual(res_fallback["price_basis"], "PRIOR_CLOSE_X_0.998")
self.assertEqual(res_fallback["action"], "PROFIT_TRIM_50")
res_tp2 = compute_sell_decision({"close": 10000, "profitPct": 50.0, "tp2Price": 12000})
self.assertEqual(res_tp2["price_basis"], "TAKE_PROFIT_TIER2_PRICE")
self.assertEqual(res_tp2["limit_price"], 12000)
def test_relative_weakness_and_time_exit_price_qty_parity(self):
res_rw = compute_sell_decision({"close": 10000, "rwPartial": 1})
self.assertEqual(res_rw["action"], "TRIM_25")
self.assertEqual(res_rw["ratio_pct"], 25)
self.assertEqual(res_rw["price_basis"], "PRIOR_CLOSE_X_0.998")
res_rw2 = compute_sell_decision({"close": 10000, "rwPartial": 2})
self.assertEqual(res_rw2["action"], "TRIM_50")
self.assertEqual(res_rw2["ratio_pct"], 50)
self.assertEqual(res_rw2["order_type"], "LIMIT_SELL")
res_time = compute_sell_decision({"close": 10000, "daysToTimeStop": 0})
self.assertEqual(res_time["action"], "TIME_EXIT_100")
self.assertEqual(res_time["ratio_pct"], 100)
self.assertEqual(res_time["price_basis"], "TIME_STOP_CLOSE_PROTECT")
res_time_trim = compute_sell_decision({"close": 10000, "daysToTimeStop": 6})
self.assertEqual(res_time_trim["action"], "TIME_TRIM_50")
self.assertEqual(res_time_trim["ratio_pct"], 50)
res_time_window = compute_sell_decision({"close": 10000, "daysToTimeStop": 7})
self.assertEqual(res_time_window["action"], "TIME_TRIM_50")
self.assertEqual(res_time_window["ratio_pct"], 50)
res_time_14 = compute_sell_decision({"close": 10000, "daysToTimeStop": 14})
self.assertEqual(res_time_14["action"], "TIME_TRIM_25")
self.assertEqual(res_time_14["ratio_pct"], 25)
res_time_15 = compute_sell_decision({"close": 10000, "daysToTimeStop": 15})
self.assertEqual(res_time_15["action"], "HOLD")
def test_stop_action_ladder_parity(self):
res = compute_stop_action_ladder({"profitPct": 10.0})
self.assertEqual(res["action"], "TAKE_PROFIT_TIER1")
self.assertEqual(res["quantity_pct"], 25)
self.assertEqual(res["priority"], 5)
res_review = compute_stop_action_ladder({"profitPct": 9.99, "daysToTimeStop": 1})
self.assertEqual(res_review["action"], "REVIEW_HUMAN")
self.assertEqual(res_review["quantity_pct"], 0)
res_exit = compute_stop_action_ladder({"timingAction": "STOP_OR_TIME_EXIT_READY"})
self.assertEqual(res_exit["action"], "EXIT_100")
self.assertEqual(res_exit["quantity_pct"], 100)
res_risk_off = compute_stop_action_ladder({"REGIME_PRELIM": "RISK_OFF"})
self.assertEqual(res_risk_off["action"], "REGIME_TRIM_50")
self.assertEqual(res_risk_off["quantity_pct"], 50)
self.assertEqual(res_risk_off["priority"], 2)
res_rw2b = compute_stop_action_ladder({"rw_partial_excluding_rw2b": 1, "RW2b_5d_rapid_weakness": True})
self.assertEqual(res_rw2b["action"], "TRIM_50")
self.assertEqual(res_rw2b["priority"], 2.5)
res_trailing = compute_stop_action_ladder({"trailingStopBreach": True})
self.assertEqual(res_trailing["action"], "TRIM_50")
self.assertEqual(res_trailing["priority"], 4)
def test_fallback_stop_price_is_tick_independent(self):
res = compute_sell_decision({"close": 10000, "profitPct": 20.0, "tp1Price": None})
self.assertEqual(res["action"], "PROFIT_TRIM_25")
self.assertEqual(res["price_basis"], "PRIOR_CLOSE_X_0.998")
self.assertEqual(res["validation"], "SIGNAL_CONFIRMED")
res_tp2 = compute_sell_decision({"close": 10000, "profitPct": 50.0, "tp2Price": None})
self.assertEqual(res_tp2["action"], "PROFIT_TRIM_50")
self.assertEqual(res_tp2["price_basis"], "PRIOR_CLOSE_X_0.998")
res_tp2_fallback = compute_sell_decision({"close": 10000, "profitPct": 50.0, "tp2Price": None, "tp1Price": None})
self.assertEqual(res_tp2_fallback["action"], "PROFIT_TRIM_50")
self.assertEqual(res_tp2_fallback["price_source"], "CLOSE_PROFIT_PROTECT")
def test_tick_normalization_boundaries(self):
self.assertEqual(normalize_tick(1999.9), 1999)
self.assertEqual(normalize_tick(2000.0), 2000)
self.assertEqual(normalize_tick(5001.0), 5000)
self.assertEqual(normalize_tick(20000.0), 20000)
self.assertEqual(normalize_tick(50000.0), 50000)
self.assertEqual(normalize_tick(200000.0), 200000)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,164 @@
from __future__ import annotations
import sys
import unittest
import math
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
# Python Port simulation of GAS runRouteFlow_ logic
def run_route_flow_simulation(
h: dict,
df: dict,
h1: dict
) -> tuple[str, list[dict]]:
base_fa = str(df.get("finalAction") or "INSUFFICIENT_DATA").upper()
final_fa = base_fa
trace = []
# ── Gate 1a: Stop_Price Breach 감지
if h.get("stopBreach"):
if h1.get("intradayLock"):
final_fa = "TRIM_50"
trace.append({"gate": "STOP_BREACH", "result": "DOWNGRADE_P4", "reason": "장중(P4): stop_breach→TRIM_50 완화"})
else:
final_fa = "EXIT_100"
trace.append({"gate": "STOP_BREACH", "result": "FORCE_EXIT", "reason": f"close({h.get('close')})<=stop({h.get('stop_price')})"})
else:
trace.append({"gate": "STOP_BREACH", "result": "PASS", "reason": "no_breach"})
# ── Gate 1a-bis: Relative Stop
if final_fa != "EXIT_100":
rs_ret20d = df.get("ret20d")
rs_atr20 = df.get("atr20")
rs_close = h.get("close") or df.get("close") or 0.0
rs_pft = h.get("profitPct")
rs_hdays = h.get("holdingDays") or 0
rs_kospi = h1.get("kospiRet20d") or 0.0
if rs_ret20d is not None and rs_atr20 is not None and rs_close > 0:
rs_beta = min(3.0, max(0.3, rs_ret20d / rs_kospi)) if abs(rs_kospi) >= 0.5 else 1.0
rs_excess = rs_ret20d - rs_beta * rs_kospi
rs_sigma = (rs_atr20 / rs_close * 100.0) * math.sqrt(20)
rs_thresh = -2.0 * rs_sigma
rs_abs_fl = rs_pft is not None and rs_pft < -20.0
rs_time_st = rs_hdays >= 60 and rs_excess < 0
if rs_abs_fl or (rs_excess < rs_thresh) or rs_time_st:
trace.append({"gate": "RELATIVE_STOP", "result": "TRIM_50", "reason": "relative_stop_breached"})
if final_fa == "HOLD" or "BUY" in final_fa:
final_fa = "TRIM_50"
else:
trace.append({"gate": "RELATIVE_STOP", "result": "PASS", "reason": "relative_stop_passed"})
else:
trace.append({"gate": "RELATIVE_STOP", "result": "SKIP", "reason": "insufficient_data"})
# ── Gate 1b: Intraday_Lock
if h1.get("intradayLock"):
intraday_blocked_keywords = ["BUY", "BUY_LADDER", "EXIT_100"]
intraday_allowed_actions = ["WATCH", "TRIM_50", "HOLD", "TRIM_33"]
if any(keyword in final_fa for keyword in intraday_blocked_keywords):
downgraded = "WATCH" if "BUY" in final_fa else "TRIM_50"
trace.append({"gate": "INTRADAY_LOCK", "result": "DOWNGRADE", "reason": f"P4: {final_fa}{downgraded}"})
final_fa = downgraded
if final_fa not in intraday_allowed_actions:
trace.append({"gate": "INTRADAY_LOCK", "result": "FORCE_WATCH", "reason": f"P4_ALLOWLIST: {final_fa} not allowed→WATCH"})
final_fa = "WATCH"
else:
trace.append({"gate": "INTRADAY_LOCK", "result": "PASS", "reason": "action_in_allowlist"})
else:
trace.append({"gate": "INTRADAY_LOCK", "result": "INACTIVE", "reason": "post_market"})
# ── Gate 1c: Heat Gate
if h1.get("heatGate") == "BLOCK_NEW_BUY" and "BUY" in final_fa:
trace.append({"gate": "HEAT_GATE", "result": "BLOCK_BUY", "reason": "total_heat>=10%: BUY→WATCH"})
final_fa = "WATCH"
elif h1.get("heatGate") == "HALVE_NEW_BUY_QUANTITY" and "BUY" in final_fa:
trace.append({"gate": "HEAT_GATE", "result": "HALVE_QTY", "reason": "total_heat>=7%: Qty halved"})
else:
trace.append({"gate": "HEAT_GATE", "result": "PASS", "reason": "heat_gate_pass"})
# ── Gate 1d: Mean Reversion Gate
if "BUY" in final_fa:
mrg_close = df.get("close") or 0.0
mrg_ma20 = df.get("ma20") or 0.0
if mrg_close > 0.0 and mrg_ma20 > 0.0:
dev_ratio = mrg_close / mrg_ma20
if dev_ratio >= 1.15:
trace.append({"gate": "MEAN_REVERSION_GATE", "result": "BUY_HARD_BLOCK", "reason": f"deviation_ratio={dev_ratio:.2f}>=1.15"})
final_fa = "WATCH"
else:
trace.append({"gate": "MEAN_REVERSION_GATE", "result": "PASS", "reason": "mean_reversion_pass"})
# ── Gate 2: Cash Floor
if h1.get("cashFloorStatus") == "HARD_BLOCK" and "BUY" in final_fa:
trace.append({"gate": "CASH_FLOOR", "result": "HARD_BLOCK", "reason": "immediate_cash<floor: BUY→WATCH"})
final_fa = "WATCH"
elif h1.get("cashFloorStatus") == "TRIM_REQUIRED" and "BUY" in final_fa:
trace.append({"gate": "CASH_FLOOR", "result": "BUY_BLOCKED", "reason": "TRIM_REQUIRED: BUY→WATCH"})
final_fa = "WATCH"
elif h1.get("cashFloorStatus") == "TRIM_REQUIRED" and final_fa == "HOLD":
trace.append({"gate": "CASH_FLOOR", "result": "NUDGE_TRIM", "reason": "TRIM_REQUIRED: HOLD→TRIM_33"})
final_fa = "TRIM_33"
else:
trace.append({"gate": "CASH_FLOOR", "result": "PASS", "reason": "cash_floor_pass"})
return final_fa, trace
class TestRoutingDecisionParity(unittest.TestCase):
def test_stop_breach_routing_gating(self):
# Scenario 1: Stop breach during post-market (no intraday lock)
# Expected: FORCE_EXIT -> EXIT_100
h_1 = {"stopBreach": True, "close": 9000, "stop_price": 10000}
df_1 = {"finalAction": "HOLD"}
h1_1 = {"intradayLock": False}
final_fa, trace = run_route_flow_simulation(h_1, df_1, h1_1)
self.assertEqual(final_fa, "EXIT_100")
self.assertEqual(trace[0]["result"], "FORCE_EXIT")
# Scenario 2: Stop breach during intraday market (intraday lock active)
# Expected: DOWNGRADE_P4 -> TRIM_50
h1_2 = {"intradayLock": True}
final_fa_2, trace_2 = run_route_flow_simulation(h_1, df_1, h1_2)
self.assertEqual(final_fa_2, "TRIM_50")
self.assertEqual(trace_2[0]["result"], "DOWNGRADE_P4")
def test_heat_gate_and_mr_gating(self):
# Scenario 1: Heat gate BLOCK_NEW_BUY overrides BUY_LADDER
h_3 = {"stopBreach": False}
df_3 = {"finalAction": "BUY_LADDER", "close": 10000, "ma20": 10000}
h1_3 = {"intradayLock": False, "heatGate": "BLOCK_NEW_BUY"}
final_fa, trace = run_route_flow_simulation(h_3, df_3, h1_3)
self.assertEqual(final_fa, "WATCH")
# Scenario 2: Mean Reversion Gate (MRG001) close/ma20 = 12000/10000 = 1.20 >= 1.15
df_4 = {"finalAction": "BUY_LADDER", "close": 12000, "ma20": 10000}
h1_4 = {"intradayLock": False, "heatGate": "PASS"}
final_fa_4, trace_4 = run_route_flow_simulation(h_3, df_4, h1_4)
self.assertEqual(final_fa_4, "WATCH")
self.assertTrue(any(t["gate"] == "MEAN_REVERSION_GATE" and t["result"] == "BUY_HARD_BLOCK" for t in trace_4))
def test_cash_floor_routes_hold_to_trim_and_preserves_exit(self):
h_5 = {"stopBreach": False}
df_5 = {"finalAction": "HOLD"}
h1_5 = {"intradayLock": False, "cashFloorStatus": "TRIM_REQUIRED"}
final_fa_5, trace_5 = run_route_flow_simulation(h_5, df_5, h1_5)
self.assertEqual(final_fa_5, "TRIM_33")
self.assertTrue(any(t["gate"] == "CASH_FLOOR" and t["result"] == "NUDGE_TRIM" for t in trace_5))
df_6 = {"finalAction": "EXIT_REVIEW"}
final_fa_6, trace_6 = run_route_flow_simulation(h_5, df_6, {"intradayLock": False, "cashFloorStatus": "HARD_BLOCK"})
self.assertEqual(final_fa_6, "EXIT_REVIEW")
self.assertTrue(any(t["gate"] == "CASH_FLOOR" and t["result"] == "PASS" for t in trace_6))
if __name__ == "__main__":
unittest.main()
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import math
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))
def run_route_flow_simulation(h: dict, df: dict, h1: dict) -> tuple[str, list[dict]]:
base_fa = str(df.get("finalAction") or "INSUFFICIENT_DATA").upper()
final_fa = base_fa
trace = []
if h.get("stopBreach"):
if h1.get("intradayLock"):
final_fa = "TRIM_50"
trace.append({"gate": "STOP_BREACH", "result": "DOWNGRADE_P4"})
else:
final_fa = "EXIT_100"
trace.append({"gate": "STOP_BREACH", "result": "FORCE_EXIT"})
else:
trace.append({"gate": "STOP_BREACH", "result": "PASS"})
if final_fa != "EXIT_100":
rs_ret20d = df.get("ret20d")
rs_atr20 = df.get("atr20")
rs_close = h.get("close") or df.get("close") or 0.0
rs_pft = h.get("profitPct")
rs_hdays = h.get("holdingDays") or 0
rs_kospi = h1.get("kospiRet20d") or 0.0
if rs_ret20d is not None and rs_atr20 is not None and rs_close > 0:
rs_beta = min(3.0, max(0.3, rs_ret20d / rs_kospi)) if abs(rs_kospi) >= 0.5 else 1.0
rs_excess = rs_ret20d - rs_beta * rs_kospi
rs_sigma = (rs_atr20 / rs_close * 100.0) * math.sqrt(20)
rs_thresh = -2.0 * rs_sigma
rs_abs_fl = rs_pft is not None and rs_pft < -20.0
rs_time_st = rs_hdays >= 60 and rs_excess < 0
if rs_abs_fl or (rs_excess < rs_thresh) or rs_time_st:
trace.append({"gate": "RELATIVE_STOP", "result": "TRIM_50"})
if final_fa == "HOLD" or "BUY" in final_fa:
final_fa = "TRIM_50"
else:
trace.append({"gate": "RELATIVE_STOP", "result": "PASS"})
else:
trace.append({"gate": "RELATIVE_STOP", "result": "SKIP"})
if h1.get("intradayLock"):
intraday_blocked_keywords = ["BUY", "BUY_LADDER", "EXIT_100"]
intraday_allowed_actions = ["WATCH", "TRIM_50", "HOLD", "TRIM_33"]
if any(keyword in final_fa for keyword in intraday_blocked_keywords):
final_fa = "WATCH" if "BUY" in final_fa else "TRIM_50"
if final_fa not in intraday_allowed_actions:
final_fa = "WATCH"
if h1.get("heatGate") == "BLOCK_NEW_BUY" and "BUY" in final_fa:
final_fa = "WATCH"
elif h1.get("heatGate") == "HALVE_NEW_BUY_QUANTITY" and "BUY" in final_fa:
pass
if "BUY" in final_fa:
mrg_close = df.get("close") or 0.0
mrg_ma20 = df.get("ma20") or 0.0
if mrg_close > 0.0 and mrg_ma20 > 0.0 and (mrg_close / mrg_ma20) >= 1.15:
final_fa = "WATCH"
if h1.get("cashFloorStatus") == "HARD_BLOCK" and "BUY" in final_fa:
final_fa = "WATCH"
elif h1.get("cashFloorStatus") == "TRIM_REQUIRED" and "BUY" in final_fa:
final_fa = "WATCH"
elif h1.get("cashFloorStatus") == "TRIM_REQUIRED" and final_fa == "HOLD":
final_fa = "TRIM_33"
return final_fa, trace
class TestRoutingGateParityV1(unittest.TestCase):
def test_stop_breach_routes_exit_or_trim(self):
final_fa, trace = run_route_flow_simulation({"stopBreach": True}, {"finalAction": "HOLD"}, {"intradayLock": False})
self.assertEqual(final_fa, "EXIT_100")
self.assertEqual(trace[0]["result"], "FORCE_EXIT")
final_fa_lock, trace_lock = run_route_flow_simulation({"stopBreach": True}, {"finalAction": "HOLD"}, {"intradayLock": True})
self.assertEqual(final_fa_lock, "TRIM_50")
self.assertEqual(trace_lock[0]["result"], "DOWNGRADE_P4")
def test_heat_gate_blocks_buy_and_allows_half_quantity_trace(self):
final_fa_buy, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "BUY_LADDER", "close": 12000, "ma20": 10000}, {"heatGate": "PASS"})
self.assertEqual(final_fa_buy, "WATCH")
final_fa_half, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "BUY_LADDER", "close": 11000, "ma20": 10000}, {"heatGate": "HALVE_NEW_BUY_QUANTITY"})
self.assertEqual(final_fa_half, "BUY_LADDER")
def test_cash_floor_routes_hold_and_buy_separately(self):
final_fa, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "HOLD"}, {"cashFloorStatus": "TRIM_REQUIRED"})
self.assertEqual(final_fa, "TRIM_33")
final_fa_cash_block, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "BUY_LADDER"}, {"cashFloorStatus": "HARD_BLOCK"})
self.assertEqual(final_fa_cash_block, "WATCH")
final_fa_trim_buy, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "BUY_LADDER"}, {"cashFloorStatus": "TRIM_REQUIRED"})
self.assertEqual(final_fa_trim_buy, "WATCH")
def test_relative_stop_and_mean_reversion(self):
final_fa_rw, trace_rw = run_route_flow_simulation(
{"stopBreach": False, "profitPct": -25.0},
{"finalAction": "HOLD", "ret20d": -5.0, "atr20": 100.0, "close": 10000.0},
{"intradayLock": False, "kospiRet20d": 1.0},
)
self.assertEqual(final_fa_rw, "TRIM_50")
self.assertTrue(any(item["gate"] == "RELATIVE_STOP" for item in trace_rw))
final_fa_mr, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "BUY_LADDER", "close": 12000, "ma20": 10000}, {"heatGate": "PASS", "cashFloorStatus": "PASS"})
self.assertEqual(final_fa_mr, "WATCH")
def test_cash_floor_hard_block_preserves_non_buy_actions(self):
final_fa, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "EXIT_REVIEW"}, {"cashFloorStatus": "HARD_BLOCK"})
self.assertEqual(final_fa, "EXIT_REVIEW")
final_fa_hard_hold, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "HOLD"}, {"cashFloorStatus": "HARD_BLOCK"})
self.assertEqual(final_fa_hard_hold, "HOLD")
final_fa_trim_hold, _ = run_route_flow_simulation({"stopBreach": False}, {"finalAction": "HOLD"}, {"cashFloorStatus": "TRIM_REQUIRED", "heatGate": "PASS"})
self.assertEqual(final_fa_trim_hold, "TRIM_33")
if __name__ == "__main__":
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
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()