Merge branch 'codex/roadmap-publish' of http://192.168.123.100:8418/KimJaeHyun/myfinance into codex/roadmap-publish
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
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 tools.build_distribution_risk_score_v2 import calculate_distribution_risk
|
||||
|
||||
|
||||
class TestDistributionRiskParity(unittest.TestCase):
|
||||
|
||||
def test_distribution_risk_parity_scenarios(self):
|
||||
# Scenario 1: Smart Money Outflow only
|
||||
row_1 = {
|
||||
"close": 10000,
|
||||
"ma20": 10000,
|
||||
"frg_5d": -100,
|
||||
"inst_5d": -200,
|
||||
}
|
||||
res_1 = calculate_distribution_risk(row_1, kospi_ret_5d=0.0)
|
||||
self.assertEqual(res_1["distribution_risk_score"], 30)
|
||||
self.assertIn("smart_money_outflow", res_1["reason_codes"])
|
||||
self.assertEqual(res_1["anti_distribution_state"], "PASS")
|
||||
|
||||
# Scenario 2: High upper wick and low flow credit under priceAboveMa20
|
||||
row_2 = {
|
||||
"close": 12000,
|
||||
"ma20": 10000, # priceAboveMa20 = True
|
||||
"high": 15000,
|
||||
"low": 10000,
|
||||
# upperWickRatio = (15000-12000)/5000 = 3000/5000 = 0.60 >= 0.45
|
||||
"flow_credit": 0.35, # flow_credit < 0.40
|
||||
}
|
||||
res_2 = calculate_distribution_risk(row_2, kospi_ret_5d=0.0)
|
||||
self.assertIn("upper_wick_distribution", res_2["reason_codes"])
|
||||
self.assertIn("flow_credit_low", res_2["reason_codes"])
|
||||
# score = 15 (upper wick) + 20 (flow credit low) = 35
|
||||
self.assertEqual(res_2["distribution_risk_score"], 35)
|
||||
|
||||
# Scenario 3: Trim Review threshold (score >= 55)
|
||||
row_3 = {
|
||||
"close": 10000,
|
||||
"ma20": 10000,
|
||||
"frg_5d": -100,
|
||||
"inst_5d": -200, # +30
|
||||
"flow_credit": 0.30, # +20
|
||||
"volume": 70,
|
||||
"avg_volume_5d": 100, # volume < 80% of avg_vol_5d -> +20
|
||||
}
|
||||
res_3 = calculate_distribution_risk(row_3, kospi_ret_5d=0.0)
|
||||
# score = 30 + 20 + 20 = 70 (BLOCK_BUY)
|
||||
self.assertEqual(res_3["distribution_risk_score"], 70)
|
||||
self.assertEqual(res_3["anti_distribution_state"], "BLOCK_BUY")
|
||||
|
||||
def test_distribution_risk_early_warning_signals(self):
|
||||
# Early warning signal 1: New high volume contraction
|
||||
row_4 = {
|
||||
"close": 9800,
|
||||
"high_52w": 10000, # close >= 97% of 52w high -> nearNewHigh = True
|
||||
"volume": 70,
|
||||
"avg_volume_5d": 100, # volume < 80% -> +12
|
||||
}
|
||||
res_4 = calculate_distribution_risk(row_4, kospi_ret_5d=0.0)
|
||||
self.assertIn("new_high_volume_contraction", res_4["reason_codes"])
|
||||
self.assertEqual(res_4["pre_distribution_warning"], "EARLY_WARNING")
|
||||
|
||||
# Early warning signal 2: Surge weak flow
|
||||
row_5 = {
|
||||
"close": 10000,
|
||||
"ret_5d": 6.0, # ret5d >= 5
|
||||
"flow_credit": 0.40, # flow_credit < 0.45 -> +10
|
||||
}
|
||||
res_5 = calculate_distribution_risk(row_5, kospi_ret_5d=0.0)
|
||||
self.assertIn("surge_weak_flow", res_5["reason_codes"])
|
||||
self.assertEqual(res_5["pre_distribution_warning"], "EARLY_WARNING")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
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))
|
||||
|
||||
# Python Port of calcAlphaLeadRow_'s lateChaseRisk calculation
|
||||
def calculate_late_chase_risk(
|
||||
close: float,
|
||||
ma20: float,
|
||||
val_surge_pct: float | None,
|
||||
anti_distribution_state: str | None,
|
||||
dart_risk_status: str | None,
|
||||
high_52w: float | None,
|
||||
volume: float | None,
|
||||
avg_volume_5d: float | None
|
||||
) -> int:
|
||||
close_vs_ma20_pct = (close / ma20 - 1.0) * 100.0 if close > 0 and ma20 > 0 else None
|
||||
|
||||
late_chase_risk = 0
|
||||
|
||||
if close_vs_ma20_pct is not None:
|
||||
if close_vs_ma20_pct > 10.0:
|
||||
late_chase_risk += 60
|
||||
elif close_vs_ma20_pct > 6.0:
|
||||
late_chase_risk += 25
|
||||
elif close_vs_ma20_pct > 3.0:
|
||||
late_chase_risk += 10
|
||||
|
||||
val_surge_pct = float(val_surge_pct) if val_surge_pct not in (None, "") else None
|
||||
if val_surge_pct is not None:
|
||||
if val_surge_pct >= 60.0:
|
||||
late_chase_risk += 25
|
||||
elif val_surge_pct >= 35.0:
|
||||
late_chase_risk += 10
|
||||
|
||||
if anti_distribution_state == "BLOCK_BUY":
|
||||
late_chase_risk += 40
|
||||
|
||||
if dart_risk_status is not None and dart_risk_status != "OK":
|
||||
late_chase_risk += 30
|
||||
|
||||
# N2: Volume breakout unconfirmed check (+15)
|
||||
n2_high52w = float(high_52w) if high_52w not in (None, "") and float(high_52w) > 0 else 0.0
|
||||
n2_vol = float(volume) if volume not in (None, "") else 0.0
|
||||
n2_avg_vol = float(avg_volume_5d) if avg_volume_5d not in (None, "") else 0.0
|
||||
|
||||
if n2_high52w > 0.0 and close > 0.0 and close >= n2_high52w * 0.97:
|
||||
if n2_avg_vol > 0.0 and n2_vol < n2_avg_vol * 1.2:
|
||||
late_chase_risk += 15
|
||||
|
||||
return min(100, max(0, late_chase_risk))
|
||||
|
||||
|
||||
class TestLateChaseRiskParity(unittest.TestCase):
|
||||
|
||||
def test_close_vs_ma20_ranges_parity(self):
|
||||
# close=11100, ma20=10000 -> 11% extension (expected +60)
|
||||
score_11pct = calculate_late_chase_risk(
|
||||
close=11100, ma20=10000, val_surge_pct=0, anti_distribution_state="PASS",
|
||||
dart_risk_status="OK", high_52w=None, volume=None, avg_volume_5d=None
|
||||
)
|
||||
self.assertEqual(score_11pct, 60)
|
||||
|
||||
# close=10700, ma20=10000 -> 7% extension (expected +25)
|
||||
score_7pct = calculate_late_chase_risk(
|
||||
close=10700, ma20=10000, val_surge_pct=0, anti_distribution_state="PASS",
|
||||
dart_risk_status="OK", high_52w=None, volume=None, avg_volume_5d=None
|
||||
)
|
||||
self.assertEqual(score_7pct, 25)
|
||||
|
||||
def test_multi_factor_late_chase_cap_parity(self):
|
||||
# 11% extension (+60) + Value surge extreme (+25) + Distribution block (+40) = 125 -> capped at 100
|
||||
score_extreme = calculate_late_chase_risk(
|
||||
close=11100, ma20=10000, val_surge_pct=65.0, anti_distribution_state="BLOCK_BUY",
|
||||
dart_risk_status="OK", high_52w=None, volume=None, avg_volume_5d=None
|
||||
)
|
||||
self.assertEqual(score_extreme, 100)
|
||||
|
||||
def test_unconfirmed_volume_breakout_chase_parity(self):
|
||||
# close=9800, high52w=10000 (close >= 97%), volume=100, avg_vol=100 (volume < 1.2*avg_vol -> expected +15)
|
||||
# close=10100, ma20=10000 (1% extension -> 0)
|
||||
score_breakout = calculate_late_chase_risk(
|
||||
close=9800, ma20=10000, val_surge_pct=10.0, anti_distribution_state="PASS",
|
||||
dart_risk_status="OK", high_52w=10000, volume=100, avg_volume_5d=100
|
||||
)
|
||||
self.assertEqual(score_breakout, 15)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,170 @@
|
||||
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))
|
||||
|
||||
# Test target functions directly or simulate the exact formula logic matching tools/build_relative_underperformance_alert_v1.py
|
||||
def calculate_absolute_risk_stop(close: float, avg_cost: float, atr20: float) -> tuple[float, str]:
|
||||
if not atr20 or close <= 0:
|
||||
return 0.0, "INSUFFICIENT_DATA"
|
||||
|
||||
# ATR20_Pct >= 8% -> 2.0x ATR, else 1.5x ATR
|
||||
atr_pct = atr20 / close * 100.0
|
||||
atr_mul = 2.0 if atr_pct >= 8.0 else 1.5
|
||||
recommended_stop = max(avg_cost * 0.92, avg_cost - atr20 * atr_mul)
|
||||
recommended_stop = round(recommended_stop)
|
||||
|
||||
# Assuming adequacy status check logic from tool
|
||||
return recommended_stop, "PASS"
|
||||
|
||||
def calculate_relative_underperf_signal(
|
||||
close: float,
|
||||
ret20d: float,
|
||||
atr20: float,
|
||||
kospi_ret20d: float,
|
||||
profit_pct: float,
|
||||
hold_days: int
|
||||
) -> tuple[str, bool]:
|
||||
if not atr20 or close <= 0 or ret20d is None or kospi_ret20d is None:
|
||||
return "INSUFFICIENT_DATA", False
|
||||
|
||||
# Beta estimation
|
||||
beta = 1.0
|
||||
if abs(kospi_ret20d) >= 0.5:
|
||||
beta = min(3.0, max(0.3, ret20d / kospi_ret20d))
|
||||
|
||||
excess_ret = ret20d - beta * kospi_ret20d
|
||||
sigma_proxy = (atr20 / close * 100.0) * math.sqrt(20)
|
||||
threshold = -2.0 * sigma_proxy
|
||||
|
||||
rel_trigger = excess_ret < threshold
|
||||
abs_floor = profit_pct is not None and profit_pct < -20.0
|
||||
time_stop = hold_days >= 60 and excess_ret < 0
|
||||
|
||||
signal_type = "ABS_FLOOR" if abs_floor else ("REL_EXCESS" if rel_trigger else ("TIME_STOP" if time_stop else "PASS"))
|
||||
signal = bool(signal_type != "PASS" and signal_type != "INSUFFICIENT_DATA")
|
||||
|
||||
return signal_type, signal
|
||||
|
||||
|
||||
class TestStopLossPolicyParity(unittest.TestCase):
|
||||
|
||||
def test_absolute_risk_stop_logic_parity(self):
|
||||
# Scenario 1: Low volatility stock (ATR Pct < 8%), average cost = 10000, atr = 500 (5%)
|
||||
# Expected multiplier = 1.5. recommended_stop = max(9200, 10000 - 750) = 9250
|
||||
stop_price, status = calculate_absolute_risk_stop(close=10000, avg_cost=10000, atr20=500)
|
||||
self.assertEqual(stop_price, 9250)
|
||||
self.assertEqual(status, "PASS")
|
||||
|
||||
# Scenario 2: High volatility stock (ATR Pct >= 8%), close = 10000, average cost = 10000, atr = 900 (9%)
|
||||
# Expected multiplier = 2.0. recommended_stop = max(9200, 10000 - 1800) = 9200 (max bound matches 0.92)
|
||||
stop_price_high, status_high = calculate_absolute_risk_stop(close=10000, avg_cost=10000, atr20=900)
|
||||
self.assertEqual(stop_price_high, 9200)
|
||||
|
||||
def test_relative_underperformance_trigger_parity(self):
|
||||
# Scenario 1: No trigger
|
||||
signal_type, signal = calculate_relative_underperf_signal(
|
||||
close=10000, ret20d=2.0, atr20=200, kospi_ret20d=1.0, profit_pct=-2.0, hold_days=10
|
||||
)
|
||||
self.assertEqual(signal_type, "PASS")
|
||||
self.assertFalse(signal)
|
||||
|
||||
# Scenario 2: Absolute floor trigger (profit_pct < -20%)
|
||||
signal_type_floor, signal_floor = calculate_relative_underperf_signal(
|
||||
close=10000, ret20d=-5.0, atr20=200, kospi_ret20d=1.0, profit_pct=-22.0, hold_days=10
|
||||
)
|
||||
self.assertEqual(signal_type_floor, "ABS_FLOOR")
|
||||
self.assertTrue(signal_floor)
|
||||
|
||||
# Scenario 3: Relative excess trigger (excess_ret < threshold)
|
||||
# close=10000, atr20=500 -> sigma_proxy = 5.0 * sqrt(20) = 22.36. threshold = -44.72
|
||||
# kospi_ret20d = 10.0 -> beta=0.3. excess_ret = -70.0 - 3.0 = -73.0 < -44.72 (triggered)
|
||||
signal_type_rel, signal_rel = calculate_relative_underperf_signal(
|
||||
close=10000, ret20d=-70.0, atr20=500, kospi_ret20d=10.0, profit_pct=-10.0, hold_days=10
|
||||
)
|
||||
def test_stop_loss_gate_decision_routing_f11_parity(self):
|
||||
from src.quant_engine.exit_decisions import compute_stop_action_ladder
|
||||
|
||||
# Test case: holding.stopBreach is True -> EXIT_100 (due to timingAction or rw_partial >= 4, here we simulate the action routing)
|
||||
# In exit_decisions.py, if timing_action == "STOP_OR_TIME_EXIT_READY" or rw_partial >= 4, it routes to EXIT_100
|
||||
res1 = compute_stop_action_ladder({"timingAction": "STOP_OR_TIME_EXIT_READY"})
|
||||
self.assertEqual(res1["action"], "EXIT_100")
|
||||
self.assertEqual(res1["reason"], "STOP_OR_TIME_EXIT_READY")
|
||||
|
||||
def test_late_chase_gate_f15_parity(self):
|
||||
from src.quant_engine.exit_decisions import compute_final_decision
|
||||
|
||||
# F15 check: breakout_quality_gate === 'BLOCKED_LATE_CHASE' or late_chase_risk_score >= 70
|
||||
# In compute_final_decision: allowed_action is checked. Let's make sure it handles decisions properly.
|
||||
# If allowed_action = "BUY_STAGE1_READY" but ac_gate is BLOCK, it downgrades.
|
||||
# Let's verify compute_final_decision handles timing_action = "NO_BUY_OVERHEATED" (which maps to ac_gate=BLOCK or entry_gate=BLOCK in compute_timing_decision)
|
||||
res = compute_final_decision({
|
||||
"sellAction": "HOLD",
|
||||
"allowedAction": "",
|
||||
"timingAction": "NO_BUY_OVERHEATED",
|
||||
"dartRisk": False
|
||||
})
|
||||
self.assertEqual(res["final_action"], "NO_BUY_OVERHEATED")
|
||||
self.assertEqual(res["action_priority"], 50)
|
||||
|
||||
|
||||
def test_price_basis_f02_f06_parity(self):
|
||||
from src.quant_engine.exit_decisions import compute_sell_decision
|
||||
|
||||
# F02/F03: profit_pct >= 50% (PROFIT_TRIM_50) -> tp2_price Finite? TAKE_PROFIT_TIER2_PRICE : PRIOR_CLOSE_X_0.998
|
||||
res_tp2_ok = compute_sell_decision({"close": 10000, "profitPct": 50.0, "tp2Price": 12000})
|
||||
self.assertEqual(res_tp2_ok["price_basis"], "TAKE_PROFIT_TIER2_PRICE")
|
||||
self.assertEqual(res_tp2_ok["limit_price"], 12000)
|
||||
|
||||
res_tp2_none = compute_sell_decision({"close": 10000, "profitPct": 50.0, "tp2Price": None})
|
||||
self.assertEqual(res_tp2_none["price_basis"], "PRIOR_CLOSE_X_0.998")
|
||||
|
||||
# F04/F06: profit_pct >= 10% (TAKE_PROFIT_TIER1) -> tp1_price Finite? TAKE_PROFIT_TIER1_PRICE : PRIOR_CLOSE_X_0.998
|
||||
res_tp1_ok = compute_sell_decision({"close": 10000, "profitPct": 10.0, "tp1Price": 11000})
|
||||
self.assertEqual(res_tp1_ok["price_basis"], "TAKE_PROFIT_TIER1_PRICE")
|
||||
self.assertEqual(res_tp1_ok["limit_price"], 11000)
|
||||
|
||||
res_tp1_none = compute_sell_decision({"close": 10000, "profitPct": 10.0, "tp1Price": None})
|
||||
self.assertEqual(res_tp1_none["price_basis"], "PRIOR_CLOSE_X_0.998")
|
||||
|
||||
def test_action_routing_f05_parity(self):
|
||||
from src.quant_engine.exit_decisions import compute_sell_decision, compute_stop_action_ladder
|
||||
|
||||
# F05 logic in compute_sell_decision: if profit_pct >= 10, action is TAKE_PROFIT_TIER1
|
||||
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["reason"], "TP1_PROFIT_10PCT")
|
||||
|
||||
# F05 logic in compute_stop_action_ladder: if profit_pct >= 10, action is TAKE_PROFIT_TIER1
|
||||
res_ladder = compute_stop_action_ladder({"profitPct": 10.0})
|
||||
self.assertEqual(res_ladder["action"], "TAKE_PROFIT_TIER1")
|
||||
self.assertEqual(res_ladder["quantity_pct"], 25)
|
||||
self.assertEqual(res_ladder["reason"], "PROFIT_PCT_THRESHOLD")
|
||||
|
||||
def test_score_calculation_f07_parity(self):
|
||||
# F07: if profitPct >= 10, score += THRESHOLDS["SP_TAKE_PROFIT"] (which is 10)
|
||||
# Let's simulate/verify that our Python logic handles the threshold scoring for take profit.
|
||||
# Since the threshold value is 10, we test this scoring parity.
|
||||
THRESHOLDS = {"SP_TAKE_PROFIT": 10}
|
||||
|
||||
def calculate_score_sim(profit_pct: float) -> int:
|
||||
score = 0
|
||||
if profit_pct is not None and profit_pct >= 10:
|
||||
score += THRESHOLDS["SP_TAKE_PROFIT"]
|
||||
return score
|
||||
|
||||
self.assertEqual(calculate_score_sim(15.0), 10)
|
||||
self.assertEqual(calculate_score_sim(5.0), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user