Merge branch 'codex/roadmap-publish' of http://192.168.123.100:8418/KimJaeHyun/myfinance into codex/roadmap-publish
This commit is contained in:
@@ -96,6 +96,8 @@ source_of_truth_order:
|
||||
28: "prompts/*.md — reusable prompt entrypoints for analysis/review"
|
||||
29: "examples/*.yaml and examples/examples.jsonl — examples are illustrative and never override rules"
|
||||
30: "tests/*.yaml — consistency checks for future edits"
|
||||
31: "spec/03_risk_policy.yaml — legacy redirect stub for backward compatibility"
|
||||
32: "spec/04_strategy_rules.yaml — legacy redirect stub for backward compatibility"
|
||||
|
||||
load_sequence:
|
||||
STEP_1_always:
|
||||
|
||||
@@ -74,7 +74,32 @@ class CollectionRun:
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
def init_db(db_path: Path) -> None:
|
||||
# SQLite와 PostgreSQL 연결을 동적으로 감지하여 연결 인스턴스를 리턴하는 헬퍼
|
||||
def _get_connection(db_target: Path | str) -> Any:
|
||||
db_str = str(db_target)
|
||||
if db_str.startswith("postgresql://") or db_str.startswith("postgres://"):
|
||||
try:
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
conn = psycopg2.connect(db_str)
|
||||
# SQLite의 row_factory = Row 처럼 dict 접근을 가능하게 설정
|
||||
return conn
|
||||
except ImportError:
|
||||
raise ImportError("PostgreSQL DSN이 제공되었으나 psycopg2 패키지가 설치되어 있지 않습니다.")
|
||||
else:
|
||||
return sqlite3.connect(Path(db_target))
|
||||
|
||||
|
||||
def init_db(db_target: Path | str) -> None:
|
||||
db_str = str(db_target)
|
||||
if db_str.startswith("postgresql://") or db_str.startswith("postgres://"):
|
||||
# PostgreSQL은 DB 서버 측에서 직접 Schema 생성을 관리하므로, CLI 도구가 생성한 DDL 마이그레이션 스텁을 사용합니다.
|
||||
# 런타임 수집 중 자동 DDL 실행은 락 이슈 예방을 위해 스킵하고 트랜잭션 연결만 보장합니다.
|
||||
conn = _get_connection(db_target)
|
||||
conn.close()
|
||||
return
|
||||
|
||||
db_path = Path(db_target)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
@@ -84,26 +109,33 @@ def init_db(db_path: Path) -> None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upsert_collection_run(db_path: Path, run: CollectionRun, finished_at: str | None = None) -> None:
|
||||
init_db(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
def upsert_collection_run(db_target: Path | str, run: CollectionRun, finished_at: str | None = None) -> None:
|
||||
init_db(db_target)
|
||||
conn = _get_connection(db_target)
|
||||
db_str = str(db_target)
|
||||
is_pg = db_str.startswith("postgresql://") or db_str.startswith("postgres://")
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
# SQLite와 PostgreSQL 쿼리 바인딩 플레이스홀더 분기 (? vs %s)
|
||||
param_char = "%s" if is_pg else "?"
|
||||
query = f"""
|
||||
INSERT INTO collection_runs (
|
||||
run_id, collector_name, started_at, finished_at, status,
|
||||
input_source, output_json_path, output_db_path, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES ({', '.join([param_char]*9)})
|
||||
ON CONFLICT(run_id) DO UPDATE SET
|
||||
collector_name=excluded.collector_name,
|
||||
started_at=excluded.started_at,
|
||||
finished_at=excluded.finished_at,
|
||||
status=excluded.status,
|
||||
input_source=excluded.input_source,
|
||||
output_json_path=excluded.output_json_path,
|
||||
output_db_path=excluded.output_db_path,
|
||||
notes=excluded.notes
|
||||
""",
|
||||
collector_name=EXCLUDED.collector_name,
|
||||
started_at=EXCLUDED.started_at,
|
||||
finished_at=EXCLUDED.finished_at,
|
||||
status=EXCLUDED.status,
|
||||
input_source=EXCLUDED.input_source,
|
||||
output_json_path=EXCLUDED.output_json_path,
|
||||
output_db_path=EXCLUDED.output_db_path,
|
||||
notes=EXCLUDED.notes
|
||||
"""
|
||||
# PostgreSQL은 ON CONFLICT 테이블명 제외, EXCLUDED는 대소문자 무관하지만 PostgreSQL의 표준은 대문자 EXCLUDED를 권장
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
query,
|
||||
(
|
||||
run.run_id,
|
||||
run.collector_name,
|
||||
@@ -122,7 +154,7 @@ def upsert_collection_run(db_path: Path, run: CollectionRun, finished_at: str |
|
||||
|
||||
|
||||
def upsert_collection_snapshot(
|
||||
db_path: Path,
|
||||
db_target: Path | str,
|
||||
*,
|
||||
run_id: str,
|
||||
dataset_name: str,
|
||||
@@ -135,24 +167,29 @@ def upsert_collection_snapshot(
|
||||
payload: dict[str, Any],
|
||||
provenance: dict[str, Any],
|
||||
) -> None:
|
||||
init_db(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
init_db(db_target)
|
||||
conn = _get_connection(db_target)
|
||||
db_str = str(db_target)
|
||||
is_pg = db_str.startswith("postgresql://") or db_str.startswith("postgres://")
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
param_char = "%s" if is_pg else "?"
|
||||
query = f"""
|
||||
INSERT INTO collection_snapshots (
|
||||
run_id, dataset_name, ticker, name, sector, as_of_date,
|
||||
source_priority, source_status, payload_json, provenance_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES ({', '.join([param_char]*10)})
|
||||
ON CONFLICT(run_id, dataset_name, ticker) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
sector=excluded.sector,
|
||||
as_of_date=excluded.as_of_date,
|
||||
source_priority=excluded.source_priority,
|
||||
source_status=excluded.source_status,
|
||||
payload_json=excluded.payload_json,
|
||||
provenance_json=excluded.provenance_json
|
||||
""",
|
||||
name=EXCLUDED.name,
|
||||
sector=EXCLUDED.sector,
|
||||
as_of_date=EXCLUDED.as_of_date,
|
||||
source_priority=EXCLUDED.source_priority,
|
||||
source_status=EXCLUDED.source_status,
|
||||
payload_json=EXCLUDED.payload_json,
|
||||
provenance_json=EXCLUDED.provenance_json
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
query,
|
||||
(
|
||||
run_id,
|
||||
dataset_name,
|
||||
@@ -172,7 +209,7 @@ def upsert_collection_snapshot(
|
||||
|
||||
|
||||
def append_collection_error(
|
||||
db_path: Path,
|
||||
db_target: Path | str,
|
||||
*,
|
||||
run_id: str,
|
||||
source_name: str,
|
||||
@@ -181,15 +218,20 @@ def append_collection_error(
|
||||
ticker: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
init_db(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
init_db(db_target)
|
||||
conn = _get_connection(db_target)
|
||||
db_str = str(db_target)
|
||||
is_pg = db_str.startswith("postgresql://") or db_str.startswith("postgres://")
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
param_char = "%s" if is_pg else "?"
|
||||
query = f"""
|
||||
INSERT INTO collection_source_errors (
|
||||
run_id, ticker, source_name, error_kind, error_message, payload_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
) VALUES ({', '.join([param_char]*6)})
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
query,
|
||||
(
|
||||
run_id,
|
||||
ticker,
|
||||
@@ -204,101 +246,131 @@ def append_collection_error(
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_latest_snapshots(db_path: Path, ticker: str, dataset_name: str | None = None) -> list[dict[str, Any]]:
|
||||
if not db_path.exists():
|
||||
def fetch_latest_snapshots(db_target: Path | str, ticker: str, dataset_name: str | None = None) -> list[dict[str, Any]]:
|
||||
db_str = str(db_target)
|
||||
is_pg = db_str.startswith("postgresql://") or db_str.startswith("postgres://")
|
||||
if not is_pg and not Path(db_target).exists():
|
||||
return []
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
conn = _get_connection(db_target)
|
||||
if not is_pg:
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
param_char = "%s" if is_pg else "?"
|
||||
cursor = conn.cursor()
|
||||
if dataset_name:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT * FROM collection_snapshots
|
||||
WHERE ticker = ? AND dataset_name = ?
|
||||
WHERE ticker = {param_char} AND dataset_name = {param_char}
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(ticker, dataset_name),
|
||||
).fetchall()
|
||||
)
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT * FROM collection_snapshots
|
||||
WHERE ticker = ?
|
||||
WHERE ticker = {param_char}
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(ticker,),
|
||||
).fetchall()
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def iter_recent_snapshots(db_path: Path, limit: int = 50) -> Iterable[dict[str, Any]]:
|
||||
if not db_path.exists():
|
||||
def iter_recent_snapshots(db_target: Path | str, limit: int = 50) -> Iterable[dict[str, Any]]:
|
||||
db_str = str(db_target)
|
||||
is_pg = db_str.startswith("postgresql://") or db_str.startswith("postgres://")
|
||||
if not is_pg and not Path(db_target).exists():
|
||||
return []
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
conn = _get_connection(db_target)
|
||||
if not is_pg:
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_snapshots ORDER BY created_at DESC LIMIT ?",
|
||||
param_char = "%s" if is_pg else "?"
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"SELECT * FROM collection_snapshots ORDER BY created_at DESC LIMIT {param_char}",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_collection_runs(db_path: Path, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not db_path.exists():
|
||||
def load_collection_runs(db_target: Path | str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
db_str = str(db_target)
|
||||
is_pg = db_str.startswith("postgresql://") or db_str.startswith("postgres://")
|
||||
if not is_pg and not Path(db_target).exists():
|
||||
return []
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
conn = _get_connection(db_target)
|
||||
if not is_pg:
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
param_char = "%s" if is_pg else "?"
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT run_id, collector_name, started_at, finished_at, status,
|
||||
input_source, output_json_path, output_db_path, notes, created_at
|
||||
FROM collection_runs
|
||||
ORDER BY started_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
LIMIT {param_char}
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_collection_errors(db_path: Path, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not db_path.exists():
|
||||
def load_collection_errors(db_target: Path | str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
db_str = str(db_target)
|
||||
is_pg = db_str.startswith("postgresql://") or db_str.startswith("postgres://")
|
||||
if not is_pg and not Path(db_target).exists():
|
||||
return []
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
conn = _get_connection(db_target)
|
||||
if not is_pg:
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
param_char = "%s" if is_pg else "?"
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT run_id, ticker, source_name, error_kind, error_message, payload_json, created_at
|
||||
FROM collection_source_errors
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
LIMIT {param_char}
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_collection_dashboard_state(
|
||||
db_path: Path | str | None = None,
|
||||
db_target: Path | str | None = None,
|
||||
output_json_path: Path | str | None = None,
|
||||
*,
|
||||
limit: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
db = Path(db_path) if db_path else Path()
|
||||
db_str = str(db_target or "")
|
||||
is_pg = db_str.startswith("postgresql://") or db_str.startswith("postgres://")
|
||||
db = Path(db_target) if db_target and not is_pg else Path()
|
||||
report = Path(output_json_path) if output_json_path else Path()
|
||||
state: dict[str, Any] = {
|
||||
"db_path": str(db),
|
||||
"db_path": db_str,
|
||||
"output_json_path": str(report) if output_json_path else "",
|
||||
"runs": [],
|
||||
"recent_snapshots": [],
|
||||
@@ -316,17 +388,31 @@ def load_collection_dashboard_state(
|
||||
state["latest_report"] = json.loads(report.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
state["latest_report"] = {}
|
||||
if not db.exists():
|
||||
|
||||
if not is_pg and (not db_target or not db.exists()):
|
||||
return state
|
||||
conn = sqlite3.connect(db)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
conn = _get_connection(db_target)
|
||||
if not is_pg:
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
state["counts"] = {
|
||||
"collection_runs": conn.execute("SELECT COUNT(*) FROM collection_runs").fetchone()[0],
|
||||
"collection_snapshots": conn.execute("SELECT COUNT(*) FROM collection_snapshots").fetchone()[0],
|
||||
"collection_source_errors": conn.execute("SELECT COUNT(*) FROM collection_source_errors").fetchone()[0],
|
||||
"collection_runs": cursor.execute("SELECT COUNT(*) FROM collection_runs").fetchone()[0] if not is_pg else cursor.execute("SELECT COUNT(*) FROM collection_runs") or 0,
|
||||
"collection_snapshots": cursor.execute("SELECT COUNT(*) FROM collection_snapshots").fetchone()[0] if not is_pg else cursor.execute("SELECT COUNT(*) FROM collection_snapshots") or 0,
|
||||
"collection_source_errors": cursor.execute("SELECT COUNT(*) FROM collection_source_errors").fetchone()[0] if not is_pg else cursor.execute("SELECT COUNT(*) FROM collection_source_errors") or 0,
|
||||
}
|
||||
run_row = conn.execute(
|
||||
# PostgreSQL인 경우 단순 fetchone() 보완
|
||||
if is_pg:
|
||||
# PostgreSQL count 처리
|
||||
cursor.execute("SELECT COUNT(*) FROM collection_runs")
|
||||
state["counts"]["collection_runs"] = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM collection_snapshots")
|
||||
state["counts"]["collection_snapshots"] = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM collection_source_errors")
|
||||
state["counts"]["collection_source_errors"] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT run_id, collector_name, started_at, finished_at, status,
|
||||
input_source, output_json_path, output_db_path, notes, created_at
|
||||
@@ -334,37 +420,45 @@ def load_collection_dashboard_state(
|
||||
ORDER BY started_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
)
|
||||
run_row = cursor.fetchone()
|
||||
state["latest_run"] = dict(run_row) if run_row is not None else {}
|
||||
state["runs"] = [dict(row) for row in conn.execute(
|
||||
"""
|
||||
|
||||
param_char = "%s" if is_pg else "?"
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT run_id, collector_name, started_at, finished_at, status,
|
||||
input_source, output_json_path, output_db_path, notes, created_at
|
||||
FROM collection_runs
|
||||
ORDER BY started_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
LIMIT {param_char}
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()]
|
||||
state["recent_snapshots"] = [dict(row) for row in conn.execute(
|
||||
"""
|
||||
)
|
||||
state["runs"] = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT run_id, dataset_name, ticker, name, sector, as_of_date,
|
||||
source_priority, source_status, created_at
|
||||
FROM collection_snapshots
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
LIMIT {param_char}
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()]
|
||||
state["recent_errors"] = [dict(row) for row in conn.execute(
|
||||
"""
|
||||
)
|
||||
state["recent_snapshots"] = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT run_id, ticker, source_name, error_kind, error_message, created_at
|
||||
FROM collection_source_errors
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
LIMIT {param_char}
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()]
|
||||
)
|
||||
state["recent_errors"] = [dict(row) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
return state
|
||||
|
||||
@@ -297,8 +297,21 @@ def _collect_one(row: dict[str, Any], *, kis_account: str, include_naver: bool,
|
||||
if key in naver:
|
||||
normalized.setdefault(key, naver.get(key))
|
||||
normalized.setdefault("naver_price_status", naver.get("status"))
|
||||
# KIS API 누락 또는 실패 시 Naver 가격 정보를 가격 필드들의 Fallback으로 지정
|
||||
normalized.setdefault("current_price", naver.get("close"))
|
||||
normalized.setdefault("open", naver.get("open"))
|
||||
normalized.setdefault("high", naver.get("high"))
|
||||
normalized.setdefault("low", naver.get("low"))
|
||||
normalized.setdefault("volume", naver.get("volume"))
|
||||
provenance["source_priority"].append("naver_finance")
|
||||
|
||||
# KIS 및 Naver 가격 정보가 모두 없을 시, GatherTradingData.json 원본 시드 가격을 최후의 수단으로 복원
|
||||
normalized.setdefault("current_price", _coerce_float(row.get("current_price") or row.get("Current_Price") or row.get("close")))
|
||||
normalized.setdefault("open", _coerce_float(row.get("open") or row.get("Open")))
|
||||
normalized.setdefault("high", _coerce_float(row.get("high") or row.get("High")))
|
||||
normalized.setdefault("low", _coerce_float(row.get("low") or row.get("Low")))
|
||||
normalized.setdefault("volume", _coerce_float(row.get("volume") or row.get("Volume")))
|
||||
|
||||
normalized.setdefault("collection_as_of", _kst_now_iso())
|
||||
return normalized, provenance
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest
|
||||
|
||||
from src.quant_engine.kis_api_client_v1 import (
|
||||
KisCredentials,
|
||||
OrderEndpointBlockedError,
|
||||
@@ -30,69 +30,72 @@ FORBIDDEN_ORDER_TR_IDS = (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", FORBIDDEN_ORDER_PATHS)
|
||||
def test_order_path_is_blocked(path: str):
|
||||
with pytest.raises(OrderEndpointBlockedError):
|
||||
_assert_read_only(path, "FHKST01010100")
|
||||
class TestKisApiClientV1(unittest.TestCase):
|
||||
|
||||
def test_order_path_is_blocked(self):
|
||||
for path in FORBIDDEN_ORDER_PATHS:
|
||||
with self.assertRaises(OrderEndpointBlockedError):
|
||||
_assert_read_only(path, "FHKST01010100")
|
||||
|
||||
def test_order_tr_id_is_blocked(self):
|
||||
for tr_id in FORBIDDEN_ORDER_TR_IDS:
|
||||
with self.assertRaises(OrderEndpointBlockedError):
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", tr_id)
|
||||
|
||||
def test_known_readonly_endpoints_pass(self):
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100")
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200")
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000")
|
||||
|
||||
def test_no_order_endpoint_substring_anywhere_in_kis_client_source(self):
|
||||
"""정적 검증 — 누군가 향후 주문 함수를 추가하더라도 경로 문자열이 소스에 남으면 즉시 탐지.
|
||||
|
||||
TTTC8434R/VTTC8434R(주식잔고조회)는 FORBIDDEN_TR_ID_PREFIXES 차단목록 '데이터'로
|
||||
이 파일에 의도적으로 존재한다(prefix가 아닌 전체 TR_ID라 prefix-매칭으로는 막을 수
|
||||
없어 명시적으로 등재) — 이 두 개는 검사에서 제외한다. 전체 코드베이스 차원의
|
||||
"차단목록 외 파일에는 한 글자도 없어야 한다"는 보장은
|
||||
tools/validate_no_direct_api_trading_v1.py(ALLOWLISTED_FILES 제외 전체 스캔)가 맡는다.
|
||||
"""
|
||||
source = (ROOT / "src" / "quant_engine" / "kis_api_client_v1.py").read_text(encoding="utf-8")
|
||||
blocklist_data_exceptions = {"TTTC8434R", "VTTC8434R"}
|
||||
for forbidden_path in FORBIDDEN_ORDER_PATHS:
|
||||
self.assertNotIn(forbidden_path, source, f"주문 엔드포인트 경로가 소스에 존재함: {forbidden_path}")
|
||||
for forbidden_tr_id in FORBIDDEN_ORDER_TR_IDS:
|
||||
if forbidden_tr_id in blocklist_data_exceptions:
|
||||
continue
|
||||
self.assertNotIn(forbidden_tr_id, source, f"주문 TR_ID가 소스에 존재함: {forbidden_tr_id}")
|
||||
|
||||
def test_kis_client_module_defines_no_order_submission_function(self):
|
||||
import src.quant_engine.kis_api_client_v1 as kis_module
|
||||
|
||||
public_names = [name for name in dir(kis_module) if not name.startswith("_")]
|
||||
banned_keywords = (
|
||||
"place_order", "submit_order", "cancel_order", "revise_order", "send_order",
|
||||
"inquire_balance", "account_balance",
|
||||
)
|
||||
for name in public_names:
|
||||
lowered = name.lower()
|
||||
for banned in banned_keywords:
|
||||
self.assertNotIn(banned, lowered, f"주문 제출/정정/취소로 의심되는 함수가 존재함: {name}")
|
||||
|
||||
def test_kis_credentials_load_uses_required_env_vars(self):
|
||||
with patch.dict("os.environ", {
|
||||
"KIS_APP_Key": "real-key",
|
||||
"KIS_APP_Secret": "real-secret",
|
||||
"KIS_APP_Key_TEST": "mock-key",
|
||||
"KIS_APP_Secret_TEST": "mock-secret"
|
||||
}):
|
||||
real = KisCredentials.load("real")
|
||||
mock = KisCredentials.load("mock")
|
||||
|
||||
self.assertEqual(real.app_key, "real-key")
|
||||
self.assertEqual(real.app_secret, "real-secret")
|
||||
self.assertEqual(real.account, "real")
|
||||
self.assertEqual(mock.app_key, "mock-key")
|
||||
self.assertEqual(mock.app_secret, "mock-secret")
|
||||
self.assertEqual(mock.account, "mock")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tr_id", FORBIDDEN_ORDER_TR_IDS)
|
||||
def test_order_tr_id_is_blocked(tr_id: str):
|
||||
with pytest.raises(OrderEndpointBlockedError):
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", tr_id)
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
def test_known_readonly_endpoints_pass():
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100")
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200")
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000")
|
||||
|
||||
|
||||
def test_no_order_endpoint_substring_anywhere_in_kis_client_source():
|
||||
"""정적 검증 — 누군가 향후 주문 함수를 추가하더라도 경로 문자열이 소스에 남으면 즉시 탐지.
|
||||
|
||||
TTTC8434R/VTTC8434R(주식잔고조회)는 FORBIDDEN_TR_ID_PREFIXES 차단목록 '데이터'로
|
||||
이 파일에 의도적으로 존재한다(prefix가 아닌 전체 TR_ID라 prefix-매칭으로는 막을 수
|
||||
없어 명시적으로 등재) — 이 두 개는 검사에서 제외한다. 전체 코드베이스 차원의
|
||||
"차단목록 외 파일에는 한 글자도 없어야 한다"는 보장은
|
||||
tools/validate_no_direct_api_trading_v1.py(ALLOWLISTED_FILES 제외 전체 스캔)가 맡는다.
|
||||
"""
|
||||
source = (ROOT / "src" / "quant_engine" / "kis_api_client_v1.py").read_text(encoding="utf-8")
|
||||
blocklist_data_exceptions = {"TTTC8434R", "VTTC8434R"}
|
||||
for forbidden_path in FORBIDDEN_ORDER_PATHS:
|
||||
assert forbidden_path not in source, f"주문 엔드포인트 경로가 소스에 존재함: {forbidden_path}"
|
||||
for forbidden_tr_id in FORBIDDEN_ORDER_TR_IDS:
|
||||
if forbidden_tr_id in blocklist_data_exceptions:
|
||||
continue
|
||||
assert forbidden_tr_id not in source, f"주문 TR_ID가 소스에 존재함: {forbidden_tr_id}"
|
||||
|
||||
|
||||
def test_kis_client_module_defines_no_order_submission_function():
|
||||
import src.quant_engine.kis_api_client_v1 as kis_module
|
||||
|
||||
public_names = [name for name in dir(kis_module) if not name.startswith("_")]
|
||||
banned_keywords = (
|
||||
"place_order", "submit_order", "cancel_order", "revise_order", "send_order",
|
||||
"inquire_balance", "account_balance",
|
||||
)
|
||||
for name in public_names:
|
||||
lowered = name.lower()
|
||||
for banned in banned_keywords:
|
||||
assert banned not in lowered, f"주문 제출/정정/취소로 의심되는 함수가 존재함: {name}"
|
||||
|
||||
|
||||
def test_kis_credentials_load_uses_required_env_vars(monkeypatch):
|
||||
monkeypatch.setenv("KIS_APP_Key", "real-key")
|
||||
monkeypatch.setenv("KIS_APP_Secret", "real-secret")
|
||||
monkeypatch.setenv("KIS_APP_Key_TEST", "mock-key")
|
||||
monkeypatch.setenv("KIS_APP_Secret_TEST", "mock-secret")
|
||||
|
||||
real = KisCredentials.load("real")
|
||||
mock = KisCredentials.load("mock")
|
||||
|
||||
assert real.app_key == "real-key"
|
||||
assert real.app_secret == "real-secret"
|
||||
assert real.account == "real"
|
||||
assert mock.app_key == "mock-key"
|
||||
assert mock.app_secret == "mock-secret"
|
||||
assert mock.account == "mock"
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -11,10 +12,17 @@ if str(ROOT) not in sys.path:
|
||||
import tools.validate_gitea_secrets_contract_v1 as validator
|
||||
|
||||
|
||||
def test_validate_gitea_secrets_contract_passes():
|
||||
rc = validator.main()
|
||||
payload = json.loads((ROOT / "Temp" / "gitea_secrets_contract_v1.json").read_text(encoding="utf-8"))
|
||||
class TestValidateGiteaSecretsContract(unittest.TestCase):
|
||||
|
||||
def test_validate_gitea_secrets_contract_passes(self):
|
||||
rc = validator.main()
|
||||
payload = json.loads((ROOT / "Temp" / "gitea_secrets_contract_v1.json").read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["gate"], "PASS")
|
||||
self.assertTrue(payload["evidence"][".gitea/workflows/kis_data_collection.yml"]["vars.KIS_APP_KEY"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
assert rc == 0
|
||||
assert payload["gate"] == "PASS"
|
||||
assert payload["evidence"][".gitea/workflows/kis_data_collection.yml"]["vars.KIS_APP_KEY"] is True
|
||||
|
||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
@@ -19,35 +21,54 @@ class _FakeCreds:
|
||||
self.app_secret = f"{account}-secret"
|
||||
|
||||
|
||||
def test_validate_kis_api_credentials_writes_pass_json(tmp_path, monkeypatch):
|
||||
out = tmp_path / "kis_api_credentials_validation_v1.json"
|
||||
class TestValidateKisApiCredentials(unittest.TestCase):
|
||||
|
||||
monkeypatch.setenv("KIS_APP_Key_TEST", "mock-key")
|
||||
monkeypatch.setenv("KIS_APP_Secret_TEST", "mock-secret")
|
||||
monkeypatch.setattr(validator, "KisCredentials", type("CredFactory", (), {"load": staticmethod(lambda account: _FakeCreds(account))}))
|
||||
monkeypatch.setattr(validator, "get_current_price", lambda creds, ticker: (_ for _ in ()).throw(RuntimeError("network should not be called in dry-run")))
|
||||
monkeypatch.setattr(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--dry-run", "--output", str(out)])
|
||||
def test_validate_kis_api_credentials_writes_pass_json(self):
|
||||
import tempfile
|
||||
import shutil
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
out = Path(tmp_dir) / "kis_api_credentials_validation_v1.json"
|
||||
|
||||
rc = validator.main()
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
with patch.dict("os.environ", {"KIS_APP_Key_TEST": "mock-key", "KIS_APP_Secret_TEST": "mock-secret"}):
|
||||
with patch.object(validator, "KisCredentials") as mock_creds:
|
||||
mock_creds.load.side_effect = lambda account: _FakeCreds(account)
|
||||
with patch.object(validator, "get_current_price") as mock_price:
|
||||
mock_price.side_effect = RuntimeError("network should not be called in dry-run")
|
||||
with patch.object(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--dry-run", "--output", str(out)]):
|
||||
rc = validator.main()
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
|
||||
assert rc == 0
|
||||
assert payload["gate"] == "PASS"
|
||||
assert payload["evidence"]["account"] == "mock"
|
||||
assert payload["evidence"]["ticker"] == "005930"
|
||||
assert payload["evidence"]["dry_run"] is True
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["gate"], "PASS")
|
||||
self.assertEqual(payload["evidence"]["account"], "mock")
|
||||
self.assertEqual(payload["evidence"]["ticker"], "005930")
|
||||
self.assertTrue(payload["evidence"]["dry_run"])
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
def test_validate_kis_api_credentials_fails_when_api_call_errors(self):
|
||||
import tempfile
|
||||
import shutil
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
out = Path(tmp_dir) / "kis_api_credentials_validation_v1.json"
|
||||
|
||||
with patch.object(validator, "KisCredentials") as mock_creds:
|
||||
mock_creds.load.side_effect = lambda account: _FakeCreds(account)
|
||||
with patch.object(validator, "get_current_price") as mock_price:
|
||||
mock_price.side_effect = RuntimeError("boom")
|
||||
with patch.object(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--output", str(out)]):
|
||||
rc = validator.main()
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(payload["gate"], "FAIL")
|
||||
self.assertTrue(payload["errors"])
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
|
||||
def test_validate_kis_api_credentials_fails_when_api_call_errors(tmp_path, monkeypatch):
|
||||
out = tmp_path / "kis_api_credentials_validation_v1.json"
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
monkeypatch.setattr(validator, "KisCredentials", type("CredFactory", (), {"load": staticmethod(lambda account: _FakeCreds(account))}))
|
||||
monkeypatch.setattr(validator, "get_current_price", lambda creds, ticker: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
monkeypatch.setattr(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--output", str(out)])
|
||||
|
||||
rc = validator.main()
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
|
||||
assert rc == 1
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert payload["errors"]
|
||||
|
||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
@@ -11,115 +13,93 @@ if str(ROOT) not in sys.path:
|
||||
import tools.validate_platform_transition_wbs_v1 as validator
|
||||
|
||||
|
||||
def test_validate_platform_transition_wbs_reports_failure_notes(monkeypatch):
|
||||
spec = {
|
||||
"phase_5_platform_transition": {
|
||||
"P1_kis_core_api_collector": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
"P2_sqlite_canonical_store": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
"P3_ci_scheduler_cutover": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
"P4_gas_thin_adapter_minimize": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
"P5_postgresql_upgrade_path": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
class TestValidatePlatformTransitionWbs(unittest.TestCase):
|
||||
|
||||
def test_validate_platform_transition_wbs_reports_failure_notes(self):
|
||||
spec = {
|
||||
"phase_5_platform_transition": {
|
||||
"P1_kis_core_api_collector": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
"P2_sqlite_canonical_store": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
"P3_ci_scheduler_cutover": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
"P4_gas_thin_adapter_minimize": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
"P5_postgresql_upgrade_path": {
|
||||
"success_criteria": {
|
||||
"expected_success_value": {},
|
||||
"evidence_artifacts": [],
|
||||
"verification_commands": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
validator,
|
||||
"_load_spec",
|
||||
lambda: spec,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
validator,
|
||||
"_read_text",
|
||||
lambda path: "Phase 5 데이터 플랫폼 전환 WBS 성공값 P1 KIS core collector P2 SQLite canonical store P3 CI scheduler cutover P4 GAS thin adapter minimize P5 PostgreSQL upgrade path",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
validator,
|
||||
"_check_p1",
|
||||
lambda: {
|
||||
"gate": "FAIL",
|
||||
"expected_success_value": {},
|
||||
"evidence": {"summary_path": "Temp/test_kis_data_collection.json", "db_path": "Temp/test_kis_data_collection.db"},
|
||||
"errors": ["summary_status=None"],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
validator,
|
||||
"_check_p2",
|
||||
lambda: {
|
||||
"gate": "FAIL",
|
||||
"expected_success_value": {},
|
||||
"evidence": {"db_path": "Temp/test_kis_data_collection.db"},
|
||||
"errors": ["sqlite_round_trip_missing"],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
validator,
|
||||
"_check_p3",
|
||||
lambda: {
|
||||
"gate": "PASS",
|
||||
"expected_success_value": {},
|
||||
"evidence": {"workflow_path": ".gitea/workflows/kis_data_collection.yml"},
|
||||
"errors": [],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
validator,
|
||||
"_check_p4",
|
||||
lambda: {
|
||||
"gate": "FAIL",
|
||||
"expected_success_value": {},
|
||||
"evidence": {"validation_path": "Temp/gas_thin_adapter_validation_v1.json"},
|
||||
"errors": ["gate=None", "function_inventory_coverage_pct<100"],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
validator,
|
||||
"_check_p5",
|
||||
lambda: {
|
||||
"gate": "PASS",
|
||||
"expected_success_value": {},
|
||||
"evidence": {},
|
||||
"errors": [],
|
||||
},
|
||||
)
|
||||
with patch.object(validator, "_load_spec", return_value=spec):
|
||||
with patch.object(validator, "_read_text", return_value="Phase 5 데이터 플랫폼 전환 WBS 성공값 P1 KIS core collector P2 SQLite canonical store P3 CI scheduler cutover P4 GAS thin adapter minimize P5 PostgreSQL upgrade path"):
|
||||
with patch.object(validator, "_check_p1", return_value={
|
||||
"gate": "FAIL",
|
||||
"expected_success_value": {},
|
||||
"evidence": {"summary_path": "Temp/test_kis_data_collection.json", "db_path": "Temp/test_kis_data_collection.db"},
|
||||
"errors": ["summary_status=None"],
|
||||
}):
|
||||
with patch.object(validator, "_check_p2", return_value={
|
||||
"gate": "FAIL",
|
||||
"expected_success_value": {},
|
||||
"evidence": {"db_path": "Temp/test_kis_data_collection.db"},
|
||||
"errors": ["sqlite_round_trip_missing"],
|
||||
}):
|
||||
with patch.object(validator, "_check_p3", return_value={
|
||||
"gate": "PASS",
|
||||
"expected_success_value": {},
|
||||
"evidence": {"workflow_path": ".gitea/workflows/kis_data_collection.yml"},
|
||||
"errors": [],
|
||||
}):
|
||||
with patch.object(validator, "_check_p4", return_value={
|
||||
"gate": "FAIL",
|
||||
"expected_success_value": {},
|
||||
"evidence": {"validation_path": "Temp/gas_thin_adapter_validation_v1.json"},
|
||||
"errors": ["gate=None", "function_inventory_coverage_pct<100"],
|
||||
}):
|
||||
with patch.object(validator, "_check_p5", return_value={
|
||||
"gate": "PASS",
|
||||
"expected_success_value": {},
|
||||
"evidence": {},
|
||||
"errors": [],
|
||||
}):
|
||||
rc = validator.main()
|
||||
payload = json.loads((ROOT / "Temp" / "platform_transition_wbs_v1.json").read_text(encoding="utf-8"))
|
||||
|
||||
rc = validator.main()
|
||||
payload = json.loads((ROOT / "Temp" / "platform_transition_wbs_v1.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(payload["gate"], "FAIL")
|
||||
self.assertTrue(payload["message"].startswith("Platform transition WBS check failed"))
|
||||
self.assertEqual(len(payload["failure_notes"]), 3)
|
||||
self.assertIn("P1 failed", payload["failure_notes"][0])
|
||||
self.assertIn("P2 failed", payload["failure_notes"][1])
|
||||
self.assertIn("P4 failed", payload["failure_notes"][2])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
assert rc == 1
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert payload["message"].startswith("Platform transition WBS check failed")
|
||||
assert len(payload["failure_notes"]) == 3
|
||||
assert "P1 failed" in payload["failure_notes"][0]
|
||||
assert "P2 failed" in payload["failure_notes"][1]
|
||||
assert "P4 failed" in payload["failure_notes"][2]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
@@ -11,59 +13,81 @@ if str(ROOT) not in sys.path:
|
||||
import tools.validate_specs as vs
|
||||
|
||||
|
||||
def test_real_repo_has_no_missing_code_path():
|
||||
"""현재 저장소 상태에서 1차 태깅된 파일들은 모두 code_path가 실존해야 한다."""
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
assert result["gate"] == "PASS"
|
||||
assert result["missing_code_path_count"] == 0
|
||||
assert result["checked_count"] >= 10
|
||||
assert not errors
|
||||
class TestValidateSpecCodeSync(unittest.TestCase):
|
||||
|
||||
def test_real_repo_has_no_missing_code_path(self):
|
||||
"""현재 저장소 상태에서 1차 태깅된 파일들은 모두 code_path가 실존해야 한다."""
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
self.assertEqual(result["gate"], "PASS")
|
||||
self.assertEqual(result["missing_code_path_count"], 0)
|
||||
self.assertTrue(result["checked_count"] >= 10)
|
||||
self.assertFalse(errors)
|
||||
|
||||
def test_missing_code_path_fails(self):
|
||||
import tempfile
|
||||
import shutil
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
tmp_path = Path(tmp_dir)
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "fake_contract.yaml").write_text(
|
||||
"meta:\n has_code_implementation: true\n code_path: \"tools/does_not_exist_v1.py\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.object(vs, "ROOT", tmp_path):
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
self.assertEqual(result["gate"], "FAIL")
|
||||
self.assertEqual(result["missing_code_path_count"], 1)
|
||||
self.assertTrue(any("does_not_exist_v1.py" in e for e in errors))
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
def test_redirect_only_and_has_code_is_contradiction(self):
|
||||
import tempfile
|
||||
import shutil
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
tmp_path = Path(tmp_dir)
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "contradiction.yaml").write_text(
|
||||
"meta:\n has_code_implementation: true\n redirect_only: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.object(vs, "ROOT", tmp_path):
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
self.assertEqual(result["gate"], "FAIL")
|
||||
self.assertTrue(any("contradiction" in e for e in errors))
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
def test_files_without_the_field_are_skipped_not_failed(self):
|
||||
import tempfile
|
||||
import shutil
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
tmp_path = Path(tmp_dir)
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "untouched.yaml").write_text(
|
||||
"meta:\n title: legacy doc with no sync field\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.object(vs, "ROOT", tmp_path):
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
self.assertEqual(result["gate"], "PASS")
|
||||
self.assertEqual(result["checked_count"], 0)
|
||||
self.assertEqual(result["total_spec_files"], 1)
|
||||
self.assertFalse(errors)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
|
||||
def test_missing_code_path_fails(tmp_path, monkeypatch):
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "fake_contract.yaml").write_text(
|
||||
"meta:\n has_code_implementation: true\n code_path: \"tools/does_not_exist_v1.py\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(vs, "ROOT", tmp_path)
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
assert result["gate"] == "FAIL"
|
||||
assert result["missing_code_path_count"] == 1
|
||||
assert any("does_not_exist_v1.py" in e for e in errors)
|
||||
|
||||
|
||||
def test_redirect_only_and_has_code_is_contradiction(tmp_path, monkeypatch):
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "contradiction.yaml").write_text(
|
||||
"meta:\n has_code_implementation: true\n redirect_only: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(vs, "ROOT", tmp_path)
|
||||
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
assert result["gate"] == "FAIL"
|
||||
assert any("contradiction" in e for e in errors)
|
||||
|
||||
|
||||
def test_files_without_the_field_are_skipped_not_failed(tmp_path, monkeypatch):
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "untouched.yaml").write_text(
|
||||
"meta:\n title: legacy doc with no sync field\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(vs, "ROOT", tmp_path)
|
||||
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
assert result["gate"] == "PASS"
|
||||
assert result["checked_count"] == 0
|
||||
assert result["total_spec_files"] == 1
|
||||
assert not errors
|
||||
|
||||
@@ -6,6 +6,93 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def calculate_distribution_risk(row: dict, kospi_ret_5d: float) -> dict:
|
||||
close = float(row.get("Close") or row.get("close") or 0.0)
|
||||
ma20 = float(row.get("MA20") or row.get("ma20") or 0.0)
|
||||
high = float(row.get("High") or row.get("high") or close)
|
||||
low = float(row.get("Low") or row.get("low") or close)
|
||||
volume = row.get("Volume") or row.get("volume")
|
||||
avg_vol_5d = row.get("AvgVolume5d") or row.get("avg_volume_5d") or row.get("Avg_Volume_5D")
|
||||
flow_credit = row.get("FlowCredit") or row.get("flow_credit") or row.get("Flow_Credit")
|
||||
|
||||
# Coerce to float if valid
|
||||
volume = float(volume) if volume not in (None, "") else None
|
||||
avg_vol_5d = float(avg_vol_5d) if avg_vol_5d not in (None, "") else None
|
||||
flow_credit = float(flow_credit) if flow_credit not in (None, "") else None
|
||||
|
||||
price_above_ma20 = close > 0 and ma20 > 0 and close > ma20
|
||||
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
frg5d = row.get("Frg5D") or row.get("frg_5d") or row.get("Frg_5D")
|
||||
inst5d = row.get("Inst5D") or row.get("inst_5d") or row.get("Inst_5D")
|
||||
frg5d = float(frg5d) if frg5d not in (None, "") else None
|
||||
inst5d = float(inst5d) if inst5d not in (None, "") else None
|
||||
|
||||
if frg5d is not None and inst5d is not None and frg5d < 0 and inst5d < 0:
|
||||
score += 30
|
||||
reasons.append("smart_money_outflow")
|
||||
|
||||
if volume is not None and avg_vol_5d is not None and avg_vol_5d > 0 and volume < avg_vol_5d * 0.80:
|
||||
score += 20
|
||||
reasons.append("volume_fade_after_surge")
|
||||
|
||||
if high > low and close > 0:
|
||||
upper_wick_ratio = (high - close) / max(high - low, 1.0)
|
||||
if upper_wick_ratio >= 0.45 and price_above_ma20:
|
||||
score += 15
|
||||
reasons.append("upper_wick_distribution")
|
||||
|
||||
if flow_credit is not None and flow_credit < 0.40:
|
||||
score += 20
|
||||
reasons.append("flow_credit_low")
|
||||
|
||||
ret5d = row.get("Ret5D") or row.get("ret_5d") or row.get("Ret_5D")
|
||||
ret5d = float(ret5d) if ret5d not in (None, "") else None
|
||||
if ret5d is not None and kospi_ret_5d is not None and ret5d < kospi_ret_5d - 3:
|
||||
score += 15
|
||||
reasons.append("sector_relative_lag")
|
||||
|
||||
ac_gate = row.get("acGate") or row.get("ac_gate")
|
||||
if ac_gate and "CLIMAX" in str(ac_gate).upper():
|
||||
score += 15
|
||||
reasons.append("anti_climax_gate")
|
||||
|
||||
ac_total = row.get("acTotal") or row.get("ac_total")
|
||||
ac_total = float(ac_total) if ac_total not in (None, "") else None
|
||||
if ac_total is not None and ac_total >= 2:
|
||||
score += 10
|
||||
reasons.append("ac_total_gte2")
|
||||
|
||||
val_surge_pct = row.get("valSurgePct") or row.get("val_surge_pct")
|
||||
val_surge_pct = float(val_surge_pct) if val_surge_pct not in (None, "") else None
|
||||
if val_surge_pct is not None and val_surge_pct >= 40 and price_above_ma20 and (flow_credit is None or flow_credit < 0.50):
|
||||
score += 10
|
||||
reasons.append("val_surge_no_flow_support")
|
||||
|
||||
high52w = row.get("high52w") or row.get("high_52w") or row.get("High_52W")
|
||||
high52w = float(high52w) if high52w not in (None, "") and float(high52w) > 0 else None
|
||||
near_new_high = (high52w is not None and close > 0 and close >= high52w * 0.97) or (ma20 > 0 and close > ma20 * 1.15)
|
||||
if near_new_high and volume is not None and avg_vol_5d is not None and avg_vol_5d > 0 and volume < avg_vol_5d * 0.80:
|
||||
score += 12
|
||||
reasons.append("new_high_volume_contraction")
|
||||
|
||||
if ret5d is not None and ret5d >= 5 and flow_credit is not None and flow_credit < 0.45:
|
||||
score += 10
|
||||
reasons.append("surge_weak_flow")
|
||||
|
||||
final_score = min(100, max(0, score))
|
||||
state = "BLOCK_BUY" if final_score >= 70 else "TRIM_REVIEW" if final_score >= 55 else "PASS"
|
||||
pre_dist_warning = "EARLY_WARNING" if ("new_high_volume_contraction" in reasons or "surge_weak_flow" in reasons) else "NONE"
|
||||
|
||||
return {
|
||||
"distribution_risk_score": final_score,
|
||||
"anti_distribution_state": state,
|
||||
"pre_distribution_warning": pre_dist_warning,
|
||||
"reason_codes": reasons
|
||||
}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--json", default="GatherTradingData.json")
|
||||
@@ -18,22 +105,23 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
raw = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
core_satellite = raw.get("data", {}).get("core_satellite", []) or []
|
||||
data_feed = raw.get("data", {}).get("data_feed", []) or []
|
||||
|
||||
# Find KOSPI ret_5d if present in macro to align with kospiRet5d
|
||||
macro = raw.get("data", {}).get("macro", []) or []
|
||||
kospi_ret_5d = 0.0
|
||||
for m in macro:
|
||||
if str(m.get("Ticker") or m.get("ticker")).strip() == "KOSPI":
|
||||
kospi_ret_5d = float(m.get("Ret5D") or m.get("ret_5d") or 0.0)
|
||||
break
|
||||
|
||||
scores = {}
|
||||
for row in core_satellite:
|
||||
ticker = row.get("Ticker")
|
||||
for row in data_feed:
|
||||
ticker = row.get("Ticker") or row.get("ticker")
|
||||
if not ticker:
|
||||
continue
|
||||
close = row.get("Close") or 0.0
|
||||
ma20 = row.get("MA20") or close
|
||||
|
||||
# Calculate distribution risk score: 0 to 100
|
||||
score = min(100, max(0, int((close - ma20) / ma20 * 200)))
|
||||
scores[ticker] = {
|
||||
"distribution_risk_score": score,
|
||||
"status": "HIGH" if score >= 50 else "NORMAL"
|
||||
}
|
||||
res = calculate_distribution_risk(row, kospi_ret_5d)
|
||||
scores[ticker] = res
|
||||
|
||||
out_path = ROOT / args.out
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -248,14 +248,58 @@ def sync_sector_insights_via_clasp_run() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def run_pre_deploy_linter() -> bool:
|
||||
print("[deploy_gas] Running pre-deploy gas thin-adapter audit...")
|
||||
# Run auditor v1
|
||||
audit_res = subprocess.run(
|
||||
["python", "tools/audit_gas_thin_adapter_v1.py"],
|
||||
cwd=str(ROOT),
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if audit_res.returncode != 0:
|
||||
print("[deploy_gas] Error: tools/audit_gas_thin_adapter_v1.py failed")
|
||||
print(audit_res.stdout)
|
||||
print(audit_res.stderr)
|
||||
return False
|
||||
|
||||
# Run validator v2
|
||||
validate_res = subprocess.run(
|
||||
["python", "tools/validate_gas_thin_adapter_v2.py"],
|
||||
cwd=str(ROOT),
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
print(validate_res.stdout)
|
||||
if validate_res.returncode != 0:
|
||||
print("[deploy_gas] ABORT: GAS Thin Adapter validation failed!")
|
||||
if validate_res.stderr:
|
||||
print("STDERR: " + validate_res.stderr)
|
||||
return False
|
||||
|
||||
print("[deploy_gas] Pre-deploy thin-adapter audit PASS")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="GAS auto-deploy")
|
||||
parser.add_argument("--dry-run", action="store_true", help="List files without writing")
|
||||
parser.add_argument("--skip-push", action="store_true", help="Bundle only, skip clasp push")
|
||||
parser.add_argument("--skip-lint", action="store_true", help="Skip pre-deploy thin-adapter validation")
|
||||
parser.add_argument("--sync-sector-insights", action="store_true", help="POST sector insight JSON to a deployed GAS web app")
|
||||
parser.add_argument("--webapp-url", default=os.environ.get("GAS_WEBAPP_URL", DEFAULT_WEBAPP_URL), help="Apps Script web app URL for sync POST")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.skip_lint:
|
||||
if not run_pre_deploy_linter():
|
||||
raise SystemExit(1)
|
||||
|
||||
ok = build_deploy(dry_run=args.dry_run)
|
||||
if not ok:
|
||||
print("[deploy_gas] Some source files missing -- check warnings above")
|
||||
|
||||
@@ -26,6 +26,7 @@ REQUIRED_PATTERNS = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
evidence: dict[str, dict[str, bool]] = {}
|
||||
|
||||
Reference in New Issue
Block a user