비기계적 매도전략(가치보존) + 위성종목 추천 엔진 추가
매크로·실적·펀더멘털·공매도수급·호가미시구조·대내외 변수 5개 독립 팩터군의 confluence(최소 3/5 합의) 없이는 매도 트리거를 금지하는 정성적 매도판단 엔진과, 보유종목 제외 위성후보 추천 로직을 추가한다. - 단일 팩터 임계값 돌파만으로는 매도 신호를 생성하지 않음 (mechanical_sell_prohibited=true) - 데이터 결측 시 항상 DATA_MISSING/INSUFFICIENT_DATA_NO_ACTION — 추정값으로 채우지 않음 - KIS 호가10단계·공매도거래비중 + Naver 시세/수급 스크래핑 입력 연동 - SQLite 시계열 저장 + 사후 적중률 자체평가 (evaluate_qualitative_sell_strategy_accuracy_v1) - Gitea 일일 스케줄(장마감 후) + 파이프라인 계약 검증 게이트
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
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.evaluate_qualitative_sell_strategy_accuracy_v1 import (
|
||||
_scoreable_direction,
|
||||
build_accuracy_report,
|
||||
evaluate_decision,
|
||||
)
|
||||
from src.quant_engine.qualitative_sell_strategy_store_v1 import insert_sell_strategy_result
|
||||
|
||||
|
||||
def test_scoreable_direction():
|
||||
assert _scoreable_direction("EXIT_REVIEW_FULL") == -1
|
||||
assert _scoreable_direction("TRIM_REVIEW_PARTIAL") == -1
|
||||
assert _scoreable_direction("HOLD_ADD_CONVICTION") == 1
|
||||
assert _scoreable_direction("HOLD_NO_CONFLUENCE") is None
|
||||
assert _scoreable_direction("INSUFFICIENT_DATA_NO_ACTION") is None
|
||||
|
||||
|
||||
def test_evaluate_decision_sell_success_when_price_drops():
|
||||
decision = {"action": "EXIT_REVIEW_FULL"}
|
||||
result = evaluate_decision(decision, price_at_decision=100.0, price_after=90.0)
|
||||
assert result["success"] is True
|
||||
assert result["realized_return_pct"] == -10.0
|
||||
|
||||
|
||||
def test_evaluate_decision_sell_failure_when_price_rises():
|
||||
decision = {"action": "TRIM_REVIEW_PARTIAL"}
|
||||
result = evaluate_decision(decision, price_at_decision=100.0, price_after=110.0)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_evaluate_decision_hold_add_success_when_price_rises():
|
||||
decision = {"action": "HOLD_ADD_CONVICTION"}
|
||||
result = evaluate_decision(decision, price_at_decision=100.0, price_after=105.0)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
def test_evaluate_decision_returns_none_for_non_directional_action():
|
||||
assert evaluate_decision({"action": "HOLD_NO_CONFLUENCE"}, 100.0, 105.0) is None
|
||||
|
||||
|
||||
def test_build_accuracy_report_data_gated_when_sample_too_small(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
insert_sell_strategy_result(db_path, {
|
||||
"code": "005930", "generated_at": "2026-06-01T12:00:00",
|
||||
"decision": {"action": "EXIT_REVIEW_FULL"},
|
||||
})
|
||||
report = build_accuracy_report(db_path, price_lookup={
|
||||
"005930": {"2026-06-01": 100.0, "2026-06-06": 90.0},
|
||||
})
|
||||
assert report["status"] == "DATA_GATED"
|
||||
assert report["scored_sample_count"] == 1
|
||||
|
||||
|
||||
def test_build_accuracy_report_ok_with_enough_samples(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
price_lookup: dict = {}
|
||||
for i in range(12):
|
||||
code = f"00000{i % 3}"
|
||||
gen_at = f"2026-05-{(i % 20) + 1:02d}T12:00:00"
|
||||
insert_sell_strategy_result(db_path, {
|
||||
"code": code, "generated_at": gen_at,
|
||||
"decision": {"action": "EXIT_REVIEW_FULL"},
|
||||
})
|
||||
date_key = gen_at[:10]
|
||||
future_key = (
|
||||
__import__("datetime").date.fromisoformat(date_key) + __import__("datetime").timedelta(days=5)
|
||||
).isoformat()
|
||||
price_lookup.setdefault(code, {})[date_key] = 100.0
|
||||
price_lookup[code][future_key] = 90.0 # 매도신호 후 하락 — success
|
||||
report = build_accuracy_report(db_path, price_lookup)
|
||||
assert report["status"] == "OK"
|
||||
assert report["hit_rate_pct"] == 100.0
|
||||
assert report["scored_sample_count"] == 12
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
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.qualitative_sell_strategy_store_v1 import (
|
||||
QualitativeSellStoreSpec,
|
||||
fetch_recent_sell_strategy_results,
|
||||
insert_satellite_recommendation,
|
||||
insert_sell_strategy_result,
|
||||
resolve_store_path,
|
||||
)
|
||||
|
||||
|
||||
def test_insert_and_fetch_sell_strategy_result(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
result = {
|
||||
"code": "005930",
|
||||
"generated_at": "2026-06-21T12:00:00+09:00",
|
||||
"decision": {
|
||||
"action": "TRIM_REVIEW_PARTIAL",
|
||||
"conviction": "MEDIUM",
|
||||
"market_regime": "TECHNICAL_MARKET",
|
||||
"composite_score": 0.42,
|
||||
"rationale": "test rationale",
|
||||
},
|
||||
}
|
||||
insert_sell_strategy_result(db_path, result)
|
||||
rows = fetch_recent_sell_strategy_results(db_path, "005930")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["action"] == "TRIM_REVIEW_PARTIAL"
|
||||
assert rows[0]["composite_score"] == 0.42
|
||||
|
||||
|
||||
def test_fetch_returns_empty_list_when_db_missing(tmp_path):
|
||||
rows = fetch_recent_sell_strategy_results(tmp_path / "nonexistent.db", "005930")
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_multiple_inserts_ordered_by_generated_at_desc(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
for ts in ("2026-06-19T12:00:00", "2026-06-21T12:00:00", "2026-06-20T12:00:00"):
|
||||
insert_sell_strategy_result(db_path, {
|
||||
"code": "005930", "generated_at": ts,
|
||||
"decision": {"action": "HOLD_NO_CONFLUENCE"},
|
||||
})
|
||||
rows = fetch_recent_sell_strategy_results(db_path, "005930")
|
||||
assert [r["generated_at"] for r in rows] == ["2026-06-21T12:00:00", "2026-06-20T12:00:00", "2026-06-19T12:00:00"]
|
||||
|
||||
|
||||
def test_insert_satellite_recommendation(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
insert_satellite_recommendation(db_path, "2026-06-21T12:00:00+09:00", {
|
||||
"ticker": "042700",
|
||||
"score": {"satellite_action": "BUY_CANDIDATE", "attractiveness_score": 0.6, "market_regime": "PERFORMANCE_MARKET"},
|
||||
})
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
row = conn.execute("SELECT ticker, satellite_action, attractiveness_score FROM satellite_recommendations").fetchone()
|
||||
conn.close()
|
||||
assert row == ("042700", "BUY_CANDIDATE", 0.6)
|
||||
|
||||
|
||||
def test_resolve_store_path_supports_sqlite(tmp_path):
|
||||
db_path = resolve_store_path(QualitativeSellStoreSpec(location=tmp_path / "qualitative.db"), ROOT)
|
||||
assert str(db_path).endswith("qualitative.db")
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import date
|
||||
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.qualitative_sell_strategy_v1 import (
|
||||
classify_market_regime,
|
||||
compute_microstructure_pressure_from_orderbook,
|
||||
compute_qualitative_sell_strategy,
|
||||
compute_satellite_candidate_score,
|
||||
compute_short_interest_composite,
|
||||
)
|
||||
|
||||
|
||||
def test_classify_market_regime():
|
||||
assert classify_market_regime("RISING") == "PERFORMANCE_MARKET"
|
||||
assert classify_market_regime("FLAT") == "TECHNICAL_MARKET"
|
||||
assert classify_market_regime("FALLING") == "TECHNICAL_MARKET"
|
||||
assert classify_market_regime(None) == "NEUTRAL"
|
||||
assert classify_market_regime("garbage") == "NEUTRAL"
|
||||
|
||||
|
||||
def test_short_interest_composite_data_missing_without_estimating():
|
||||
result = compute_short_interest_composite({"short_balance_ratio": 0.6})
|
||||
assert result["status"] == "DATA_MISSING"
|
||||
assert "short_turnover_share" in result["missing_inputs"]
|
||||
assert result["short_interest_pressure"] is None
|
||||
|
||||
|
||||
def test_short_interest_composite_low_balance_regime_reweights():
|
||||
low_balance = compute_short_interest_composite({
|
||||
"short_balance_ratio": 0.6, "short_balance_ratio_chg_20d": 0.1,
|
||||
"short_turnover_share": 14.0, "relative_return_20d": -8.0,
|
||||
"volume_ratio_5d": 1.8, "earnings_outlook": "DETERIORATING",
|
||||
})
|
||||
assert low_balance["low_balance_regime"] is True
|
||||
assert low_balance["weights_used"]["balance"] < low_balance["weights_used"]["turnover"]
|
||||
assert low_balance["label"] == "ELEVATED_SHORT_PRESSURE"
|
||||
|
||||
|
||||
def test_confluence_requires_minimum_three_agreeing_factors():
|
||||
# 2개 팩터만 매도방향(macro, short_interest) 합의 — 3개 미달이므로 매도 액션 금지
|
||||
ctx = {
|
||||
"macro_pressure": 0.5, "short_interest_pressure": 0.6,
|
||||
"fundamental_trajectory": -0.5, "microstructure_pressure": -0.4,
|
||||
"liquidity_rotation_risk": 0.1,
|
||||
}
|
||||
out = compute_qualitative_sell_strategy(ctx)
|
||||
assert out["action"] not in {"EXIT_REVIEW_FULL", "TRIM_REVIEW_PARTIAL"}
|
||||
|
||||
|
||||
def test_confluence_triggers_trim_when_three_factors_agree():
|
||||
ctx = {
|
||||
"macro_pressure": 0.5, "short_interest_pressure": 0.5,
|
||||
"fundamental_trajectory": 0.4, "microstructure_pressure": 0.1,
|
||||
"liquidity_rotation_risk": 0.0,
|
||||
}
|
||||
out = compute_qualitative_sell_strategy(ctx)
|
||||
assert out["action"] == "TRIM_REVIEW_PARTIAL"
|
||||
assert set(out["sell_agreeing_factors"]) == {"macro_pressure", "short_interest_pressure", "fundamental_trajectory"}
|
||||
|
||||
|
||||
def test_insufficient_data_does_not_fabricate_action():
|
||||
out = compute_qualitative_sell_strategy({"macro_pressure": 0.9})
|
||||
assert out["action"] == "INSUFFICIENT_DATA_NO_ACTION"
|
||||
assert out["mechanical_sell_prohibited"] is True
|
||||
|
||||
|
||||
def test_review_window_pre_earnings_when_outlook_deteriorating():
|
||||
ctx = {
|
||||
"macro_pressure": 0.5, "fundamental_trajectory": 0.5, "short_interest_pressure": 0.5,
|
||||
"earnings_outlook": "DETERIORATING",
|
||||
"next_earnings_date": date(2026, 7, 24),
|
||||
"today": date(2026, 6, 21),
|
||||
}
|
||||
out = compute_qualitative_sell_strategy(ctx)
|
||||
assert out["review_window"]["window_basis"] == "PRE_EARNINGS_EXIT_BEFORE_SURPRISE_RISK"
|
||||
assert out["review_window"]["review_window_end"] < "2026-07-24"
|
||||
|
||||
|
||||
def test_review_window_defers_past_earnings_when_outlook_improving():
|
||||
ctx = {
|
||||
"macro_pressure": -0.5, "fundamental_trajectory": -0.5, "short_interest_pressure": -0.5,
|
||||
"earnings_outlook": "IMPROVING",
|
||||
"next_earnings_date": date(2026, 7, 24),
|
||||
"today": date(2026, 6, 21),
|
||||
}
|
||||
out = compute_qualitative_sell_strategy(ctx)
|
||||
assert out["action"] == "HOLD_ADD_CONVICTION"
|
||||
assert out["review_window"]["window_basis"] == "REASSESS_AFTER_EARNINGS_CONFIRM"
|
||||
|
||||
|
||||
def test_regime_weighting_shifts_composite_score_without_changing_confluence_count():
|
||||
base_ctx = {
|
||||
"macro_pressure": 0.4, "fundamental_trajectory": 0.6, "short_interest_pressure": 0.35,
|
||||
"microstructure_pressure": 0.1, "liquidity_rotation_risk": 0.0,
|
||||
}
|
||||
performance = compute_qualitative_sell_strategy({**base_ctx, "rate_trend": "RISING"})
|
||||
technical = compute_qualitative_sell_strategy({**base_ctx, "rate_trend": "FALLING"})
|
||||
assert performance["market_regime"] == "PERFORMANCE_MARKET"
|
||||
assert technical["market_regime"] == "TECHNICAL_MARKET"
|
||||
assert performance["sell_agreeing_factors"] == technical["sell_agreeing_factors"]
|
||||
assert performance["composite_score"] != technical["composite_score"]
|
||||
|
||||
|
||||
def test_satellite_candidate_score_insufficient_data():
|
||||
out = compute_satellite_candidate_score({"fundamental_trajectory": 0.2})
|
||||
assert out["satellite_action"] == "INSUFFICIENT_DATA_NO_ACTION"
|
||||
|
||||
|
||||
def test_satellite_candidate_score_buy_candidate_on_strong_export_and_fundamentals():
|
||||
out = compute_satellite_candidate_score({
|
||||
"sector_export_trend": 12.0, "fundamental_trajectory": -0.4,
|
||||
"relative_return_20d": 3.0, "rate_trend": "RISING",
|
||||
})
|
||||
assert out["satellite_action"] == "BUY_CANDIDATE"
|
||||
assert out["market_regime"] == "PERFORMANCE_MARKET"
|
||||
|
||||
|
||||
def test_microstructure_pressure_from_orderbook_ask_heavy_is_positive():
|
||||
out = compute_microstructure_pressure_from_orderbook({"total_askp_rsqn": "300000", "total_bidp_rsqn": "100000"})
|
||||
assert out["status"] == "OK"
|
||||
assert out["microstructure_pressure"] > 0
|
||||
|
||||
|
||||
def test_microstructure_pressure_from_orderbook_bid_heavy_is_negative():
|
||||
out = compute_microstructure_pressure_from_orderbook({"total_askp_rsqn": "100000", "total_bidp_rsqn": "300000"})
|
||||
assert out["microstructure_pressure"] < 0
|
||||
|
||||
|
||||
def test_microstructure_pressure_from_orderbook_missing_fields():
|
||||
out = compute_microstructure_pressure_from_orderbook({})
|
||||
assert out["status"] == "DATA_MISSING"
|
||||
assert out["microstructure_pressure"] is None
|
||||
|
||||
|
||||
def test_map_universe_sector_to_hs_sector_substring_match():
|
||||
from tools.build_satellite_candidate_recommendations_v1 import map_universe_sector_to_hs_sector
|
||||
|
||||
assert map_universe_sector_to_hs_sector("반도체/PCB") == "반도체"
|
||||
assert map_universe_sector_to_hs_sector("자동차/부품") == "자동차"
|
||||
assert map_universe_sector_to_hs_sector("AI전력/기기") is None
|
||||
assert map_universe_sector_to_hs_sector(None) is None
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import tools.validate_qualitative_sell_strategy_pipeline_v1 as validator
|
||||
|
||||
|
||||
def test_validate_qualitative_sell_strategy_pipeline_passes(tmp_path, monkeypatch):
|
||||
out = tmp_path / "qualitative_sell_strategy_pipeline_v1.json"
|
||||
monkeypatch.setattr(sys, "argv", ["validate_qualitative_sell_strategy_pipeline_v1.py"])
|
||||
monkeypatch.setattr(validator, "ROOT", ROOT)
|
||||
|
||||
rc = validator.main()
|
||||
payload = json.loads((ROOT / "Temp" / "qualitative_sell_strategy_pipeline_v1.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert rc == 0
|
||||
assert payload["gate"] == "PASS"
|
||||
assert payload["checks"]["store_contract"] is True
|
||||
Reference in New Issue
Block a user