.json에 기록한다. --batch --workbook으로
+ account_snapshot 실보유 종목 전체 일괄 처리."
+
+ satellite_orchestrator:
+ tool: "tools/build_satellite_candidate_recommendations_v1.py"
+ purpose: "universe 시트(미보유 위성 유니버스)에서 보유종목을 제외한 후보 전체를
+ SATELLITE_CANDIDATE_SCORE_V1로 평가해 outputs/qualitative_sell_strategy/
+ satellite_recommendations.json에 기록한다. universe.Sector 한글 라벨은 부분
+ 문자열 매칭으로 SECTOR_HS_MAP에 연결 — 매칭 실패 시 sector_export_trend를
+ 추정하지 않고 None 유지(추정 금지 원칙)."
diff --git a/spec/ownership_map.yaml b/spec/ownership_map.yaml
index 2ee4d4f9..1ec6466b 100644
--- a/spec/ownership_map.yaml
+++ b/spec/ownership_map.yaml
@@ -82,16 +82,9 @@ ownership_map:
must_not_own: ["투자 규칙 수치"]
# ── 호환 인덱스 (redirect-only, 실제 규칙은 canonical_split_files 참조) ──
- "spec/03_risk_policy.yaml":
- role: "compatibility_index"
- owns: ["legacy path alias for spec/risk/*.yaml"]
- must_not_own: ["수치 임계값", "새 리스크 규칙"]
- canonical_files: ["spec/risk/portfolio_exposure.yaml", "spec/risk/risk_control.yaml", "spec/risk/quality_control.yaml"]
- "spec/04_strategy_rules.yaml":
- role: "compatibility_index"
- owns: ["legacy path alias for spec/strategy/*.yaml"]
- must_not_own: ["수치 임계값", "새 전략 규칙"]
- canonical_files: ["spec/strategy/sector_model.yaml", "spec/strategy/entry_gates.yaml", "spec/strategy/stock_model.yaml", "spec/strategy/rebalancing_trigger.yaml"]
+ # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml, spec/04_strategy_rules.yaml은
+ # role: deprecated_redirect(영구 유지가 아닌 완전 폐기 대상)였으며 활성 참조 0건을
+ # 확인 후 실삭제했다. 캐노니컬 split 파일들은 영향 없이 그대로 유지된다.
"spec/06_exit_policy.yaml":
role: "compatibility_index"
owns: ["legacy path alias for spec/exit/*.yaml"]
diff --git a/spec/risk/README.md b/spec/risk/README.md
index 39ccf558..74f1788b 100644
--- a/spec/risk/README.md
+++ b/spec/risk/README.md
@@ -1,6 +1,7 @@
# Risk Spec Split Plan
-`spec/03_risk_policy.yaml` is now a compatibility index.
+`spec/03_risk_policy.yaml` was a deprecated_redirect-only stub and has been deleted
+(2026-06-22, WBS-7.11 — zero active references confirmed before removal).
The canonical risk rules are the split files in this directory.
Canonical split files:
@@ -14,7 +15,7 @@ Canonical split files:
Migration rule:
-- Do not add numeric thresholds to `spec/03_risk_policy.yaml` or `spec/risk/risk_control.yaml`.
+- Do not add numeric thresholds to `spec/risk/risk_control.yaml` (compatibility index only).
- Keep old paths valid only through compatibility indexes and `spec/aliases.yaml`.
- New documents must reference canonical split files directly.
- `spec/00_execution_contract.yaml` remains higher authority than all risk split files.
diff --git a/spec/risk/factor_risk.yaml b/spec/risk/factor_risk.yaml
index 718cd49a..cdab1523 100644
--- a/spec/risk/factor_risk.yaml
+++ b/spec/risk/factor_risk.yaml
@@ -1,6 +1,6 @@
meta:
title: "은퇴자산포트폴리오 — 포트폴리오 노출·현금 정책 분할 후보"
- parent_file: "spec/03_risk_policy.yaml"
+ parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml 삭제로 갱신
version: "2026-05-16-F9_secular_leader"
language: "ko-KR"
timezone: "Asia/Seoul"
diff --git a/spec/risk/portfolio_exposure.yaml b/spec/risk/portfolio_exposure.yaml
index e03b0927..746f64eb 100644
--- a/spec/risk/portfolio_exposure.yaml
+++ b/spec/risk/portfolio_exposure.yaml
@@ -1,6 +1,6 @@
meta:
title: "은퇴자산포트폴리오 — 포트폴리오 노출·현금 정책 분할 후보"
- parent_file: "spec/03_risk_policy.yaml"
+ parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml 삭제로 갱신
version: "2026-05-18-F10_score_clamp_d2_fix"
language: "ko-KR"
timezone: "Asia/Seoul"
diff --git a/spec/risk/quality_control.yaml b/spec/risk/quality_control.yaml
index da5982d0..c782ad1c 100644
--- a/spec/risk/quality_control.yaml
+++ b/spec/risk/quality_control.yaml
@@ -1,6 +1,6 @@
meta:
title: "은퇴자산포트폴리오 — 리스크 품질관리 분할 후보"
- parent_file: "spec/03_risk_policy.yaml"
+ parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml 삭제로 갱신
version: "2026-05-15-F8_split"
language: "ko-KR"
timezone: "Asia/Seoul"
diff --git a/spec/risk/risk_control.yaml b/spec/risk/risk_control.yaml
index 687246ab..c68e347a 100644
--- a/spec/risk/risk_control.yaml
+++ b/spec/risk/risk_control.yaml
@@ -1,10 +1,12 @@
meta:
title: "은퇴자산포트폴리오 — 리스크 제어 호환 인덱스"
- parent_file: "spec/03_risk_policy.yaml"
+ parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml 삭제로 갱신
version: "2026-05-15-F12_index_only"
language: "ko-KR"
timezone: "Asia/Seoul"
role: "compatibility_index"
+ has_code_implementation: false
+ redirect_only: true
purpose: "기존 risk_control 경로를 보존하기 위한 인덱스 파일."
canonical_split_files:
diff --git a/spec/strategy/README.md b/spec/strategy/README.md
index 72059201..64281212 100644
--- a/spec/strategy/README.md
+++ b/spec/strategy/README.md
@@ -1,6 +1,7 @@
# Strategy Spec Split Plan
-`spec/04_strategy_rules.yaml` is now a compatibility index.
+`spec/04_strategy_rules.yaml` was a deprecated_redirect-only stub and has been deleted
+(2026-06-22, WBS-7.11 — zero active references confirmed before removal).
The canonical strategy rules are the split files in this directory.
Canonical split files:
@@ -17,5 +18,5 @@ Canonical split files:
Migration rule:
- Do not duplicate thresholds without `canonical_ref`.
-- Keep old paths valid through `spec/04_strategy_rules.yaml.legacy_path_aliases`.
+- Keep old paths valid through `spec/strategy/entry_gates.yaml.legacy_path_aliases` (compatibility index only).
- `spec/09_decision_flow.yaml` controls execution order; strategy split files only define domain logic.
diff --git a/spec/strategy/entry_gates.yaml b/spec/strategy/entry_gates.yaml
index 80dccef8..14d69dd3 100644
--- a/spec/strategy/entry_gates.yaml
+++ b/spec/strategy/entry_gates.yaml
@@ -1,10 +1,12 @@
meta:
title: "은퇴자산포트폴리오 — 진입 게이트 호환 인덱스"
- parent_file: "spec/04_strategy_rules.yaml"
+ parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/04_strategy_rules.yaml 삭제로 갱신
version: "2026-05-15-F11_index_only"
language: "ko-KR"
timezone: "Asia/Seoul"
role: "compatibility_index"
+ has_code_implementation: false
+ redirect_only: true
purpose: >
기존 spec/strategy/entry_gates.yaml 경로를 보존하기 위한 인덱스 파일.
실제 진입 규칙은 세부 split 파일을 canonical로 사용한다.
diff --git a/spec/strategy/rebalancing_trigger.yaml b/spec/strategy/rebalancing_trigger.yaml
index f0d04a74..fd969851 100644
--- a/spec/strategy/rebalancing_trigger.yaml
+++ b/spec/strategy/rebalancing_trigger.yaml
@@ -1,6 +1,6 @@
meta:
title: "은퇴자산포트폴리오 — 리밸런싱 트리거 분할 후보"
- parent_file: "spec/04_strategy_rules.yaml"
+ parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/04_strategy_rules.yaml 삭제로 갱신
version: "2026-05-15-F8_split"
language: "ko-KR"
timezone: "Asia/Seoul"
diff --git a/spec/strategy/sector_model.yaml b/spec/strategy/sector_model.yaml
index 26f8b012..4730388f 100644
--- a/spec/strategy/sector_model.yaml
+++ b/spec/strategy/sector_model.yaml
@@ -1,6 +1,6 @@
meta:
title: "은퇴자산포트폴리오 — 섹터 모델 분할 후보"
- parent_file: "spec/04_strategy_rules.yaml"
+ parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/04_strategy_rules.yaml 삭제로 갱신
version: "2026-05-15-F8_split"
language: "ko-KR"
timezone: "Asia/Seoul"
diff --git a/spec/strategy/stock_model.yaml b/spec/strategy/stock_model.yaml
index 71668881..5b22258b 100644
--- a/spec/strategy/stock_model.yaml
+++ b/spec/strategy/stock_model.yaml
@@ -1,6 +1,6 @@
meta:
title: "은퇴자산포트폴리오 — 종목 모델 분할 후보"
- parent_file: "spec/04_strategy_rules.yaml"
+ parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/04_strategy_rules.yaml 삭제로 갱신
version: "2026-05-16-F10_peg_gate"
language: "ko-KR"
timezone: "Asia/Seoul"
diff --git a/src/gas/core/gas_lib.gs b/src/gas/core/gas_lib.gs
index 24c3884f..e9be0a2c 100644
--- a/src/gas/core/gas_lib.gs
+++ b/src/gas/core/gas_lib.gs
@@ -2467,6 +2467,29 @@ function doPost(e) {
.createTextOutput(JSON.stringify(result, null, 2))
.setMimeType(ContentService.MimeType.JSON);
}
+ if (action === "trigger_run_all") {
+ // 외부(Gitea CI) 스케줄러가 run_all()을 원격 트리거할 수 있게 하는 진입점.
+ // run_all은 매수/매도 주문을 실행하지 않는다(데이터 갱신·분석 전용) — governance
+ // 06/07과 동일한 "조회/분석만, 주문 없음" 원칙을 따른다. 공유 비밀키로 무단 호출 차단.
+ const expectedSecret = String(PropertiesService.getScriptProperties().getProperty("RUN_ALL_TRIGGER_SECRET") || "");
+ const providedSecret = String(payload.secret || "");
+ if (!expectedSecret || providedSecret !== expectedSecret) {
+ return ContentService
+ .createTextOutput(JSON.stringify({ status: "ERROR", message: "unauthorized" }, null, 2))
+ .setMimeType(ContentService.MimeType.JSON);
+ }
+ const startedAt = new Date().toISOString();
+ try {
+ run_all();
+ return ContentService
+ .createTextOutput(JSON.stringify({ status: "OK", started_at: startedAt, finished_at: new Date().toISOString() }, null, 2))
+ .setMimeType(ContentService.MimeType.JSON);
+ } catch (runErr) {
+ return ContentService
+ .createTextOutput(JSON.stringify({ status: "ERROR", message: String(runErr && runErr.message ? runErr.message : runErr) }, null, 2))
+ .setMimeType(ContentService.MimeType.JSON);
+ }
+ }
return ContentService
.createTextOutput(JSON.stringify({
status: "ERROR",
diff --git a/src/quant_engine/data_collection_backend_v1.py b/src/quant_engine/data_collection_backend_v1.py
new file mode 100644
index 00000000..e998c296
--- /dev/null
+++ b/src/quant_engine/data_collection_backend_v1.py
@@ -0,0 +1,18 @@
+"""Storage backend selection for the collection pipeline.
+
+This module is a thin compatibility wrapper over the generic storage backend
+contract. The collector is intentionally designed around a backend contract,
+not a hard SQLite-only assumption.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from src.quant_engine.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec
+
+
+CollectionStoreSpec = StoreSpec
+
+
+def default_collection_store_path(root: Path) -> Path:
+ return default_sqlite_store_path(root, "kis_data_collection/kis_data_collection.db")
diff --git a/src/quant_engine/data_collection_store_v1.py b/src/quant_engine/data_collection_store_v1.py
new file mode 100644
index 00000000..81848b65
--- /dev/null
+++ b/src/quant_engine/data_collection_store_v1.py
@@ -0,0 +1,370 @@
+"""SQLite store for platform-transition data collection outputs.
+
+This store is intentionally small and backend-agnostic enough to be upgraded to
+PostgreSQL later without changing the row contract. The canonical payload is the
+normalized factor row plus provenance metadata.
+"""
+from __future__ import annotations
+
+import json
+import sqlite3
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Iterable
+
+
+SCHEMA = """
+PRAGMA journal_mode=WAL;
+
+CREATE TABLE IF NOT EXISTS collection_runs (
+ run_id TEXT PRIMARY KEY,
+ collector_name TEXT NOT NULL,
+ started_at TEXT NOT NULL,
+ finished_at TEXT,
+ status TEXT NOT NULL,
+ input_source TEXT,
+ output_json_path TEXT,
+ output_db_path TEXT,
+ notes TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS collection_snapshots (
+ run_id TEXT NOT NULL,
+ dataset_name TEXT NOT NULL,
+ ticker TEXT NOT NULL,
+ name TEXT,
+ sector TEXT,
+ as_of_date TEXT,
+ source_priority TEXT,
+ source_status TEXT,
+ payload_json TEXT NOT NULL,
+ provenance_json TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ PRIMARY KEY (run_id, dataset_name, ticker)
+);
+
+CREATE TABLE IF NOT EXISTS collection_source_errors (
+ run_id TEXT NOT NULL,
+ ticker TEXT,
+ source_name TEXT NOT NULL,
+ error_kind TEXT NOT NULL,
+ error_message TEXT NOT NULL,
+ payload_json TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_collection_snapshots_ticker_time
+ ON collection_snapshots(ticker, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_collection_source_errors_run
+ ON collection_source_errors(run_id, source_name);
+"""
+
+
+@dataclass(frozen=True)
+class CollectionRun:
+ run_id: str
+ collector_name: str
+ started_at: str
+ status: str
+ input_source: str | None = None
+ output_json_path: str | None = None
+ output_db_path: str | None = None
+ notes: str | None = None
+
+
+def init_db(db_path: Path) -> None:
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.executescript(SCHEMA)
+ conn.commit()
+ finally:
+ 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)
+ try:
+ conn.execute(
+ """
+ INSERT INTO collection_runs (
+ run_id, collector_name, started_at, finished_at, status,
+ input_source, output_json_path, output_db_path, notes
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ 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
+ """,
+ (
+ run.run_id,
+ run.collector_name,
+ run.started_at,
+ finished_at,
+ run.status,
+ run.input_source,
+ run.output_json_path,
+ run.output_db_path,
+ run.notes,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def upsert_collection_snapshot(
+ db_path: Path,
+ *,
+ run_id: str,
+ dataset_name: str,
+ ticker: str,
+ name: str | None,
+ sector: str | None,
+ as_of_date: str | None,
+ source_priority: str,
+ source_status: str,
+ payload: dict[str, Any],
+ provenance: dict[str, Any],
+) -> None:
+ init_db(db_path)
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.execute(
+ """
+ INSERT INTO collection_snapshots (
+ run_id, dataset_name, ticker, name, sector, as_of_date,
+ source_priority, source_status, payload_json, provenance_json
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ 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
+ """,
+ (
+ run_id,
+ dataset_name,
+ ticker,
+ name,
+ sector,
+ as_of_date,
+ source_priority,
+ source_status,
+ json.dumps(payload, ensure_ascii=False, default=str),
+ json.dumps(provenance, ensure_ascii=False, default=str),
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def append_collection_error(
+ db_path: Path,
+ *,
+ run_id: str,
+ source_name: str,
+ error_kind: str,
+ error_message: str,
+ ticker: str | None = None,
+ payload: dict[str, Any] | None = None,
+) -> None:
+ init_db(db_path)
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.execute(
+ """
+ INSERT INTO collection_source_errors (
+ run_id, ticker, source_name, error_kind, error_message, payload_json
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ run_id,
+ ticker,
+ source_name,
+ error_kind,
+ error_message,
+ json.dumps(payload or {}, ensure_ascii=False, default=str),
+ ),
+ )
+ conn.commit()
+ finally:
+ 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():
+ return []
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ try:
+ if dataset_name:
+ rows = conn.execute(
+ """
+ SELECT * FROM collection_snapshots
+ WHERE ticker = ? AND dataset_name = ?
+ ORDER BY created_at DESC
+ """,
+ (ticker, dataset_name),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ """
+ SELECT * FROM collection_snapshots
+ WHERE ticker = ?
+ ORDER BY created_at DESC
+ """,
+ (ticker,),
+ ).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():
+ return []
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ try:
+ rows = conn.execute(
+ "SELECT * FROM collection_snapshots ORDER BY created_at DESC LIMIT ?",
+ (limit,),
+ ).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():
+ return []
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ try:
+ rows = conn.execute(
+ """
+ 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 ?
+ """,
+ (int(limit),),
+ ).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():
+ return []
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ try:
+ rows = conn.execute(
+ """
+ SELECT run_id, ticker, source_name, error_kind, error_message, payload_json, created_at
+ FROM collection_source_errors
+ ORDER BY created_at DESC
+ LIMIT ?
+ """,
+ (int(limit),),
+ ).fetchall()
+ return [dict(row) for row in rows]
+ finally:
+ conn.close()
+
+
+def load_collection_dashboard_state(
+ db_path: 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()
+ report = Path(output_json_path) if output_json_path else Path()
+ state: dict[str, Any] = {
+ "db_path": str(db),
+ "output_json_path": str(report) if output_json_path else "",
+ "runs": [],
+ "recent_snapshots": [],
+ "recent_errors": [],
+ "counts": {
+ "collection_runs": 0,
+ "collection_snapshots": 0,
+ "collection_source_errors": 0,
+ },
+ "latest_run": {},
+ "latest_report": {},
+ }
+ if report.exists():
+ try:
+ state["latest_report"] = json.loads(report.read_text(encoding="utf-8"))
+ except Exception:
+ state["latest_report"] = {}
+ if not db.exists():
+ return state
+ conn = sqlite3.connect(db)
+ conn.row_factory = sqlite3.Row
+ try:
+ 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],
+ }
+ run_row = conn.execute(
+ """
+ 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 1
+ """
+ ).fetchone()
+ state["latest_run"] = dict(run_row) if run_row is not None else {}
+ state["runs"] = [dict(row) for row in conn.execute(
+ """
+ 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 ?
+ """,
+ (int(limit),),
+ ).fetchall()]
+ state["recent_snapshots"] = [dict(row) for row in conn.execute(
+ """
+ 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 ?
+ """,
+ (int(limit),),
+ ).fetchall()]
+ state["recent_errors"] = [dict(row) for row in conn.execute(
+ """
+ SELECT run_id, ticker, source_name, error_kind, error_message, created_at
+ FROM collection_source_errors
+ ORDER BY created_at DESC
+ LIMIT ?
+ """,
+ (int(limit),),
+ ).fetchall()]
+ finally:
+ conn.close()
+ return state
diff --git a/src/quant_engine/execution_slippage_store_v1.py b/src/quant_engine/execution_slippage_store_v1.py
new file mode 100644
index 00000000..07fe088c
--- /dev/null
+++ b/src/quant_engine/execution_slippage_store_v1.py
@@ -0,0 +1,144 @@
+"""WBS-7.6(2026-06-21) — 실거래 슬리피지 실측 캡처 스캐폴딩.
+
+spec/55_execution_simulator_contract.yaml의 slippage_model(bps=5)은 이론치이며
+"추후 실측 데이터로 보정 예정"이라는 메모만 있고 실제 캡처 경로가 없었다. 이 모듈은
+주문은 사람이 HTS에서 직접 실행한다는 governance/rules/06 원칙을 그대로 유지한 채
+(API로 체결을 가져오지 않는다), 실행 후 사람이 수동으로 기록한 실제 체결가를
+누적해 가정치(5bps)와 비교할 수 있게 한다. 5건 미만이면 항상 DATA_GATED로 보고한다
+— 추정 금지 원칙(spec/00_execution_contract.yaml)을 따른다. 표준 라이브러리
+sqlite3만 사용한다.
+"""
+from __future__ import annotations
+
+import sqlite3
+from pathlib import Path
+from typing import Any
+
+from src.quant_engine.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS realized_slippage_samples (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ticker TEXT NOT NULL,
+ side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')),
+ intended_price REAL NOT NULL,
+ actual_fill_price REAL NOT NULL,
+ slippage_bps_actual REAL NOT NULL,
+ recorded_at TEXT NOT NULL,
+ note TEXT,
+ inserted_at TEXT DEFAULT (datetime('now'))
+);
+"""
+
+ASSUMED_SLIPPAGE_BPS = 5.0
+MIN_SAMPLE_FOR_COMPARISON = 5
+
+
+def default_execution_slippage_store_path(root: Path) -> Path:
+ return default_sqlite_store_path(root, "execution_slippage/execution_slippage.db")
+
+
+def resolve_store_path(spec: StoreSpec, root: Path) -> Path:
+ backend, location = normalize_store_spec(
+ spec, root, default_sqlite_name="execution_slippage/execution_slippage.db"
+ )
+ if backend != "sqlite":
+ raise ValueError("execution_slippage_store_v1 currently executes on sqlite only.")
+ return Path(location)
+
+
+def init_db(db_path: Path) -> None:
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.executescript(SCHEMA)
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def _compute_slippage_bps(intended_price: float, actual_fill_price: float, side: str) -> float:
+ """체결가가 의도가(지정가)보다 불리한 방향으로 움직인 만큼을 양수 bps로 환산한다.
+
+ BUY: 실제 체결가가 의도가보다 높으면(더 비싸게 샀으면) 양수 슬리피지.
+ SELL: 실제 체결가가 의도가보다 낮으면(더 싸게 팔았으면) 양수 슬리피지.
+ """
+ if intended_price <= 0:
+ raise ValueError("intended_price must be > 0")
+ direction = 1 if side.upper() == "BUY" else -1
+ return direction * (actual_fill_price - intended_price) / intended_price * 10_000.0
+
+
+def insert_realized_slippage_sample(
+ db_path: Path,
+ *,
+ ticker: str,
+ side: str,
+ intended_price: float,
+ actual_fill_price: float,
+ recorded_at: str,
+ note: str | None = None,
+) -> dict[str, Any]:
+ init_db(db_path)
+ slippage_bps = _compute_slippage_bps(intended_price, actual_fill_price, side)
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.execute(
+ "INSERT INTO realized_slippage_samples "
+ "(ticker, side, intended_price, actual_fill_price, slippage_bps_actual, recorded_at, note) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (ticker, side.upper(), intended_price, actual_fill_price, slippage_bps, recorded_at, note),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ return {
+ "ticker": ticker,
+ "side": side.upper(),
+ "intended_price": intended_price,
+ "actual_fill_price": actual_fill_price,
+ "slippage_bps_actual": round(slippage_bps, 4),
+ "recorded_at": recorded_at,
+ }
+
+
+def fetch_all_samples(db_path: Path) -> list[dict[str, Any]]:
+ if not db_path.exists():
+ return []
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ try:
+ rows = conn.execute(
+ "SELECT ticker, side, intended_price, actual_fill_price, slippage_bps_actual, recorded_at, note "
+ "FROM realized_slippage_samples ORDER BY recorded_at ASC"
+ ).fetchall()
+ return [dict(row) for row in rows]
+ finally:
+ conn.close()
+
+
+def build_slippage_comparison_report(db_path: Path) -> dict[str, Any]:
+ """WBS-7.6 성공 하네스 — 5건 미만이면 DATA_GATED를 정직하게 반환한다(추정 금지)."""
+ samples = fetch_all_samples(db_path)
+ sample_n = len(samples)
+ if sample_n < MIN_SAMPLE_FOR_COMPARISON:
+ return {
+ "status": "DATA_GATED",
+ "sample_n": sample_n,
+ "min_required": MIN_SAMPLE_FOR_COMPARISON,
+ "assumed_slippage_bps": ASSUMED_SLIPPAGE_BPS,
+ "actual_mean_slippage_bps": None,
+ "note": f"실측 표본 {sample_n}/{MIN_SAMPLE_FOR_COMPARISON}건 — 비교 불가, 가정치(5bps) 유지",
+ }
+ actual_mean = sum(s["slippage_bps_actual"] for s in samples) / sample_n
+ gap = abs(actual_mean - ASSUMED_SLIPPAGE_BPS)
+ return {
+ "status": "OK",
+ "sample_n": sample_n,
+ "assumed_slippage_bps": ASSUMED_SLIPPAGE_BPS,
+ "actual_mean_slippage_bps": round(actual_mean, 4),
+ "gap_bps": round(gap, 4),
+ "recommendation": (
+ "가정치(5bps) 유지" if gap <= 3.0 else "spec/55_execution_simulator_contract.yaml의 bps 값을 실측 평균으로 갱신 검토"
+ ),
+ }
diff --git a/src/quant_engine/kis_api_client_v1.py b/src/quant_engine/kis_api_client_v1.py
new file mode 100644
index 00000000..013d4e3f
--- /dev/null
+++ b/src/quant_engine/kis_api_client_v1.py
@@ -0,0 +1,212 @@
+"""한국투자증권(KIS) Open API 클라이언트 — 조회(read-only) 전용.
+
+근거: https://apiportal.koreainvestment.com/apiservice-summary ,
+ https://github.com/koreainvestment/open-trading-api (2026-06-21 실측 확인된
+ api_url/tr_id만 사용 — 추정 금지).
+
+══════════════════════════════════════════════════════════════════════════════
+[CRITICAL] governance/rules/06_no_direct_api_trading.yaml — 절대 규칙
+이 모듈은 매수/매도 주문을 어떤 경로로도 제출하지 않는다. 주문 제출/정정/취소
+함수는 이 파일에 일체 작성하지 않으며, 공유 요청 함수(_send_request)는 주문
+관련 경로("/trading/")나 TR_ID(TTTC08*/VTTC08* 등)를 만나면 즉시 RuntimeError로
+요청을 차단한다(2차 방어). 이 원칙을 어기면 엔진 전체가 '제안 시스템'에서
+'자동매매 시스템'으로 변질되어 프로젝트 핵심 전제가 깨진다(사용자 직접 지시).
+══════════════════════════════════════════════════════════════════════════════
+
+인증 정보는 Windows 환경변수에서 읽는다(실제계좌: KIS_APP_Key/KIS_APP_Secret,
+모의계좌: KIS_APP_Key_TEST/KIS_APP_Secret_TEST). 방금 setx로 설정된 값은 현재
+프로세스의 os.environ에 아직 반영되지 않을 수 있어, HKCU\\Environment 레지스트리
+폴백을 둔다(읽기만 함, 값을 로그에 남기지 않음).
+"""
+from __future__ import annotations
+
+import datetime as dt
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+import requests
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+REAL_DOMAIN = "https://openapi.koreainvestment.com:9443"
+MOCK_DOMAIN = "https://openapivts.koreainvestment.com:29443"
+TOKEN_CACHE_DIR = ROOT / "Temp"
+
+# ── [CRITICAL] 주문 차단 목록 — 절대 수정/완화 금지 (governance/rules/06_no_direct_api_trading.yaml) ──
+# "/trading/" 하위 경로는 주문(order)뿐 아니라 계좌잔고조회(inquire-balance)도 포함한다.
+# 계좌 보유종목/잔고는 governance/rules/07_no_kis_account_balance_query.yaml에 의해
+# 별도로도 금지된다 — HTS 캡처가 유일한 출처(사용자 직접 지시).
+FORBIDDEN_PATH_SUBSTRINGS: tuple[str, ...] = ("/trading/",)
+FORBIDDEN_TR_ID_PREFIXES: tuple[str, ...] = (
+ "TTTC08", "VTTC08", "TTTC01", "VTTC01", # 현금/신용 매수·매도·정정·취소
+ "TTTC8434R", "VTTC8434R", # 주식잔고조회 — 계좌 보유종목 조회 금지(07번 규칙)
+)
+
+
+class OrderEndpointBlockedError(RuntimeError):
+ """주문 제출/정정/취소 경로 호출 시도 — 절대 차단."""
+
+
+def _assert_read_only(path: str, tr_id: str) -> None:
+ for forbidden in FORBIDDEN_PATH_SUBSTRINGS:
+ if forbidden in path:
+ raise OrderEndpointBlockedError(
+ f"BLOCKED: 주문 관련 경로 호출 시도 차단 — path={path!r}. "
+ "이 엔진은 매수/매도를 API로 직접 실행하지 않는다(governance/rules/06_no_direct_api_trading.yaml)."
+ )
+ for prefix in FORBIDDEN_TR_ID_PREFIXES:
+ if tr_id.upper().startswith(prefix):
+ raise OrderEndpointBlockedError(
+ f"BLOCKED: 주문 관련 TR_ID 호출 시도 차단 — tr_id={tr_id!r}. "
+ "이 엔진은 매수/매도를 API로 직접 실행하지 않는다(governance/rules/06_no_direct_api_trading.yaml)."
+ )
+
+
+def _read_env_var(name: str) -> str | None:
+ import os
+
+ value = os.environ.get(name)
+ if value:
+ return value
+ if sys.platform != "win32":
+ return None
+ try:
+ import winreg
+
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
+ value, _ = winreg.QueryValueEx(key, name)
+ return value or None
+ except OSError:
+ return None
+
+
+class KisCredentials:
+ def __init__(self, app_key: str, app_secret: str, account: str):
+ self.app_key = app_key
+ self.app_secret = app_secret
+ self.account = account # "real" | "mock"
+ self.domain = REAL_DOMAIN if account == "real" else MOCK_DOMAIN
+
+ @classmethod
+ def load(cls, account: str = "mock") -> "KisCredentials":
+ if account == "real":
+ key_name, secret_name = "KIS_APP_Key", "KIS_APP_Secret"
+ elif account == "mock":
+ key_name, secret_name = "KIS_APP_Key_TEST", "KIS_APP_Secret_TEST"
+ else:
+ raise ValueError("account must be 'real' or 'mock'")
+ app_key = _read_env_var(key_name)
+ app_secret = _read_env_var(secret_name)
+ if not app_key or not app_secret:
+ raise RuntimeError(
+ f"{key_name}/{secret_name} 환경변수를 찾을 수 없음 — Windows 환경변수 설정 후 "
+ "새 셸에서 재시도하거나 HKCU\\Environment 레지스트리 반영을 확인하세요."
+ )
+ return cls(app_key=app_key, app_secret=app_secret, account=account)
+
+
+def _token_cache_path(creds: KisCredentials) -> Path:
+ TOKEN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ return TOKEN_CACHE_DIR / f"kis_token_cache_{creds.account}.json"
+
+
+def _issue_or_reuse_token(creds: KisCredentials) -> str:
+ """KIS는 토큰 발급 빈도를 제한한다 — 만료 전까지 캐시 재사용 필수."""
+ cache_path = _token_cache_path(creds)
+ if cache_path.exists():
+ try:
+ cached = json.loads(cache_path.read_text(encoding="utf-8"))
+ expires_at = dt.datetime.fromisoformat(cached["expires_at"])
+ if dt.datetime.now(dt.timezone.utc) < expires_at - dt.timedelta(minutes=10):
+ return cached["access_token"]
+ except (json.JSONDecodeError, KeyError, ValueError):
+ pass
+
+ resp = requests.post(
+ f"{creds.domain}/oauth2/tokenP",
+ json={"grant_type": "client_credentials", "appkey": creds.app_key, "appsecret": creds.app_secret},
+ timeout=15,
+ )
+ resp.raise_for_status()
+ body = resp.json()
+ access_token = body["access_token"]
+ expires_in_sec = int(body.get("expires_in", 86400))
+ expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_in_sec)
+ cache_path.write_text(
+ json.dumps({"access_token": access_token, "expires_at": expires_at.isoformat()}, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ return access_token
+
+
+def _send_request(creds: KisCredentials, path: str, tr_id: str, params: dict[str, Any]) -> dict[str, Any]:
+ """모든 KIS REST 호출의 단일 진입점 — 여기서만 가드가 작동하면 충분하다."""
+ _assert_read_only(path, tr_id) # [CRITICAL] 절대 제거 금지
+ access_token = _issue_or_reuse_token(creds)
+ headers = {
+ "content-type": "application/json; charset=utf-8",
+ "authorization": f"Bearer {access_token}",
+ "appkey": creds.app_key,
+ "appsecret": creds.app_secret,
+ "tr_id": tr_id,
+ "custtype": "P",
+ }
+ resp = requests.get(f"{creds.domain}{path}", headers=headers, params=params, timeout=15)
+ resp.raise_for_status()
+ return resp.json()
+
+
+# ── 조회(read-only) 함수 — 전부 GET, 전부 quotations/ranking 카테고리 (실측 확인) ──────────
+
+def get_current_price(creds: KisCredentials, code: str) -> dict[str, Any]:
+ """주식현재가 시세. api_url=/uapi/domestic-stock/v1/quotations/inquire-price, tr_id=FHKST01010100."""
+ return _send_request(
+ creds, "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100",
+ {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
+ )
+
+
+def get_asking_price_10_level(creds: KisCredentials, code: str) -> dict[str, Any]:
+ """주식현재가 호가/예상체결 — 10단계 매수/매도 호가.
+ api_url=/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn, tr_id=FHKST01010200.
+ """
+ return _send_request(
+ creds, "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200",
+ {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
+ )
+
+
+def get_daily_short_sale(creds: KisCredentials, code: str, start_date: str, end_date: str) -> dict[str, Any]:
+ """국내주식 공매도 일별추이. api_url=/uapi/domestic-stock/v1/quotations/daily-short-sale,
+ tr_id=FHPST04830000. start_date/end_date: YYYYMMDD."""
+ return _send_request(
+ creds, "/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000",
+ {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code,
+ "FID_INPUT_DATE_1": start_date, "FID_INPUT_DATE_2": end_date},
+ )
+
+
+def get_daily_item_chart_price(
+ creds: KisCredentials, code: str, start_date: str, end_date: str, period: str = "D",
+) -> dict[str, Any]:
+ """주식현재가 일자별. api_url=/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice,
+ tr_id=FHKST03010100."""
+ return _send_request(
+ creds, "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100",
+ {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code,
+ "FID_INPUT_DATE_1": start_date, "FID_INPUT_DATE_2": end_date,
+ "FID_PERIOD_DIV_CODE": period, "FID_ORG_ADJ_PRC": "0"},
+ )
+
+
+def get_investor_trend(creds: KisCredentials, code: str) -> dict[str, Any]:
+ """주식현재가 투자자(개인/외국인/기관) 매매동향.
+ api_url=/uapi/domestic-stock/v1/quotations/inquire-investor, tr_id=FHKST01010900."""
+ return _send_request(
+ creds, "/uapi/domestic-stock/v1/quotations/inquire-investor", "FHKST01010900",
+ {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
+ )
diff --git a/src/quant_engine/kis_data_collection_v1.py b/src/quant_engine/kis_data_collection_v1.py
new file mode 100644
index 00000000..5a18720e
--- /dev/null
+++ b/src/quant_engine/kis_data_collection_v1.py
@@ -0,0 +1,378 @@
+"""KIS-first data collector for the CI scheduler.
+
+The collector uses the existing `GatherTradingData.json` snapshot as the seed
+universe, then enriches Korean tickers with read-only KIS quotations and
+orderbook data, while retaining Naver/Yahoo fallbacks when available.
+The canonical persistence target is SQLite.
+"""
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import sys
+import uuid
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+try:
+ from tools.fetch_naver_market_data_v1 import ( # type: ignore
+ _session as naver_session,
+ compute_relative_return_20d,
+ compute_volume_ratio_5d,
+ fetch_foreign_institution_flow,
+ fetch_price_history,
+ )
+except Exception: # pragma: no cover - optional adapter
+ naver_session = None
+ compute_relative_return_20d = None
+ compute_volume_ratio_5d = None
+ fetch_foreign_institution_flow = None
+ fetch_price_history = None
+
+try:
+ from src.quant_engine.kis_api_client_v1 import ( # type: ignore
+ KisCredentials,
+ get_asking_price_10_level,
+ get_current_price,
+ get_daily_short_sale,
+ )
+except Exception: # pragma: no cover - safe fallback in non-KIS environments
+ KisCredentials = None
+ get_asking_price_10_level = None
+ get_current_price = None
+ get_daily_short_sale = None
+
+from src.quant_engine.data_collection_store_v1 import (
+ CollectionRun,
+ append_collection_error,
+ upsert_collection_run,
+ upsert_collection_snapshot,
+)
+from src.quant_engine.data_collection_backend_v1 import (
+ CollectionStoreSpec,
+ normalize_store_spec,
+)
+
+
+def _kst_now_iso() -> str:
+ return dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat()
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {}
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def _coerce_float(value: Any) -> float | None:
+ if value is None or value == "":
+ return None
+ try:
+ if isinstance(value, str):
+ value = value.replace(",", "").replace("%", "")
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _find_first_value(payload: Any, keys: tuple[str, ...]) -> Any:
+ stack = [payload]
+ while stack:
+ item = stack.pop()
+ if isinstance(item, dict):
+ for key in keys:
+ value = item.get(key)
+ if value not in (None, ""):
+ return value
+ stack.extend(item.values())
+ elif isinstance(item, list):
+ stack.extend(item)
+ return None
+
+
+def _normalize_naver_price_history(code: str) -> dict[str, Any]:
+ if naver_session is None or fetch_price_history is None:
+ return {"status": "DISABLED"}
+ try:
+ session = naver_session()
+ price = fetch_price_history(session, code)
+ result: dict[str, Any] = {"status": price.get("status", "UNKNOWN"), "source_url": price.get("source_url")}
+ rows = price.get("rows") or []
+ if rows:
+ result["close"] = rows[0].get("close")
+ result["open"] = rows[0].get("open")
+ result["high"] = rows[0].get("high")
+ result["low"] = rows[0].get("low")
+ result["volume"] = rows[0].get("volume")
+ if compute_relative_return_20d is not None:
+ benchmark = fetch_price_history(session, "069500")
+ result["relative_return_20d"] = compute_relative_return_20d(rows, benchmark.get("rows", []))
+ if compute_volume_ratio_5d is not None:
+ result["volume_ratio_5d"] = compute_volume_ratio_5d(rows)
+ if fetch_foreign_institution_flow is not None:
+ result["foreign_institution_flow"] = fetch_foreign_institution_flow(session, code)
+ return result
+ except Exception as exc: # noqa: BLE001 - fallback source must not break the batch
+ return {"status": "ERROR", "error": str(exc)}
+
+
+def _normalize_kis_fields(code: str, account: str) -> dict[str, Any]:
+ if KisCredentials is None or get_current_price is None or get_asking_price_10_level is None or get_daily_short_sale is None:
+ return {"status": "DISABLED"}
+ try:
+ creds = KisCredentials.load(account)
+ except Exception as exc:
+ return {"status": "ERROR", "error": str(exc)}
+
+ result: dict[str, Any] = {"status": "OK", "account": account}
+ try:
+ price = get_current_price(creds, code)
+ result["current_price_raw"] = price
+ result["current_price"] = _coerce_float(_find_first_value(price, ("stck_prpr", "stck_clpr", "close", "close_price")))
+ result["open"] = _coerce_float(_find_first_value(price, ("stck_oprc", "open", "open_price")))
+ result["high"] = _coerce_float(_find_first_value(price, ("stck_hgpr", "high", "high_price")))
+ result["low"] = _coerce_float(_find_first_value(price, ("stck_lwpr", "low", "low_price")))
+ result["prev_close"] = _coerce_float(_find_first_value(price, ("prdy_vrss", "prev_close")))
+ result["volume"] = _coerce_float(_find_first_value(price, ("acml_vol", "volume")))
+ result["change_pct"] = _coerce_float(_find_first_value(price, ("prdy_ctrt", "change_pct")))
+ except Exception as exc:
+ result["price_status"] = "ERROR"
+ result["price_error"] = str(exc)
+
+ try:
+ orderbook = get_asking_price_10_level(creds, code)
+ output1 = orderbook.get("output1") or {}
+ result["orderbook_raw"] = orderbook
+ result["microstructure_pressure"] = _coerce_float(
+ _find_first_value(output1, ("total_askp_rsqn", "total_bidp_rsqn"))
+ )
+ result["ask_1"] = _coerce_float(_find_first_value(output1, ("askp1",)))
+ result["bid_1"] = _coerce_float(_find_first_value(output1, ("bidp1",)))
+ result["orderbook_status"] = "OK"
+ except Exception as exc:
+ result["orderbook_status"] = "ERROR"
+ result["orderbook_error"] = str(exc)
+
+ try:
+ start = (dt.date.today() - dt.timedelta(days=10)).strftime("%Y%m%d")
+ end = dt.date.today().strftime("%Y%m%d")
+ short_sale = get_daily_short_sale(creds, code, start, end)
+ result["short_sale_raw"] = short_sale
+ rows = short_sale.get("output2") or []
+ if rows:
+ latest = rows[0]
+ result["short_turnover_share"] = _coerce_float(latest.get("ssts_vol_rlim"))
+ result["short_sale_status"] = "OK"
+ except Exception as exc:
+ result["short_sale_status"] = "ERROR"
+ result["short_sale_error"] = str(exc)
+
+ return result
+
+
+def _build_seed_rows(source_json: Path) -> list[dict[str, Any]]:
+ payload = _load_json(source_json)
+ data = payload.get("data") or {}
+ core_satellite = {str(row.get("Ticker") or row.get("ticker") or ""): row for row in data.get("core_satellite", [])}
+ sector_lookup = {str(row.get("Ticker") or row.get("ticker") or ""): row.get("Sector") for row in data.get("core_satellite", [])}
+ rows: list[dict[str, Any]] = []
+ for row in data.get("data_feed", []):
+ ticker = str(row.get("Ticker") or row.get("ticker") or "").strip()
+ if not ticker:
+ continue
+ merged = dict(row)
+ core_row = core_satellite.get(ticker) or {}
+ if core_row:
+ for key, value in core_row.items():
+ merged.setdefault(key, value)
+ merged["Sector"] = merged.get("Sector") or sector_lookup.get(ticker)
+ rows.append(merged)
+ return rows
+
+
+def _collect_one(row: dict[str, Any], *, kis_account: str, include_naver: bool, include_live_kis: bool) -> tuple[dict[str, Any], dict[str, Any]]:
+ ticker = str(row.get("Ticker") or row.get("ticker") or "").strip()
+ name = str(row.get("Name") or row.get("name") or "").strip()
+ sector = str(row.get("Sector") or row.get("sector") or "").strip() or None
+ normalized = dict(row)
+ provenance: dict[str, Any] = {
+ "ticker": ticker,
+ "name": name,
+ "sector": sector,
+ "source_priority": ["gathertradingdata_json"],
+ }
+
+ if include_live_kis and ticker.isdigit() and len(ticker) == 6:
+ kis = _normalize_kis_fields(ticker, kis_account)
+ provenance["kis"] = kis
+ normalized.update({k: v for k, v in kis.items() if k not in {"current_price_raw", "orderbook_raw", "short_sale_raw"}})
+ if kis.get("status") == "OK":
+ provenance["source_priority"].insert(0, "kis_open_api")
+
+ if include_naver and ticker.isdigit() and len(ticker) == 6:
+ naver = _normalize_naver_price_history(ticker)
+ provenance["naver"] = naver
+ if naver.get("status") in {"OK", "DATA_MISSING"}:
+ normalized.setdefault("relative_return_20d", naver.get("relative_return_20d"))
+ normalized.setdefault("volume_ratio_5d", naver.get("volume_ratio_5d"))
+ normalized.setdefault("naver_price_status", naver.get("status"))
+ provenance["source_priority"].append("naver_finance")
+
+ normalized.setdefault("collection_as_of", _kst_now_iso())
+ return normalized, provenance
+
+
+def collect_to_sqlite(
+ *,
+ input_json: Path,
+ sqlite_db: Path,
+ output_json: Path,
+ kis_account: str,
+ include_naver: bool = True,
+ include_live_kis: bool = True,
+) -> dict[str, Any]:
+ run_id = uuid.uuid4().hex
+ started_at = _kst_now_iso()
+ upsert_collection_run(
+ sqlite_db,
+ CollectionRun(
+ run_id=run_id,
+ collector_name="kis_data_collection_v1",
+ started_at=started_at,
+ status="RUNNING",
+ input_source=str(input_json),
+ output_json_path=str(output_json),
+ output_db_path=str(sqlite_db),
+ notes="KIS-first CI collection",
+ ),
+ )
+
+ seed_rows = _build_seed_rows(input_json)
+ summary = {
+ "formula_id": "KIS_DATA_COLLECTION_V1",
+ "run_id": run_id,
+ "started_at": started_at,
+ "input_json": str(input_json),
+ "sqlite_db": str(sqlite_db),
+ "row_count": len(seed_rows),
+ "source_counts": {},
+ "errors": [],
+ "rows": [],
+ }
+
+ for row in seed_rows:
+ ticker = str(row.get("Ticker") or row.get("ticker") or "").strip()
+ if not ticker:
+ continue
+ try:
+ normalized, provenance = _collect_one(row, kis_account=kis_account, include_naver=include_naver, include_live_kis=include_live_kis)
+ source_counts = summary["source_counts"]
+ for source_name in provenance.get("source_priority") or []:
+ source_counts[source_name] = source_counts.get(source_name, 0) + 1
+ upsert_collection_snapshot(
+ sqlite_db,
+ run_id=run_id,
+ dataset_name="data_feed",
+ ticker=ticker,
+ name=str(normalized.get("Name") or normalized.get("name") or ""),
+ sector=normalized.get("Sector"),
+ as_of_date=str(normalized.get("Price_Date") or normalized.get("AsOfDate") or normalized.get("collection_as_of") or ""),
+ source_priority=">".join(provenance.get("source_priority") or []),
+ source_status="OK",
+ payload=normalized,
+ provenance=provenance,
+ )
+ summary["rows"].append(
+ {
+ "ticker": ticker,
+ "name": normalized.get("Name") or normalized.get("name"),
+ "sector": normalized.get("Sector"),
+ "source_priority": provenance.get("source_priority"),
+ "current_price": normalized.get("current_price"),
+ "relative_return_20d": normalized.get("relative_return_20d"),
+ "volume_ratio_5d": normalized.get("volume_ratio_5d"),
+ }
+ )
+ except Exception as exc: # noqa: BLE001
+ error = {"ticker": ticker, "error": str(exc)}
+ summary["errors"].append(error)
+ append_collection_error(
+ sqlite_db,
+ run_id=run_id,
+ source_name="collector",
+ error_kind=type(exc).__name__,
+ error_message=str(exc),
+ ticker=ticker,
+ payload=row,
+ )
+
+ summary["finished_at"] = _kst_now_iso()
+ summary["status"] = "PASS" if not summary["errors"] else "PASS_WITH_WARNINGS"
+ output_json.parent.mkdir(parents=True, exist_ok=True)
+ output_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
+ upsert_collection_run(
+ sqlite_db,
+ CollectionRun(
+ run_id=run_id,
+ collector_name="kis_data_collection_v1",
+ started_at=started_at,
+ status=summary["status"],
+ input_source=str(input_json),
+ output_json_path=str(output_json),
+ output_db_path=str(sqlite_db),
+ notes="KIS-first CI collection",
+ ),
+ finished_at=summary["finished_at"],
+ )
+ return summary
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--input-json", type=Path, default=ROOT / "GatherTradingData.json")
+ ap.add_argument("--sqlite-db", type=Path, default=ROOT / "outputs" / "kis_data_collection" / "kis_data_collection.db")
+ ap.add_argument("--store-backend", default="sqlite", help="Storage backend contract placeholder (sqlite today, postgresql planned)")
+ ap.add_argument("--store-location", default=None, help="Backend location/DSN. sqlite path or future postgres DSN.")
+ ap.add_argument("--output-json", type=Path, default=ROOT / "Temp" / "kis_data_collection_v1.json")
+ ap.add_argument("--kis-account", choices=["real", "mock"], default="real")
+ ap.add_argument("--no-naver", action="store_true")
+ ap.add_argument("--no-live-kis", action="store_true")
+ args = ap.parse_args()
+
+ store_backend, store_location = normalize_store_spec(
+ CollectionStoreSpec(
+ backend=args.store_backend,
+ location=args.store_location or args.sqlite_db,
+ ),
+ ROOT,
+ )
+ if store_backend != "sqlite":
+ raise SystemExit(
+ "현재 실행 backend는 sqlite만 지원합니다. "
+ "하지만 collector는 이미 backend contract로 분리되어 있어 "
+ "후속 PostgreSQL 구현을 같은 호출 지점에 붙일 수 있습니다."
+ )
+
+ summary = collect_to_sqlite(
+ input_json=args.input_json,
+ sqlite_db=Path(store_location),
+ output_json=args.output_json,
+ kis_account=args.kis_account,
+ include_naver=not args.no_naver,
+ include_live_kis=not args.no_live_kis,
+ )
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
+ return 0 if summary.get("status") == "PASS" else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/quant_engine/qualitative_sell_strategy_store_v1.py b/src/quant_engine/qualitative_sell_strategy_store_v1.py
new file mode 100644
index 00000000..5e1e14a7
--- /dev/null
+++ b/src/quant_engine/qualitative_sell_strategy_store_v1.py
@@ -0,0 +1,146 @@
+"""qualitative_sell_strategy_v1 산출물의 SQLite 시계열 저장소.
+
+GAS/xlsx 구조와 완전히 분리된 추가(additive) 저장소다 — 이 모듈이 다루는 데이터는
+순수 Python 산출물(KIS API 수집 + confluence 판단 결과)이며, GAS가 쓰지도 읽지도
+않고 사람이 시트에서 직접 편집하지도 않는다. 기존 outputs/qualitative_sell_strategy/
+*.json 파일 출력을 대체하지 않고 병행 저장한다(JSON은 1회성 점검용, SQLite는 시계열
+추이 조회용). 표준 라이브러리 sqlite3만 사용 — 추가 의존성 없음.
+"""
+from __future__ import annotations
+
+import json
+import sqlite3
+from pathlib import Path
+from dataclasses import dataclass
+from typing import Any
+
+from src.quant_engine.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS sell_strategy_results (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT NOT NULL,
+ generated_at TEXT NOT NULL,
+ action TEXT,
+ conviction TEXT,
+ market_regime TEXT,
+ composite_score REAL,
+ rationale TEXT,
+ raw_json TEXT NOT NULL,
+ inserted_at TEXT DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_sell_strategy_code_time
+ ON sell_strategy_results(code, generated_at);
+
+CREATE TABLE IF NOT EXISTS satellite_recommendations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ticker TEXT NOT NULL,
+ generated_at TEXT NOT NULL,
+ satellite_action TEXT,
+ attractiveness_score REAL,
+ market_regime TEXT,
+ raw_json TEXT NOT NULL,
+ inserted_at TEXT DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS idx_satellite_ticker_time
+ ON satellite_recommendations(ticker, generated_at);
+"""
+
+
+@dataclass(frozen=True)
+class QualitativeSellStoreSpec(StoreSpec):
+ pass
+
+
+def default_qualitative_sell_store_path(root: Path) -> Path:
+ return default_sqlite_store_path(root, "qualitative_sell_strategy/qualitative_sell_strategy.db")
+
+
+def resolve_store_path(spec: QualitativeSellStoreSpec, root: Path) -> Path:
+ backend, location = normalize_store_spec(
+ spec,
+ root,
+ default_sqlite_name="qualitative_sell_strategy/qualitative_sell_strategy.db",
+ )
+ if backend != "sqlite":
+ raise ValueError(
+ "qualitative_sell_strategy_store_v1 currently executes on sqlite only; "
+ "the caller contract already allows future PostgreSQL swap-in."
+ )
+ return Path(location)
+
+
+def init_db(db_path: Path) -> None:
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.executescript(SCHEMA)
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def insert_sell_strategy_result(db_path: Path, result: dict[str, Any]) -> None:
+ """build_qualitative_sell_inputs_v1.process_one()의 반환값(dict)을 그대로 받는다."""
+ init_db(db_path)
+ decision = result.get("decision") or {}
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.execute(
+ "INSERT INTO sell_strategy_results "
+ "(code, generated_at, action, conviction, market_regime, composite_score, rationale, raw_json) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (
+ result.get("code"),
+ result.get("generated_at"),
+ decision.get("action"),
+ decision.get("conviction"),
+ decision.get("market_regime"),
+ decision.get("composite_score"),
+ decision.get("rationale"),
+ json.dumps(result, ensure_ascii=False, default=str),
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def insert_satellite_recommendation(db_path: Path, generated_at: str, candidate: dict[str, Any]) -> None:
+ """build_satellite_candidate_recommendations_v1.py results[i] 항목 하나를 받는다."""
+ init_db(db_path)
+ score = candidate.get("score") or {}
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.execute(
+ "INSERT INTO satellite_recommendations "
+ "(ticker, generated_at, satellite_action, attractiveness_score, market_regime, raw_json) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ (
+ candidate.get("ticker"),
+ generated_at,
+ score.get("satellite_action"),
+ score.get("attractiveness_score"),
+ score.get("market_regime"),
+ json.dumps(candidate, ensure_ascii=False, default=str),
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def fetch_recent_sell_strategy_results(db_path: Path, code: str, limit: int = 20) -> list[dict[str, Any]]:
+ if not db_path.exists():
+ return []
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ try:
+ rows = conn.execute(
+ "SELECT code, generated_at, action, conviction, market_regime, composite_score, rationale "
+ "FROM sell_strategy_results WHERE code = ? ORDER BY generated_at DESC LIMIT ?",
+ (code, limit),
+ ).fetchall()
+ return [dict(row) for row in rows]
+ finally:
+ conn.close()
diff --git a/src/quant_engine/qualitative_sell_strategy_v1.py b/src/quant_engine/qualitative_sell_strategy_v1.py
new file mode 100644
index 00000000..67858def
--- /dev/null
+++ b/src/quant_engine/qualitative_sell_strategy_v1.py
@@ -0,0 +1,377 @@
+from __future__ import annotations
+
+import math
+from datetime import date, timedelta
+from typing import Any
+
+# 매도 결정에 동원하는 5개 독립 팩터군. 단일 팩터의 임계값 돌파만으로는 행동을
+# 트리거하지 않는다 — 최소 CONFLUENCE_MIN개 팩터군이 동일 방향으로 합의해야
+# SELL/ADD 확신도가 성립한다. (기계적 단일 트리거 매도 금지 원칙)
+FACTOR_FAMILIES: tuple[str, ...] = (
+ "macro_pressure",
+ "fundamental_trajectory",
+ "short_interest_pressure",
+ "microstructure_pressure",
+ "liquidity_rotation_risk",
+)
+CONFLUENCE_MIN = 3
+EVENT_PRE_GUARD_DAYS = 5 # macro_event_synchronizer_v2.event_hold_gate와 동일 — HIGH 이벤트 5일 전
+EVENT_POST_GUARD_DAYS = 2 # 이벤트 후 2일 변동성 소화 구간
+
+# 금리국면별 시장 성격: 금리 상승기=실적장세(펀더멘털/수출입 실적이 가격을 주도),
+# 금리 보합·하락기=기술장세(수급·미시구조가 가격을 주도). 동일한 5팩터라도
+# 국면에 따라 가중치를 달리 줘야 confluence가 의미를 갖는다.
+REGIME_FLAT_WEIGHTS: dict[str, float] = {family: 1.0 for family in FACTOR_FAMILIES}
+REGIME_WEIGHT_TABLE: dict[str, dict[str, float]] = {
+ "PERFORMANCE_MARKET": { # 금리 상승기 — 실적/수출입 펀더멘털 가중 상향
+ "macro_pressure": 1.2,
+ "fundamental_trajectory": 1.8,
+ "short_interest_pressure": 1.0,
+ "microstructure_pressure": 0.5,
+ "liquidity_rotation_risk": 1.0,
+ },
+ "TECHNICAL_MARKET": { # 금리 보합·하락기 — 수급/미시구조 가중 상향
+ "macro_pressure": 0.8,
+ "fundamental_trajectory": 0.8,
+ "short_interest_pressure": 1.3,
+ "microstructure_pressure": 1.6,
+ "liquidity_rotation_risk": 1.3,
+ },
+ "NEUTRAL": REGIME_FLAT_WEIGHTS,
+}
+
+
+def classify_market_regime(rate_trend: str | None) -> str:
+ """금리 추세 문자열(RISING/FLAT/FALLING)을 실적장세/기술장세로 분류.
+
+ RISING → PERFORMANCE_MARKET(실적장세): 금리 상승기엔 유동성보다 실적/펀더멘털이
+ 가격을 결정. FLAT/FALLING → TECHNICAL_MARKET(기술장세): 유동성이 풍부해 수급·
+ 미시구조·테마성 모멘텀이 가격을 주도. 입력 결측 시 NEUTRAL(가중치 변화 없음).
+ """
+ trend = str(rate_trend or "").upper()
+ if trend == "RISING":
+ return "PERFORMANCE_MARKET"
+ if trend in {"FLAT", "FALLING"}:
+ return "TECHNICAL_MARKET"
+ return "NEUTRAL"
+
+
+def _finite(value: Any) -> bool:
+ return isinstance(value, (int, float)) and math.isfinite(float(value))
+
+
+def compute_short_interest_composite(ctx: dict[str, Any]) -> dict[str, Any]:
+ """SHORT_INTEREST_RISK_GAUGE_V1.
+
+ 5요소: 공매도잔고율 변화, 공매도거래비중, 상대수익률(섹터/지수 대비),
+ 거래량 이상, 실적전망. 잔고율 단독으로는 매도 근거가 약함(현대로템형) —
+ 잔고율이 낮을 때는 거래비중·상대수익률 가중치를 자동 상향한다.
+ """
+ missing: list[str] = []
+
+ short_balance_ratio = ctx.get("short_balance_ratio") # %, 현재 잔고율
+ short_balance_ratio_chg_20d = ctx.get("short_balance_ratio_chg_20d") # %p, 20일 변화
+ short_turnover_share = ctx.get("short_turnover_share") # 당일 거래 중 공매도 비중 %
+ relative_return_20d = ctx.get("relative_return_20d") # 종목수익률 - 섹터(or지수)수익률, %p
+ volume_ratio_5d = ctx.get("volume_ratio_5d") # 5일평균거래량 대비 비율
+ earnings_outlook = str(ctx.get("earnings_outlook") or "").upper() # IMPROVING|STABLE|DETERIORATING|UNKNOWN
+
+ for name, value in (
+ ("short_balance_ratio", short_balance_ratio),
+ ("short_turnover_share", short_turnover_share),
+ ("relative_return_20d", relative_return_20d),
+ ):
+ if not _finite(value):
+ missing.append(name)
+
+ if missing:
+ return {
+ "short_interest_pressure": None,
+ "status": "DATA_MISSING",
+ "missing_inputs": missing,
+ "note": "잔고율/거래비중/상대수익률 중 결측 — 공매도 합성 점수를 산출하지 않음(추정 금지)",
+ }
+
+ low_balance_regime = float(short_balance_ratio) < 1.0 # 잔고율 1% 미만이면 '낮은 잔고율' 취급(현대로템형)
+
+ # 잔고율 추세: 상승=매도근거 강화, 하락=매도근거 약화(혹은 매수근거)
+ balance_trend_signal = 0.0
+ if _finite(short_balance_ratio_chg_20d):
+ balance_trend_signal = max(-1.0, min(1.0, float(short_balance_ratio_chg_20d) / 1.5))
+
+ turnover_signal = max(-1.0, min(1.0, (float(short_turnover_share) - 8.0) / 12.0)) # 8% 기준선
+ relative_return_signal = max(-1.0, min(1.0, -float(relative_return_20d) / 10.0)) # 상대 약세일수록 +
+ volume_signal = 0.0
+ if _finite(volume_ratio_5d):
+ volume_signal = max(-1.0, min(1.0, (float(volume_ratio_5d) - 1.0)))
+
+ outlook_signal = {
+ "IMPROVING": -0.6,
+ "STABLE": 0.0,
+ "DETERIORATING": 0.7,
+ }.get(earnings_outlook, 0.0)
+
+ if low_balance_regime:
+ # 잔고율 자체는 약한 근거 — 거래비중·상대수익률 가중치 상향, 잔고율추세 가중치 하향
+ weights = {"balance": 0.10, "turnover": 0.30, "relative": 0.30, "volume": 0.10, "outlook": 0.20}
+ else:
+ weights = {"balance": 0.30, "turnover": 0.20, "relative": 0.20, "volume": 0.10, "outlook": 0.20}
+
+ pressure = (
+ balance_trend_signal * weights["balance"]
+ + turnover_signal * weights["turnover"]
+ + relative_return_signal * weights["relative"]
+ + volume_signal * weights["volume"]
+ + outlook_signal * weights["outlook"]
+ )
+ pressure = max(-1.0, min(1.0, pressure))
+
+ label = "ELEVATED_SHORT_PRESSURE" if pressure >= 0.5 else "WATCH" if pressure >= 0.2 else \
+ "SHORT_COVERING_SUPPORTIVE" if pressure <= -0.5 else "NEUTRAL"
+
+ return {
+ "short_interest_pressure": round(pressure, 4),
+ "status": "OK",
+ "low_balance_regime": low_balance_regime,
+ "label": label,
+ "components": {
+ "balance_trend_signal": round(balance_trend_signal, 4),
+ "turnover_signal": round(turnover_signal, 4),
+ "relative_return_signal": round(relative_return_signal, 4),
+ "volume_signal": round(volume_signal, 4),
+ "outlook_signal": outlook_signal,
+ },
+ "weights_used": weights,
+ }
+
+
+def compute_microstructure_pressure_from_orderbook(orderbook_output1: dict[str, Any]) -> dict[str, Any]:
+ """MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1.
+
+ KIS Open API FHKST01010200(주식현재가 호가/예상체결) output1의 10단계 호가 잔량을
+ -1(매수우위/지지)~+1(매도우위/압력)로 계량화. 실측 확인된 필드명(2026-06-21,
+ 005930 라이브 호출): total_askp_rsqn, total_bidp_rsqn(10단계 합계 잔량).
+ 이 점수는 전략 방향 결정에는 쓰지 않고 confluence가 성립한 이후의 '집행 타이밍'
+ 보조로만 사용한다(spec/exit/qualitative_sell_strategy_v1.yaml:factor_families.
+ microstructure_pressure 참조).
+ """
+ total_askp = orderbook_output1.get("total_askp_rsqn")
+ total_bidp = orderbook_output1.get("total_bidp_rsqn")
+ try:
+ total_askp = float(total_askp)
+ total_bidp = float(total_bidp)
+ except (TypeError, ValueError):
+ return {"microstructure_pressure": None, "status": "DATA_MISSING"}
+
+ denom = total_askp + total_bidp
+ if denom <= 0:
+ return {"microstructure_pressure": None, "status": "DATA_MISSING"}
+
+ pressure = max(-1.0, min(1.0, (total_askp - total_bidp) / denom))
+ return {
+ "microstructure_pressure": round(pressure, 4),
+ "status": "OK",
+ "total_askp_rsqn": total_askp,
+ "total_bidp_rsqn": total_bidp,
+ }
+
+
+def _event_review_window(
+ today: date,
+ pressure_sign: int,
+ next_earnings_date: date | None,
+ next_macro_event_date: date | None,
+ macro_event_impact: str | None,
+ earnings_outlook: str,
+) -> dict[str, Any]:
+ """캘린더 기반 검토 구간 산출 — 임의 날짜 고정이 아니라 실제 이벤트 일정에서 역산."""
+ candidates: list[tuple[date, str]] = []
+
+ if next_earnings_date is not None:
+ if pressure_sign < 0 and earnings_outlook == "DETERIORATING":
+ # 실적 악화 전망 + 매도압력 → 실적발표 전 정리(서프라이즈 리스크 회피)
+ candidates.append((next_earnings_date - timedelta(days=EVENT_PRE_GUARD_DAYS), "PRE_EARNINGS_EXIT_BEFORE_SURPRISE_RISK"))
+ elif pressure_sign < 0 and earnings_outlook in {"IMPROVING", "STABLE"}:
+ # 단기 기술적 매도압력이지만 실적전망은 양호 → 발표 직전 매도는 가치훼손, 발표 이후로 연기
+ candidates.append((next_earnings_date + timedelta(days=EVENT_POST_GUARD_DAYS), "DEFER_TO_POST_EARNINGS_AVOID_PREMATURE_EXIT"))
+ elif pressure_sign > 0:
+ # 추가매수/보유 신호 — 발표 변동성 통과 후 확신 재평가
+ candidates.append((next_earnings_date + timedelta(days=EVENT_POST_GUARD_DAYS), "REASSESS_AFTER_EARNINGS_CONFIRM"))
+
+ if next_macro_event_date is not None and str(macro_event_impact or "").upper() in {"HIGH", "VERY_HIGH"}:
+ if pressure_sign < 0:
+ candidates.append((next_macro_event_date - timedelta(days=EVENT_PRE_GUARD_DAYS), "PRE_MACRO_EVENT_DERISK"))
+ else:
+ candidates.append((next_macro_event_date + timedelta(days=EVENT_POST_GUARD_DAYS), "POST_MACRO_EVENT_CONFIRM"))
+
+ if not candidates:
+ return {
+ "review_window_start": today.isoformat(),
+ "review_window_end": (today + timedelta(days=10)).isoformat(),
+ "window_basis": "NO_SCHEDULED_EVENT_DEFAULT_10D_REVIEW",
+ }
+
+ earliest = min(candidates, key=lambda item: item[0])
+ window_start = max(today, earliest[0] - timedelta(days=2))
+ window_end = earliest[0] + timedelta(days=2)
+ return {
+ "review_window_start": window_start.isoformat(),
+ "review_window_end": window_end.isoformat(),
+ "window_basis": earliest[1],
+ }
+
+
+def compute_qualitative_sell_strategy(ctx: dict[str, Any]) -> dict[str, Any]:
+ """QUALITATIVE_SELL_STRATEGY_V1.
+
+ 매크로/실적/펀더멘털/공매도수급/호가미시구조/대내외(IPO·로테이션) 5개
+ 독립 팩터군의 합의(confluence)로만 행동을 생성한다. 현금부족 사유는
+ 입력에서 의도적으로 배제(cash_shortfall_excluded=True) — 가치보존이
+ 유일한 목적 함수.
+ """
+ today_raw = ctx.get("today")
+ today = today_raw if isinstance(today_raw, date) else date.today()
+
+ factor_values: dict[str, float | None] = {}
+ missing_factors: list[str] = []
+ for family in FACTOR_FAMILIES:
+ value = ctx.get(family)
+ if _finite(value):
+ factor_values[family] = max(-1.0, min(1.0, float(value)))
+ else:
+ factor_values[family] = None
+ missing_factors.append(family)
+
+ available = {k: v for k, v in factor_values.items() if v is not None}
+ if len(available) < CONFLUENCE_MIN:
+ return {
+ "action": "INSUFFICIENT_DATA_NO_ACTION",
+ "conviction": "NONE",
+ "available_factors": list(available.keys()),
+ "missing_factors": missing_factors,
+ "rationale": "5개 팩터군 중 confluence 판정에 필요한 최소 데이터가 부족 — 추정으로 행동 생성 금지",
+ "cash_shortfall_excluded": True,
+ "mechanical_sell_prohibited": True,
+ }
+
+ # 부호 규약: 모든 팩터군은 +1(매도압력 최대) ~ -1(보유/추가 지지 최대) 동일 스케일.
+ # short_interest_pressure도 동일 — ELEVATED_SHORT_PRESSURE(+) / SHORT_COVERING_SUPPORTIVE(-).
+ # confluence 합의 카운트는 국면 가중치와 무관하게 원시 방향성으로만 판정한다
+ # (가중치는 행동 '강도'에만 영향 — 합의 성립 여부 자체를 왜곡하지 않는다).
+ sell_agree = [k for k, v in available.items() if v >= 0.30]
+ hold_add_agree = [k for k, v in available.items() if v <= -0.30]
+
+ market_regime = classify_market_regime(ctx.get("rate_trend")) if "market_regime" not in ctx else str(ctx.get("market_regime") or "NEUTRAL").upper()
+ regime_weights = REGIME_WEIGHT_TABLE.get(market_regime, REGIME_FLAT_WEIGHTS)
+ weighted_sum = sum(available[k] * regime_weights.get(k, 1.0) for k in available)
+ weight_total = sum(regime_weights.get(k, 1.0) for k in available)
+ composite_score = weighted_sum / weight_total if weight_total else 0.0
+
+ earnings_outlook = str(ctx.get("earnings_outlook") or "STABLE").upper()
+ next_earnings_date = ctx.get("next_earnings_date") if isinstance(ctx.get("next_earnings_date"), date) else None
+ next_macro_event_date = ctx.get("next_macro_event_date") if isinstance(ctx.get("next_macro_event_date"), date) else None
+ macro_event_impact = ctx.get("macro_event_impact")
+
+ if len(sell_agree) >= CONFLUENCE_MIN:
+ conviction = "HIGH" if len(sell_agree) >= 4 else "MEDIUM"
+ action = "EXIT_REVIEW_FULL" if composite_score >= 0.6 else "TRIM_REVIEW_PARTIAL"
+ pressure_sign = -1
+ rationale = f"매도압력 합의({len(sell_agree)}/{len(available)} 팩터군 매도방향 합치): " + ", ".join(sell_agree)
+ elif len(hold_add_agree) >= CONFLUENCE_MIN:
+ conviction = "HIGH" if len(hold_add_agree) >= 4 else "MEDIUM"
+ action = "HOLD_ADD_CONVICTION"
+ pressure_sign = 1
+ rationale = f"보유/추가 근거 합의({len(hold_add_agree)}/{len(available)} 팩터군 지지방향 합치): " + ", ".join(hold_add_agree)
+ else:
+ conviction = "LOW"
+ action = "HOLD_NO_CONFLUENCE"
+ pressure_sign = 0
+ rationale = "팩터군 간 합의 미달 — 단일/소수 팩터의 임계값 돌파만으로는 매도 트리거 금지"
+
+ window = _event_review_window(
+ today=today,
+ pressure_sign=pressure_sign,
+ next_earnings_date=next_earnings_date,
+ next_macro_event_date=next_macro_event_date,
+ macro_event_impact=macro_event_impact,
+ earnings_outlook=earnings_outlook,
+ ) if pressure_sign != 0 else None
+
+ return {
+ "action": action,
+ "conviction": conviction,
+ "market_regime": market_regime,
+ "composite_score": round(composite_score, 4),
+ "sell_agreeing_factors": sell_agree,
+ "hold_add_agreeing_factors": hold_add_agree,
+ "missing_factors": missing_factors,
+ "review_window": window,
+ "rationale": rationale,
+ "cash_shortfall_excluded": True,
+ "mechanical_sell_prohibited": True,
+ }
+
+
+def compute_satellite_candidate_score(ctx: dict[str, Any]) -> dict[str, Any]:
+ """SATELLITE_CANDIDATE_SCORE_V1.
+
+ 미보유 유니버스 종목을 섹터 수출입 전망(sector_export_trend) + 펀더멘털
+ 추세 + 국면적합도로 평가해 WATCH/BUY_CANDIDATE/AVOID를 산출한다. 보유종목
+ 매도판단(compute_qualitative_sell_strategy)과 동일한 부호 규약을 쓰지 않고
+ 별도 -1(약세)~+1(강세) 매력도 스케일을 쓴다 — 매수후보 평가와 매도판단은
+ 목적함수가 다르므로 동일 점수를 재사용하지 않는다.
+ """
+ sector_export_trend = ctx.get("sector_export_trend") # %, 섹터 수출 YoY/MoM 추세
+ fundamental_trajectory = ctx.get("fundamental_trajectory") # -1(악화)~+1(개선), 매도엔진과 동일 정의역이나 부호 반대 해석 주의
+ relative_return_20d = ctx.get("relative_return_20d")
+ market_regime = str(ctx.get("market_regime") or classify_market_regime(ctx.get("rate_trend"))).upper()
+
+ missing = [name for name, value in (
+ ("sector_export_trend", sector_export_trend),
+ ("fundamental_trajectory", fundamental_trajectory),
+ ) if not _finite(value)]
+ if missing:
+ return {
+ "satellite_action": "INSUFFICIENT_DATA_NO_ACTION",
+ "missing_inputs": missing,
+ "market_regime": market_regime,
+ }
+
+ export_signal = max(-1.0, min(1.0, float(sector_export_trend) / 10.0))
+ fundamental_signal = max(-1.0, min(1.0, -float(fundamental_trajectory))) # 매도엔진 부호(+)=악화 -> 매력도는 반전
+ relative_signal = max(-1.0, min(1.0, float(relative_return_20d) / 10.0)) if _finite(relative_return_20d) else 0.0
+
+ if market_regime == "PERFORMANCE_MARKET":
+ weights = {"export": 0.45, "fundamental": 0.40, "relative": 0.15}
+ elif market_regime == "TECHNICAL_MARKET":
+ weights = {"export": 0.20, "fundamental": 0.25, "relative": 0.55}
+ else:
+ weights = {"export": 0.34, "fundamental": 0.33, "relative": 0.33}
+
+ attractiveness = (
+ export_signal * weights["export"]
+ + fundamental_signal * weights["fundamental"]
+ + relative_signal * weights["relative"]
+ )
+ attractiveness = max(-1.0, min(1.0, attractiveness))
+
+ if attractiveness >= 0.5:
+ satellite_action = "BUY_CANDIDATE"
+ elif attractiveness >= 0.2:
+ satellite_action = "WATCH"
+ elif attractiveness <= -0.4:
+ satellite_action = "AVOID"
+ else:
+ satellite_action = "NEUTRAL_NO_EDGE"
+
+ return {
+ "satellite_action": satellite_action,
+ "attractiveness_score": round(attractiveness, 4),
+ "market_regime": market_regime,
+ "components": {
+ "export_signal": round(export_signal, 4),
+ "fundamental_signal": round(fundamental_signal, 4),
+ "relative_signal": round(relative_signal, 4),
+ },
+ "weights_used": weights,
+ }
diff --git a/src/quant_engine/snapshot_admin_server_v1.py b/src/quant_engine/snapshot_admin_server_v1.py
new file mode 100644
index 00000000..a9f26639
--- /dev/null
+++ b/src/quant_engine/snapshot_admin_server_v1.py
@@ -0,0 +1,3007 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sqlite3
+import subprocess
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from hashlib import sha256
+from typing import Any
+from urllib.parse import urlparse, parse_qs
+
+ROOT = Path(__file__).resolve().parents[2]
+SNAPSHOT_ADMIN_VERSION = "snapshot-admin-web-v6"
+KIS_COLLECTION_DB = ROOT / "outputs" / "kis_data_collection" / "kis_data_collection.db"
+KIS_COLLECTION_REPORT = ROOT / "Temp" / "kis_data_collection_v1.json"
+QUALITATIVE_SELL_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db"
+
+# WBS-7.9 부속 — 테이블별 그리드 조회(Tabler). 화이트리스트에 없는 테이블명은
+# SQL에 절대 보간되지 않는다(요청 테이블명을 그대로 SELECT 문에 넣지 않고
+# 아래 레지스트리 키와 정확히 일치할 때만 허용).
+WORKSPACE_BROWSABLE_TABLES = (
+ "settings",
+ "account_snapshot",
+ "workspace_change_log",
+ "workspace_approval_v2",
+ "workspace_lock",
+ "workspace_meta",
+)
+COLLECTION_BROWSABLE_TABLES = (
+ "collection_runs",
+ "collection_snapshots",
+ "collection_source_errors",
+)
+QUALITATIVE_SELL_BROWSABLE_TABLES = (
+ "sell_strategy_results",
+ "satellite_recommendations",
+)
+
+
+def _resolve_table_db(table: str, workspace_db_path: Path) -> Path | None:
+ if table in WORKSPACE_BROWSABLE_TABLES:
+ return Path(workspace_db_path)
+ if table in COLLECTION_BROWSABLE_TABLES:
+ return KIS_COLLECTION_DB
+ if table in QUALITATIVE_SELL_BROWSABLE_TABLES:
+ return QUALITATIVE_SELL_DB
+ return None
+
+
+def list_browsable_tables(workspace_db_path: Path) -> list[dict[str, Any]]:
+ tables: list[dict[str, Any]] = []
+ for table in (
+ *WORKSPACE_BROWSABLE_TABLES,
+ *COLLECTION_BROWSABLE_TABLES,
+ *QUALITATIVE_SELL_BROWSABLE_TABLES,
+ ):
+ db_path = _resolve_table_db(table, workspace_db_path)
+ exists = bool(db_path and db_path.exists())
+ row_count = 0
+ if exists:
+ try:
+ with sqlite3.connect(db_path) as conn:
+ row_count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] # noqa: S608 - table is whitelist-checked above
+ except sqlite3.OperationalError:
+ exists = False
+ tables.append({"table": table, "db": str(db_path) if db_path else "", "exists": exists, "row_count": row_count})
+ return tables
+
+
+def fetch_table_rows(table: str, workspace_db_path: Path, *, limit: int = 50, offset: int = 0) -> dict[str, Any]:
+ db_path = _resolve_table_db(table, workspace_db_path)
+ if db_path is None:
+ raise ValueError(f"unknown or non-browsable table: {table}")
+ if not db_path.exists():
+ return {"table": table, "db": str(db_path), "columns": [], "rows": [], "total": 0, "limit": limit, "offset": offset}
+ with sqlite3.connect(db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ total = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] # noqa: S608 - whitelisted table name
+ cursor = conn.execute(
+ f"SELECT * FROM {table} ORDER BY rowid DESC LIMIT ? OFFSET ?", # noqa: S608 - whitelisted table name
+ (limit, offset),
+ )
+ rows = [dict(row) for row in cursor.fetchall()]
+ columns = [description[0] for description in cursor.description] if cursor.description else []
+ return {"table": table, "db": str(db_path), "columns": columns, "rows": rows, "total": total, "limit": limit, "offset": offset}
+SNAPSHOT_ADMIN_VERSION_FILES = (
+ ROOT / "src" / "quant_engine" / "snapshot_admin_server_v1.py",
+ ROOT / "src" / "quant_engine" / "snapshot_admin_store_v1.py",
+ ROOT / "src" / "quant_engine" / "data_collection_store_v1.py",
+ ROOT / "tools" / "run_snapshot_admin_server_v1.py",
+ ROOT / "tools" / "validate_snapshot_admin_web_v1.py",
+ ROOT / "tests" / "unit" / "test_snapshot_admin_web_v1.py",
+ ROOT / "package.json",
+)
+
+from .snapshot_admin_store_v1 import (
+ ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS,
+ DEFAULT_DB,
+ DEFAULT_SEED_JSON,
+ export_payload,
+ clear_lock,
+ import_seed_json,
+ is_locked,
+ load_account_snapshot_rows,
+ load_approval_for_domain,
+ load_approval_rows,
+ load_change_log_rows,
+ load_locks,
+ load_settings_rows,
+ normalize_db_path,
+ now_kst_iso,
+ open_connection,
+ parse_account_snapshot_tsv,
+ parse_scalar,
+ record_change_log,
+ validate_account_snapshot_rows,
+ validate_settings_rows,
+ build_validation_suggestions,
+ build_safe_autofix_actions,
+ apply_safe_autofix_action,
+ lock_conflicts_for_rows,
+ set_approval,
+ set_lock,
+ replace_account_snapshot,
+ replace_settings,
+ undo_last_change,
+ summarize_workspace,
+)
+from .data_collection_store_v1 import load_collection_dashboard_state
+
+
+def _strip_internal_fields(row: dict[str, Any]) -> dict[str, Any]:
+ return {key: value for key, value in row.items() if not key.startswith("_")}
+
+
+def _snapshot_columns_from_rows(rows: list[dict[str, Any]]) -> list[str]:
+ columns = list(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS)
+ extras = sorted(
+ {
+ key
+ for row in rows
+ for key in row.keys()
+ if not key.startswith("_") and key not in ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS
+ }
+ )
+ for key in extras:
+ if key not in columns:
+ columns.append(key)
+ return columns
+
+
+def _write_json(path: Path, payload: dict[str, Any]) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ return path
+
+
+def _render_approval_packet_md(packet: dict[str, Any]) -> str:
+ pending = packet.get("pending_targets") if isinstance(packet.get("pending_targets"), list) else []
+ summary = packet.get("summary") if isinstance(packet.get("summary"), dict) else {}
+ lines = [
+ "# Snapshot Admin Approval Packet",
+ "",
+ "## Summary",
+ "",
+ f"- settings_changed: {summary.get('settings_changed', 0)}",
+ f"- account_snapshot_changed: {summary.get('account_snapshot_changed', 0)}",
+ f"- pending_target_count: {summary.get('pending_target_count', 0)}",
+ "",
+ "## Pending Targets",
+ "",
+ ]
+ if pending:
+ for item in pending[:100]:
+ if not isinstance(item, dict):
+ continue
+ lines.append(f"- {item.get('domain', '')}:{item.get('target_ref', '')} ({item.get('change_type', '')})")
+ else:
+ lines.append("_none_")
+ return "\n".join(lines)
+
+
+def write_approval_packet_artifacts(packet: dict[str, Any]) -> dict[str, str]:
+ json_path = ROOT / "Temp" / "snapshot_admin_approval_packet_v1.json"
+ md_path = ROOT / "Temp" / "snapshot_admin_approval_packet_v1.md"
+ _write_json(json_path, packet)
+ md_path.parent.mkdir(parents=True, exist_ok=True)
+ md_path.write_text(_render_approval_packet_md(packet), encoding="utf-8")
+ return {"json_path": str(json_path), "md_path": str(md_path)}
+
+
+def _git_info() -> dict[str, Any]:
+ try:
+ commit = subprocess.check_output(
+ ["git", "rev-parse", "--short", "HEAD"],
+ cwd=str(ROOT),
+ text=True,
+ stderr=subprocess.DEVNULL,
+ ).strip()
+ status = subprocess.check_output(
+ ["git", "status", "--porcelain"],
+ cwd=str(ROOT),
+ text=True,
+ stderr=subprocess.DEVNULL,
+ )
+ return {
+ "commit": commit,
+ "dirty": bool(status.strip()),
+ "tree_state": "DIRTY" if status.strip() else "CLEAN",
+ }
+ except Exception:
+ return {
+ "commit": "",
+ "dirty": False,
+ "tree_state": "UNKNOWN",
+ }
+
+
+def _source_fingerprint() -> dict[str, Any]:
+ digest = sha256()
+ latest_mtime = 0.0
+ for path in SNAPSHOT_ADMIN_VERSION_FILES:
+ if not path.exists():
+ continue
+ try:
+ data = path.read_bytes()
+ digest.update(path.as_posix().encode("utf-8"))
+ digest.update(b"\0")
+ digest.update(data)
+ latest_mtime = max(latest_mtime, path.stat().st_mtime)
+ except OSError:
+ continue
+ return {
+ "fingerprint": digest.hexdigest()[:16],
+ "latest_mtime": latest_mtime,
+ }
+
+
+def _approval_entry_from_conn(conn, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
+ ensure_schema(conn)
+ row = conn.execute(
+ f"""
+ SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at
+ FROM {APPROVAL_TABLE}
+ WHERE domain = ? AND target_ref = ?
+ LIMIT 1
+ """,
+ (domain, target_ref or "*"),
+ ).fetchone()
+ return dict(row) if row is not None else None
+
+
+def _lock_entry_from_conn(conn, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
+ ensure_schema(conn)
+ row = conn.execute(
+ f"""
+ SELECT domain, target_ref, locked_by, reason, locked_at
+ FROM {LOCK_TABLE}
+ WHERE domain = ? AND target_ref = ?
+ LIMIT 1
+ """,
+ (domain, target_ref or "*"),
+ ).fetchone()
+ return dict(row) if row is not None else None
+
+
+def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]:
+ summary = summarize_workspace(db_path)
+ settings_rows = load_settings_rows(db_path)
+ account_rows = [_strip_internal_fields(row) for row in load_account_snapshot_rows(db_path)]
+ settings_errors = validate_settings_rows(settings_rows)
+ snapshot_errors = validate_account_snapshot_rows(account_rows)
+ suggestions = build_validation_suggestions(settings_rows, account_rows)
+ autofix_actions = build_safe_autofix_actions(settings_rows, account_rows)
+ collection = load_collection_dashboard_state(KIS_COLLECTION_DB, KIS_COLLECTION_REPORT)
+ return {
+ "version": {
+ "app": SNAPSHOT_ADMIN_VERSION,
+ "git": _git_info(),
+ "source": _source_fingerprint(),
+ },
+ "summary": summary,
+ "approval_rows": load_approval_rows(db_path),
+ "approval_settings": load_approval_for_domain(db_path, "settings"),
+ "approval_account_snapshot": load_approval_for_domain(db_path, "account_snapshot"),
+ "locks": load_locks(db_path),
+ "recent_changes": load_change_log_rows(db_path, limit=12),
+ "history_counts": {
+ "changes": len(load_change_log_rows(db_path, limit=200)),
+ "approvals": len(load_approval_rows(db_path)),
+ "locks": len(load_locks(db_path)),
+ },
+ "settings_rows": settings_rows,
+ "account_snapshot_rows": account_rows,
+ "account_snapshot_columns": _snapshot_columns_from_rows(account_rows),
+ "validation": {
+ "settings": settings_errors,
+ "account_snapshot": snapshot_errors,
+ "suggestions": suggestions,
+ },
+ "autofix_actions": autofix_actions,
+ "collection": collection,
+ "generated_at": now_kst_iso(),
+ }
+
+
+def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None:
+ body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
+ handler.send_response(status)
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
+ handler.send_header("Content-Length", str(len(body)))
+ handler.end_headers()
+ handler.wfile.write(body)
+
+
+def _text_response(handler: BaseHTTPRequestHandler, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None:
+ body = text.encode("utf-8")
+ handler.send_response(status)
+ handler.send_header("Content-Type", content_type)
+ handler.send_header("Content-Length", str(len(body)))
+ handler.end_headers()
+ handler.wfile.write(body)
+
+
+def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
+ length = int(handler.headers.get("Content-Length") or "0")
+ raw = handler.rfile.read(length).decode("utf-8") if length else "{}"
+ payload = json.loads(raw or "{}")
+ if not isinstance(payload, dict):
+ raise ValueError("JSON body must be an object")
+ return payload
+
+
+def render_index_html() -> str:
+ return """
+
+
+
+
+ Snapshot Admin
+
+
+
+
+ Snapshot Admin
+ SQLite canonical editor for settings and account_snapshot. Save via API only; xlsx stays as export surface.
+
+
+
+
+
+
+
+ Workspace
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
+ Validation
+
+ Suggestions
+
+
+
+ Diff preview
+
+
+
+
+
+
+
+
+ Approval & Locks
+
+
+
+
+
+
+
+
+
+
+
+
+ settings approval
+ snapshot approval
+
+
+
+
+
+
+ Recent change log
+
+
+
+
+
+ Timeline
+
+
+
+
+
+
+
+
+ KIS Collection
+
+
+
+
+
+
+
+
+ collection: loading...
+
+
+
+
+
+
+
+
+ Recent collector runs
+
+ Recent collector snapshots
+
+ Recent collector errors
+
+ Collection detail
+
+
+
+
+
+
+
+
+ Selection Inspector
+
+
+
+
+
+
+
+
+
+
+
+ No row selected.
+
+ Recent row history
+
+
+
+ Batch paste
+
+
+
+
+
+ Tip: clipboard paste still works directly in the grid. This panel is for multi-row batch edit against the selected row.
+ Shortcuts: `Ctrl+S` save current domain, `Ctrl+Enter` save current domain, `Delete` remove selected row.
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account Snapshot
+
+
+
+
+
+
+ Paste TSV below and replace all rows
+ Canonical column order follows spec/15_account_snapshot_contract.yaml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def render_collection_html() -> str:
+ return """
+
+
+
+
+ KIS Collection Dashboard
+
+
+
+
+ KIS Collection Dashboard
+ Separate read-only view for KIS collection run, snapshots, errors, and raw JSON evidence.
+
+
+
+
+
+ collection: loading...
+
+
+
+ Back to workspace
+ Open table browser
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Recent collector runs
+
+ Recent collector snapshots
+
+ Recent collector errors
+
+
+
+ Collection detail
+
+
+
+
+
+
+
+
+
+"""
+
+
+def render_tables_html() -> str:
+ return """
+
+
+
+
+ Snapshot Admin — Table Browser
+
+
+
+
+
+
+ Snapshot Admin — Table Browser
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+class SnapshotAdminHandler(BaseHTTPRequestHandler):
+ db_path: Path = DEFAULT_DB
+ seed_json_path: Path = DEFAULT_SEED_JSON
+
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
+ return
+
+ def _handle_exception(self, exc: Exception) -> None:
+ _json_response(self, HTTPStatus.INTERNAL_SERVER_ERROR, {"detail": str(exc)})
+
+ def do_GET(self) -> None: # noqa: N802
+ parsed = urlparse(self.path)
+ if parsed.path == "/":
+ _text_response(self, HTTPStatus.OK, render_index_html(), "text/html; charset=utf-8")
+ return
+ if parsed.path == "/collection":
+ _text_response(self, HTTPStatus.OK, render_collection_html(), "text/html; charset=utf-8")
+ return
+ if parsed.path == "/tables":
+ _text_response(self, HTTPStatus.OK, render_tables_html(), "text/html; charset=utf-8")
+ return
+ if parsed.path == "/api/tables":
+ _json_response(self, HTTPStatus.OK, {"tables": list_browsable_tables(self.db_path)})
+ return
+ if parsed.path == "/api/table_rows":
+ query = parse_qs(parsed.query)
+ table = (query.get("table") or [""])[0]
+ try:
+ limit = int((query.get("limit") or ["50"])[0])
+ offset = int((query.get("offset") or ["0"])[0])
+ except ValueError:
+ _json_response(self, HTTPStatus.BAD_REQUEST, {"detail": "limit/offset must be integers"})
+ return
+ limit = min(max(limit, 1), 500)
+ offset = max(offset, 0)
+ try:
+ payload = fetch_table_rows(table, self.db_path, limit=limit, offset=offset)
+ except ValueError as exc:
+ _json_response(self, HTTPStatus.BAD_REQUEST, {"detail": str(exc)})
+ return
+ _json_response(self, HTTPStatus.OK, payload)
+ return
+ if parsed.path == "/api/state":
+ _json_response(self, HTTPStatus.OK, build_ui_state(self.db_path))
+ return
+ if parsed.path == "/api/history":
+ _json_response(
+ self,
+ HTTPStatus.OK,
+ {
+ "settings": load_change_log_rows(self.db_path, limit=25),
+ "approvals": load_approval_rows(self.db_path),
+ "locks": load_locks(self.db_path),
+ },
+ )
+ return
+ if parsed.path == "/api/export":
+ _text_response(
+ self,
+ HTTPStatus.OK,
+ json.dumps(export_payload(self.db_path), ensure_ascii=False, indent=2),
+ "application/json; charset=utf-8",
+ )
+ return
+ if parsed.path == "/favicon.ico":
+ _text_response(self, HTTPStatus.NO_CONTENT, "")
+ return
+ _json_response(self, HTTPStatus.NOT_FOUND, {"detail": "not found"})
+
+ def do_POST(self) -> None: # noqa: N802
+ parsed = urlparse(self.path)
+ try:
+ if parsed.path == "/api/bootstrap":
+ summary = import_seed_json(self.db_path, self.seed_json_path)
+ _json_response(self, HTTPStatus.OK, summary)
+ return
+ payload = _read_json_body(self)
+ if parsed.path == "/api/settings/save":
+ if is_locked(self.db_path, "settings"):
+ raise ValueError("settings are locked")
+ rows = payload.get("rows")
+ if not isinstance(rows, list):
+ raise ValueError("rows must be a list")
+ normalized_rows = []
+ for idx, row in enumerate(rows, start=1):
+ if not isinstance(row, dict):
+ continue
+ key = str(row.get("key") or "").strip()
+ if not key:
+ continue
+ normalized_rows.append(
+ {
+ "ordinal": idx,
+ "key": key,
+ "value": row.get("value", ""),
+ "note": str(row.get("note") or ""),
+ }
+ )
+ conflicts = lock_conflicts_for_rows(self.db_path, "settings", normalized_rows)
+ if conflicts:
+ refs = ", ".join(sorted({str(item.get("target_ref") or "") for item in conflicts if item.get("target_ref")}))
+ raise ValueError(f"settings lock conflict: {refs}")
+ with open_connection(self.db_path) as conn:
+ replace_settings(conn, normalized_rows)
+ _json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
+ return
+ if parsed.path == "/api/account_snapshot/save":
+ if is_locked(self.db_path, "account_snapshot"):
+ raise ValueError("account_snapshot is locked")
+ rows = payload.get("rows")
+ if not isinstance(rows, list):
+ raise ValueError("rows must be a list")
+ normalized_rows: list[dict[str, Any]] = []
+ for idx, row in enumerate(rows, start=1):
+ if not isinstance(row, dict):
+ continue
+ candidate = {key: value for key, value in row.items() if not key.startswith("_")}
+ candidate["ordinal"] = idx
+ normalized_rows.append(candidate)
+ conflicts = lock_conflicts_for_rows(self.db_path, "account_snapshot", normalized_rows)
+ if conflicts:
+ refs = ", ".join(sorted({str(item.get("target_ref") or "") for item in conflicts if item.get("target_ref")}))
+ raise ValueError(f"account_snapshot lock conflict: {refs}")
+ with open_connection(self.db_path) as conn:
+ replace_account_snapshot(conn, normalized_rows)
+ _json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
+ return
+ if parsed.path == "/api/account_snapshot/import_tsv":
+ if is_locked(self.db_path, "account_snapshot"):
+ raise ValueError("account_snapshot is locked")
+ tsv_text = str(payload.get("tsv") or "")
+ rows = parse_account_snapshot_tsv(tsv_text)
+ with open_connection(self.db_path) as conn:
+ replace_account_snapshot(conn, rows)
+ _json_response(self, HTTPStatus.OK, summarize_workspace(self.db_path))
+ return
+ if parsed.path == "/api/approval_packet":
+ packet = payload.get("packet")
+ if not isinstance(packet, dict):
+ raise ValueError("packet must be an object")
+ artifacts = write_approval_packet_artifacts(packet)
+ response = {
+ "gate": "PASS",
+ "packet_path": artifacts["json_path"],
+ "md_path": artifacts["md_path"],
+ "formula_id": packet.get("formula_id", "SNAPSHOT_ADMIN_APPROVAL_PACKET_V1"),
+ }
+ _json_response(self, HTTPStatus.OK, response)
+ return
+ if parsed.path == "/api/approve":
+ domain = str(payload.get("domain") or "")
+ if domain not in {"settings", "account_snapshot"}:
+ raise ValueError("domain must be settings or account_snapshot")
+ target_ref = str(payload.get("target_ref") or "*")
+ with open_connection(self.db_path) as conn:
+ before = _approval_entry_from_conn(conn, domain, target_ref)
+ set_approval(conn, domain, "APPROVED", target_ref=target_ref, approved_by="ui", note="manual approval")
+ after = _approval_entry_from_conn(conn, domain, target_ref)
+ record_change_log(
+ conn,
+ domain=domain,
+ action="approve",
+ target_ref=target_ref,
+ before_json=before,
+ after_json=after,
+ actor="ui",
+ note="manual approval",
+ )
+ conn.commit()
+ _json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "APPROVED"})
+ return
+ if parsed.path == "/api/lock":
+ domain = str(payload.get("domain") or "")
+ target_ref = str(payload.get("target_ref") or "*")
+ if domain not in {"settings", "account_snapshot"}:
+ raise ValueError("domain must be settings or account_snapshot")
+ with open_connection(self.db_path) as conn:
+ before = _lock_entry_from_conn(conn, domain, target_ref)
+ set_lock(conn, domain, target_ref, locked_by="ui", reason="manual lock")
+ after = _lock_entry_from_conn(conn, domain, target_ref)
+ record_change_log(
+ conn,
+ domain=domain,
+ action="lock",
+ target_ref=target_ref,
+ before_json=before,
+ after_json=after,
+ actor="ui",
+ note="manual lock",
+ )
+ conn.commit()
+ _json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "LOCKED"})
+ return
+ if parsed.path == "/api/unlock":
+ domain = str(payload.get("domain") or "")
+ target_ref = str(payload.get("target_ref") or "*")
+ if domain not in {"settings", "account_snapshot"}:
+ raise ValueError("domain must be settings or account_snapshot")
+ with open_connection(self.db_path) as conn:
+ before = _lock_entry_from_conn(conn, domain, target_ref)
+ clear_lock(conn, domain, target_ref)
+ after = _lock_entry_from_conn(conn, domain, target_ref)
+ record_change_log(
+ conn,
+ domain=domain,
+ action="unlock",
+ target_ref=target_ref,
+ before_json=before,
+ after_json=after,
+ actor="ui",
+ note="manual unlock",
+ )
+ conn.commit()
+ _json_response(self, HTTPStatus.OK, {"domain": domain, "target_ref": target_ref, "status": "UNLOCKED"})
+ return
+ if parsed.path == "/api/undo":
+ domain = str(payload.get("domain") or "")
+ if domain not in {"settings", "account_snapshot"}:
+ raise ValueError("domain must be settings or account_snapshot")
+ if is_locked(self.db_path, domain):
+ raise ValueError(f"{domain} is locked")
+ with open_connection(self.db_path) as conn:
+ result = undo_last_change(conn, domain, actor="ui")
+ _json_response(self, HTTPStatus.OK, result if result else {"domain": domain, "status": "UNDONE"})
+ return
+ if parsed.path == "/api/autofix":
+ action_id = str(payload.get("action_id") or "")
+ if not action_id:
+ raise ValueError("action_id required")
+ with open_connection(self.db_path) as conn:
+ result = apply_safe_autofix_action(conn, action_id, actor="ui")
+ _json_response(self, HTTPStatus.OK, result)
+ return
+ _json_response(self, HTTPStatus.NOT_FOUND, {"detail": "not found"})
+ except Exception as exc: # noqa: BLE001
+ self._handle_exception(exc)
+
+
+def serve(host: str, port: int, db_path: Path | str | None = None, seed_json_path: Path | str | None = None, bootstrap: bool = True) -> None:
+ db = normalize_db_path(db_path)
+ seed = Path(seed_json_path) if seed_json_path else DEFAULT_SEED_JSON
+ if bootstrap and seed.exists():
+ with open_connection(db) as conn:
+ from .snapshot_admin_store_v1 import ensure_schema
+
+ ensure_schema(conn)
+ if summarize_workspace(db)["settings_rows"] == 0 and summarize_workspace(db)["account_snapshot_rows"] == 0:
+ import_seed_json(db, seed)
+ SnapshotAdminHandler.db_path = db
+ SnapshotAdminHandler.seed_json_path = seed
+ server = ThreadingHTTPServer((host, port), SnapshotAdminHandler)
+ print(f"Snapshot Admin listening on http://{host}:{port}")
+ print(f"SQLite DB: {db}")
+ print(f"Seed JSON: {seed}")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Run the snapshot admin web server.")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8787)
+ parser.add_argument("--db", type=Path, default=DEFAULT_DB)
+ parser.add_argument("--seed", type=Path, default=DEFAULT_SEED_JSON)
+ parser.add_argument("--no-bootstrap", action="store_true")
+ args = parser.parse_args()
+ serve(args.host, args.port, args.db, args.seed, bootstrap=not args.no_bootstrap)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/quant_engine/snapshot_admin_store_v1.py b/src/quant_engine/snapshot_admin_store_v1.py
new file mode 100644
index 00000000..4b40b0f8
--- /dev/null
+++ b/src/quant_engine/snapshot_admin_store_v1.py
@@ -0,0 +1,993 @@
+from __future__ import annotations
+
+import json
+import re
+import sqlite3
+from datetime import datetime
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import yaml
+
+
+ROOT = Path(__file__).resolve().parents[2]
+DEFAULT_DB = ROOT / "outputs" / "snapshot_admin" / "snapshot_admin.db"
+DEFAULT_SEED_JSON = ROOT / "GatherTradingData.json"
+KST = ZoneInfo("Asia/Seoul")
+
+SETTINGS_TABLE = "settings"
+SNAPSHOT_TABLE = "account_snapshot"
+CHANGE_LOG_TABLE = "workspace_change_log"
+APPROVAL_TABLE = "workspace_approval_v2"
+LOCK_TABLE = "workspace_lock"
+
+ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS = [
+ "captured_at",
+ "account",
+ "account_type",
+ "ticker",
+ "name",
+ "holding_quantity",
+ "available_quantity",
+ "average_cost",
+ "total_cost",
+ "current_price",
+ "market_value",
+ "profit_loss",
+ "return_pct",
+ "immediate_cash",
+ "settlement_cash_d2",
+ "available_cash",
+ "open_order_amount",
+ "monthly_contribution_limit",
+ "monthly_contribution_used",
+ "parse_status",
+ "user_confirmed",
+ "stop_price",
+ "highest_price_since_entry",
+ "entry_date",
+ "entry_stage",
+ "position_type",
+ "last_updated",
+]
+
+ALLOWED_PARSE_STATUS = {
+ "CAPTURE_READ_OK",
+ "CAPTURE_READ_FAILED",
+ "CAPTURE_PROVIDED_BUT_NOT_HOLDINGS",
+ "NOT_PROVIDED",
+}
+
+SETTINGS_SPEC_PATH = ROOT / "spec" / "18_settings_contract.yaml"
+ACCOUNT_SNAPSHOT_SPEC_PATH = ROOT / "spec" / "15_account_snapshot_contract.yaml"
+
+
+def now_kst_iso() -> str:
+ return datetime.now(tz=KST).isoformat(timespec="seconds")
+
+
+def parse_scalar(value: str) -> Any:
+ text = value.strip()
+ if text == "":
+ return ""
+ if text.lower() in {"null", "none"}:
+ return None
+ if text.lower() in {"true", "false"}:
+ return text.lower() == "true"
+ try:
+ return json.loads(text)
+ except Exception:
+ return text
+
+
+def _json_dump(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False)
+
+
+def _json_load(text: str) -> Any:
+ try:
+ return json.loads(text)
+ except Exception:
+ return text
+
+
+def normalize_db_path(db_path: Path | str | None = None) -> Path:
+ path = Path(db_path) if db_path else DEFAULT_DB
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def open_connection(db_path: Path | str | None = None) -> sqlite3.Connection:
+ conn = sqlite3.connect(normalize_db_path(db_path))
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys = ON")
+ conn.execute("PRAGMA journal_mode = WAL")
+ return conn
+
+
+def ensure_schema(conn: sqlite3.Connection) -> None:
+ conn.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS {SETTINGS_TABLE} (
+ ordinal INTEGER NOT NULL,
+ key TEXT PRIMARY KEY,
+ value_json TEXT NOT NULL,
+ note TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS {SNAPSHOT_TABLE} (
+ ordinal INTEGER NOT NULL,
+ row_json TEXT NOT NULL,
+ captured_at TEXT NOT NULL DEFAULT '',
+ account TEXT NOT NULL DEFAULT '',
+ account_type TEXT NOT NULL DEFAULT '',
+ ticker TEXT NOT NULL DEFAULT '',
+ name TEXT NOT NULL DEFAULT '',
+ parse_status TEXT NOT NULL DEFAULT '',
+ user_confirmed TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ f"CREATE INDEX IF NOT EXISTS idx_{SNAPSHOT_TABLE}_captured_at ON {SNAPSHOT_TABLE}(captured_at)"
+ )
+ conn.execute(
+ f"CREATE INDEX IF NOT EXISTS idx_{SNAPSHOT_TABLE}_ticker ON {SNAPSHOT_TABLE}(ticker)"
+ )
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS workspace_meta (key TEXT PRIMARY KEY, value_json TEXT NOT NULL)"
+ )
+ conn.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS {CHANGE_LOG_TABLE} (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain TEXT NOT NULL,
+ action TEXT NOT NULL,
+ target_ref TEXT NOT NULL DEFAULT '',
+ actor TEXT NOT NULL DEFAULT 'system',
+ note TEXT NOT NULL DEFAULT '',
+ before_json TEXT NOT NULL DEFAULT 'null',
+ after_json TEXT NOT NULL DEFAULT 'null',
+ created_at TEXT NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS {APPROVAL_TABLE} (
+ domain TEXT NOT NULL,
+ target_ref TEXT NOT NULL DEFAULT '*',
+ status TEXT NOT NULL,
+ approved_by TEXT NOT NULL DEFAULT '',
+ approved_at TEXT NOT NULL DEFAULT '',
+ note TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (domain, target_ref)
+ )
+ """
+ )
+ conn.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS {LOCK_TABLE} (
+ domain TEXT NOT NULL,
+ target_ref TEXT NOT NULL DEFAULT '',
+ locked_by TEXT NOT NULL DEFAULT '',
+ reason TEXT NOT NULL DEFAULT '',
+ locked_at TEXT NOT NULL,
+ PRIMARY KEY (domain, target_ref)
+ )
+ """
+ )
+ conn.commit()
+
+
+def _normalize_settings_rows(settings: Any) -> list[dict[str, Any]]:
+ if isinstance(settings, list):
+ rows: list[dict[str, Any]] = []
+ for idx, item in enumerate(settings, start=1):
+ if isinstance(item, dict) and "key" in item:
+ rows.append(
+ {
+ "ordinal": int(item.get("ordinal") or idx),
+ "key": str(item.get("key") or ""),
+ "value": item.get("value", ""),
+ "note": str(item.get("note") or ""),
+ }
+ )
+ return rows
+ if isinstance(settings, dict):
+ rows = []
+ for idx, (key, value) in enumerate(settings.items(), start=1):
+ rows.append({"ordinal": idx, "key": str(key), "value": value, "note": ""})
+ return rows
+ return []
+
+
+def _normalize_snapshot_rows(rows: Any) -> list[dict[str, Any]]:
+ if not isinstance(rows, list):
+ return []
+ normalized: list[dict[str, Any]] = []
+ for idx, item in enumerate(rows, start=1):
+ if isinstance(item, dict):
+ row = dict(item)
+ row.setdefault("ordinal", idx)
+ normalized.append(row)
+ return normalized
+
+
+def seed_payload_from_json(json_path: Path | str) -> dict[str, Any]:
+ payload = json.loads(Path(json_path).read_text(encoding="utf-8"))
+ data = payload.get("data") if isinstance(payload, dict) else None
+ if not isinstance(data, dict):
+ data = payload if isinstance(payload, dict) else {}
+ settings = _normalize_settings_rows(data.get("settings"))
+ account_snapshot = _normalize_snapshot_rows(data.get("account_snapshot"))
+ return {
+ "meta": payload.get("meta") if isinstance(payload, dict) else {},
+ "settings": settings,
+ "account_snapshot": account_snapshot,
+ }
+
+
+def replace_settings(conn: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
+ ensure_schema(conn)
+ errors = validate_settings_rows(rows)
+ if errors:
+ raise ValueError("; ".join(errors))
+ old_rows = load_settings_rows_from_conn(conn)
+ conn.execute(f"DELETE FROM {SETTINGS_TABLE}")
+ for idx, row in enumerate(rows, start=1):
+ key = str(row.get("key") or "").strip()
+ if not key:
+ continue
+ conn.execute(
+ f"""
+ INSERT INTO {SETTINGS_TABLE} (ordinal, key, value_json, note, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (
+ int(row.get("ordinal") or idx),
+ key,
+ _json_dump(row.get("value", "")),
+ str(row.get("note") or ""),
+ now_kst_iso(),
+ ),
+ )
+ record_change_log(
+ conn,
+ domain=SETTINGS_TABLE,
+ action="replace",
+ before_json=old_rows,
+ after_json=rows,
+ target_ref="*",
+ note="settings replace",
+ )
+ set_approval(conn, SETTINGS_TABLE, "PENDING", note="settings updated")
+ conn.commit()
+
+
+def replace_account_snapshot(conn: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
+ ensure_schema(conn)
+ errors = validate_account_snapshot_rows(rows)
+ if errors:
+ raise ValueError("; ".join(errors))
+ old_rows = load_account_snapshot_rows_from_conn(conn)
+ conn.execute(f"DELETE FROM {SNAPSHOT_TABLE}")
+ for idx, row in enumerate(rows, start=1):
+ normalized = dict(row)
+ ordinal = int(normalized.pop("ordinal", idx) or idx)
+ captured_at = str(normalized.get("captured_at") or "")
+ account = str(normalized.get("account") or "")
+ account_type = str(normalized.get("account_type") or "")
+ ticker = str(normalized.get("ticker") or "")
+ name = str(normalized.get("name") or "")
+ parse_status = str(normalized.get("parse_status") or "")
+ user_confirmed = str(normalized.get("user_confirmed") or "")
+ conn.execute(
+ f"""
+ INSERT INTO {SNAPSHOT_TABLE} (
+ ordinal, row_json, captured_at, account, account_type, ticker, name,
+ parse_status, user_confirmed, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ ordinal,
+ _json_dump(normalized),
+ captured_at,
+ account,
+ account_type,
+ ticker,
+ name,
+ parse_status,
+ user_confirmed,
+ now_kst_iso(),
+ ),
+ )
+ record_change_log(
+ conn,
+ domain=SNAPSHOT_TABLE,
+ action="replace",
+ before_json=old_rows,
+ after_json=rows,
+ target_ref="*",
+ note="account_snapshot replace",
+ )
+ set_approval(conn, SNAPSHOT_TABLE, "PENDING", note="account_snapshot updated")
+ conn.commit()
+
+
+def import_seed_json(db_path: Path | str | None, json_path: Path | str) -> dict[str, Any]:
+ payload = seed_payload_from_json(json_path)
+ with open_connection(db_path) as conn:
+ replace_settings(conn, payload["settings"])
+ replace_account_snapshot(conn, payload["account_snapshot"])
+ conn.execute(
+ "INSERT OR REPLACE INTO workspace_meta(key, value_json) VALUES (?, ?)",
+ ("seed_json_path", _json_dump(str(Path(json_path).resolve()))),
+ )
+ conn.execute(
+ "INSERT OR REPLACE INTO workspace_meta(key, value_json) VALUES (?, ?)",
+ ("seeded_at", _json_dump(now_kst_iso())),
+ )
+ conn.commit()
+ return summarize_workspace(db_path)
+
+
+def load_settings_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
+ with open_connection(db_path) as conn:
+ return load_settings_rows_from_conn(conn)
+
+
+def load_settings_rows_from_conn(conn: sqlite3.Connection) -> list[dict[str, Any]]:
+ ensure_schema(conn)
+ rows = conn.execute(
+ f"SELECT ordinal, key, value_json, note, updated_at FROM {SETTINGS_TABLE} ORDER BY ordinal ASC, key ASC"
+ ).fetchall()
+ return [
+ {
+ "ordinal": int(row["ordinal"]),
+ "key": row["key"],
+ "value": _json_load(row["value_json"]),
+ "note": row["note"],
+ "updated_at": row["updated_at"],
+ }
+ for row in rows
+ ]
+
+
+def load_account_snapshot_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
+ with open_connection(db_path) as conn:
+ return load_account_snapshot_rows_from_conn(conn)
+
+
+def load_account_snapshot_rows_from_conn(conn: sqlite3.Connection) -> list[dict[str, Any]]:
+ ensure_schema(conn)
+ rows = conn.execute(
+ f"""
+ SELECT ordinal, row_json, captured_at, account, account_type, ticker, name,
+ parse_status, user_confirmed, updated_at
+ FROM {SNAPSHOT_TABLE}
+ ORDER BY ordinal ASC
+ """
+ ).fetchall()
+ loaded: list[dict[str, Any]] = []
+ for row in rows:
+ payload = _json_load(row["row_json"])
+ item = payload if isinstance(payload, dict) else {}
+ item.setdefault("captured_at", row["captured_at"])
+ item.setdefault("account", row["account"])
+ item.setdefault("account_type", row["account_type"])
+ item.setdefault("ticker", row["ticker"])
+ item.setdefault("name", row["name"])
+ item.setdefault("parse_status", row["parse_status"])
+ item.setdefault("user_confirmed", row["user_confirmed"])
+ item["_ordinal"] = int(row["ordinal"])
+ item["_updated_at"] = row["updated_at"]
+ loaded.append(item)
+ return loaded
+
+
+def export_payload(db_path: Path | str | None = None) -> dict[str, Any]:
+ settings_rows = load_settings_rows(db_path)
+ settings = {row["key"]: row["value"] for row in settings_rows}
+ account_snapshot = load_account_snapshot_rows(db_path)
+ return {
+ "meta": {
+ "generated_at": now_kst_iso(),
+ "source_db": str(normalize_db_path(db_path)),
+ },
+ "data": {
+ "settings": settings,
+ "account_snapshot": account_snapshot,
+ },
+ }
+
+
+def write_export_json(db_path: Path | str | None, output_path: Path | str) -> Path:
+ payload = export_payload(db_path)
+ output = Path(output_path)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ return output
+
+
+def load_meta(db_path: Path | str | None = None) -> dict[str, Any]:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ rows = conn.execute("SELECT key, value_json FROM workspace_meta ORDER BY key ASC").fetchall()
+ return {row["key"]: _json_load(row["value_json"]) for row in rows}
+
+
+def record_change_log(
+ conn: sqlite3.Connection,
+ *,
+ domain: str,
+ action: str,
+ before_json: Any,
+ after_json: Any,
+ target_ref: str = "",
+ actor: str = "ui",
+ note: str = "",
+) -> None:
+ ensure_schema(conn)
+ conn.execute(
+ f"""
+ INSERT INTO {CHANGE_LOG_TABLE} (
+ domain, action, target_ref, actor, note, before_json, after_json, created_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ domain,
+ action,
+ target_ref,
+ actor,
+ note,
+ _json_dump(before_json),
+ _json_dump(after_json),
+ now_kst_iso(),
+ ),
+ )
+
+
+def set_approval(
+ conn: sqlite3.Connection,
+ domain: str,
+ status: str,
+ *,
+ target_ref: str = "*",
+ approved_by: str = "",
+ note: str = "",
+) -> None:
+ ensure_schema(conn)
+ conn.execute(
+ f"""
+ INSERT INTO {APPROVAL_TABLE} (domain, target_ref, status, approved_by, approved_at, note, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(domain, target_ref) DO UPDATE SET
+ status=excluded.status,
+ approved_by=excluded.approved_by,
+ approved_at=excluded.approved_at,
+ note=excluded.note,
+ updated_at=excluded.updated_at
+ """,
+ (
+ domain,
+ target_ref or "*",
+ status,
+ approved_by,
+ now_kst_iso() if status == "APPROVED" else "",
+ note,
+ now_kst_iso(),
+ ),
+ )
+
+
+def load_approval_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ rows = conn.execute(
+ f"SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at FROM {APPROVAL_TABLE} ORDER BY domain ASC, target_ref ASC"
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+
+def load_approval_entry(db_path: Path | str | None, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ row = conn.execute(
+ f"""
+ SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at
+ FROM {APPROVAL_TABLE}
+ WHERE domain = ? AND target_ref = ?
+ LIMIT 1
+ """,
+ (domain, target_ref or "*"),
+ ).fetchone()
+ return dict(row) if row is not None else None
+
+
+def load_change_log_rows(db_path: Path | str | None = None, limit: int = 20) -> list[dict[str, Any]]:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ rows = conn.execute(
+ f"""
+ SELECT id, domain, action, target_ref, actor, note, before_json, after_json, created_at
+ FROM {CHANGE_LOG_TABLE}
+ ORDER BY id DESC
+ LIMIT ?
+ """,
+ (int(limit),),
+ ).fetchall()
+ items = []
+ for row in rows:
+ items.append(
+ {
+ "id": int(row["id"]),
+ "domain": row["domain"],
+ "action": row["action"],
+ "target_ref": row["target_ref"],
+ "actor": row["actor"],
+ "note": row["note"],
+ "before_json": _json_load(row["before_json"]),
+ "after_json": _json_load(row["after_json"]),
+ "created_at": row["created_at"],
+ }
+ )
+ return items
+
+
+def load_last_change_row(conn: sqlite3.Connection, domain: str) -> dict[str, Any] | None:
+ ensure_schema(conn)
+ row = conn.execute(
+ f"""
+ SELECT id, domain, action, target_ref, actor, note, before_json, after_json, created_at
+ FROM {CHANGE_LOG_TABLE}
+ WHERE domain = ?
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (domain,),
+ ).fetchone()
+ if row is None:
+ return None
+ return {
+ "id": int(row["id"]),
+ "domain": row["domain"],
+ "action": row["action"],
+ "target_ref": row["target_ref"],
+ "actor": row["actor"],
+ "note": row["note"],
+ "before_json": _json_load(row["before_json"]),
+ "after_json": _json_load(row["after_json"]),
+ "created_at": row["created_at"],
+ }
+
+
+def set_lock(conn: sqlite3.Connection, domain: str, target_ref: str, *, locked_by: str, reason: str) -> None:
+ ensure_schema(conn)
+ conn.execute(
+ f"""
+ INSERT INTO {LOCK_TABLE} (domain, target_ref, locked_by, reason, locked_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(domain, target_ref) DO UPDATE SET
+ locked_by=excluded.locked_by,
+ reason=excluded.reason,
+ locked_at=excluded.locked_at
+ """,
+ (domain, target_ref, locked_by, reason, now_kst_iso()),
+ )
+
+
+def clear_lock(conn: sqlite3.Connection, domain: str, target_ref: str) -> None:
+ ensure_schema(conn)
+ conn.execute(
+ f"DELETE FROM {LOCK_TABLE} WHERE domain = ? AND target_ref = ?",
+ (domain, target_ref),
+ )
+
+
+def load_locks(db_path: Path | str | None = None) -> list[dict[str, Any]]:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ rows = conn.execute(
+ f"SELECT domain, target_ref, locked_by, reason, locked_at FROM {LOCK_TABLE} ORDER BY domain ASC, target_ref ASC"
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+
+def load_lock_entry(db_path: Path | str | None, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ row = conn.execute(
+ f"""
+ SELECT domain, target_ref, locked_by, reason, locked_at
+ FROM {LOCK_TABLE}
+ WHERE domain = ? AND target_ref = ?
+ LIMIT 1
+ """,
+ (domain, target_ref or "*"),
+ ).fetchone()
+ return dict(row) if row is not None else None
+
+
+def is_locked(db_path: Path | str | None, domain: str, target_ref: str = "*") -> bool:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ row = conn.execute(
+ f"SELECT 1 FROM {LOCK_TABLE} WHERE domain = ? AND target_ref IN (?, '*') LIMIT 1",
+ (domain, target_ref),
+ ).fetchone()
+ return row is not None
+
+
+def lock_conflicts_for_rows(
+ db_path: Path | str | None,
+ domain: str,
+ rows: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ locks = conn.execute(
+ f"SELECT domain, target_ref, locked_by, reason, locked_at FROM {LOCK_TABLE} WHERE domain = ? ORDER BY target_ref ASC",
+ (domain,),
+ ).fetchall()
+ if not locks:
+ return []
+ row_refs: list[str] = []
+ for idx, row in enumerate(rows, start=1):
+ if domain == SETTINGS_TABLE:
+ ref = str(row.get("_row_ref") or "").strip() or str(row.get("key") or "").strip()
+ elif domain == SNAPSHOT_TABLE:
+ ref = str(row.get("_row_ref") or "").strip()
+ if not ref:
+ ordinal = str(row.get("_ordinal") or row.get("ordinal") or idx).strip()
+ ref = f"row:{ordinal}"
+ else:
+ ref = str(row.get("target_ref") or "").strip()
+ if ref:
+ row_refs.append(ref)
+ if domain == SETTINGS_TABLE:
+ key = str(row.get("key") or "").strip()
+ if key:
+ row_refs.append(key)
+ if domain == SNAPSHOT_TABLE:
+ ticker = str(row.get("ticker") or "").strip()
+ if ticker:
+ row_refs.append(ticker)
+ conflicts: list[dict[str, Any]] = []
+ for lock in locks:
+ target_ref = str(lock["target_ref"] or "").strip()
+ if target_ref == "*" or target_ref in row_refs:
+ conflicts.append(dict(lock))
+ return conflicts
+
+
+def undo_last_change(conn: sqlite3.Connection, domain: str, *, actor: str = "ui") -> dict[str, Any]:
+ ensure_schema(conn)
+ last = load_last_change_row(conn, domain)
+ if not last:
+ raise ValueError(f"no change log for domain={domain}")
+ before_json = last.get("before_json")
+ if domain == SETTINGS_TABLE:
+ rows = before_json if isinstance(before_json, list) else []
+ replace_settings(conn, rows)
+ elif domain == SNAPSHOT_TABLE:
+ rows = before_json if isinstance(before_json, list) else []
+ replace_account_snapshot(conn, rows)
+ else:
+ raise ValueError(f"unsupported domain={domain}")
+ record_change_log(
+ conn,
+ domain=domain,
+ action="undo",
+ before_json=last.get("after_json"),
+ after_json=before_json,
+ target_ref=last.get("target_ref", "*"),
+ actor=actor,
+ note=f"undo change #{last['id']}",
+ )
+ conn.commit()
+ return load_last_change_row(conn, domain) or {}
+
+
+def load_approval_for_domain(db_path: Path | str | None, domain: str) -> dict[str, Any]:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ row = conn.execute(
+ f"""
+ SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at
+ FROM {APPROVAL_TABLE}
+ WHERE domain = ? AND target_ref = '*'
+ """,
+ (domain,),
+ ).fetchone()
+ return (
+ dict(row)
+ if row
+ else {"domain": domain, "target_ref": "*", "status": "MISSING", "approved_by": "", "approved_at": "", "note": "", "updated_at": ""}
+ )
+
+
+def summarize_workspace(db_path: Path | str | None = None) -> dict[str, Any]:
+ with open_connection(db_path) as conn:
+ ensure_schema(conn)
+ settings_count = conn.execute(f"SELECT COUNT(*) FROM {SETTINGS_TABLE}").fetchone()[0]
+ snapshot_count = conn.execute(f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE}").fetchone()[0]
+ latest_update = conn.execute(
+ f"""
+ SELECT MAX(updated_at)
+ FROM (
+ SELECT updated_at FROM {SETTINGS_TABLE}
+ UNION ALL
+ SELECT updated_at FROM {SNAPSHOT_TABLE}
+ )
+ """
+ ).fetchone()[0]
+ table_rows = conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)",
+ (SETTINGS_TABLE, SNAPSHOT_TABLE, CHANGE_LOG_TABLE, APPROVAL_TABLE, LOCK_TABLE),
+ ).fetchall()
+ tables = sorted(row[0] for row in table_rows)
+ workspace_db = str(normalize_db_path(db_path))
+ return {
+ "db_path": workspace_db,
+ "settings_rows": int(settings_count),
+ "account_snapshot_rows": int(snapshot_count),
+ "latest_update": latest_update or "",
+ "tables": tables,
+ "topology": {
+ "mode": "single_workspace_sqlite",
+ "workspace_db": workspace_db,
+ "collector_db": str(ROOT / "outputs" / "kis_data_collection" / "kis_data_collection.db"),
+ "settings_and_snapshot_share_db": True,
+ "collector_separate_db": True,
+ },
+ "meta": load_meta(db_path),
+ }
+
+
+def parse_account_snapshot_tsv(tsv_text: str) -> list[dict[str, Any]]:
+ lines = [line.rstrip("\r") for line in tsv_text.splitlines() if line.strip() != ""]
+ if not lines:
+ return []
+ rows: list[list[str]] = [line.split("\t") for line in lines]
+ first_row = rows[0]
+ if first_row == ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS:
+ data_rows = rows[1:]
+ elif set(first_row) >= {"captured_at", "account", "ticker"}:
+ header = first_row
+ data_rows = rows[1:]
+ converted: list[dict[str, Any]] = []
+ for idx, row in enumerate(data_rows, start=1):
+ item: dict[str, Any] = {"ordinal": idx}
+ for col_index, column in enumerate(header):
+ value = row[col_index] if col_index < len(row) else ""
+ item[column] = parse_scalar(value)
+ converted.append(item)
+ return converted
+ else:
+ data_rows = rows
+ converted = []
+ for idx, row in enumerate(data_rows, start=1):
+ item: dict[str, Any] = {"ordinal": idx}
+ for col_index, column in enumerate(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS):
+ value = row[col_index] if col_index < len(row) else ""
+ item[column] = parse_scalar(value)
+ converted.append(item)
+ return converted
+
+
+def settings_rows_to_dict(rows: list[dict[str, Any]]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for row in rows:
+ key = str(row.get("key") or "").strip()
+ if key:
+ result[key] = row.get("value", "")
+ return result
+
+
+def _as_number(value: Any) -> float | None:
+ if value is None:
+ return None
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, (int, float)):
+ return float(value)
+ text = str(value).strip()
+ if not text:
+ return None
+ try:
+ return float(text)
+ except Exception:
+ return None
+
+
+@lru_cache(maxsize=1)
+def _load_settings_spec() -> dict[str, Any]:
+ return yaml.safe_load(SETTINGS_SPEC_PATH.read_text(encoding="utf-8")) or {}
+
+
+@lru_cache(maxsize=1)
+def _load_account_snapshot_spec() -> dict[str, Any]:
+ return yaml.safe_load(ACCOUNT_SNAPSHOT_SPEC_PATH.read_text(encoding="utf-8")) or {}
+
+
+def validate_settings_rows(rows: list[dict[str, Any]]) -> list[str]:
+ errors: list[str] = []
+ spec = _load_settings_spec().get("required_keys") or {}
+ optional_spec = _load_settings_spec().get("optional_keys") or {}
+ seen: set[str] = set()
+ total_asset_found = False
+ for idx, row in enumerate(rows, start=1):
+ key = str(row.get("key") or "").strip()
+ if not key:
+ errors.append(f"settings row {idx}: missing key")
+ continue
+ if key in seen:
+ errors.append(f"settings row {idx}: duplicate key {key}")
+ seen.add(key)
+ value = row.get("value", "")
+ if key == "total_asset_krw":
+ total_asset_found = True
+ amount = _as_number(value)
+ if amount is None or amount <= 0:
+ errors.append("settings.total_asset_krw must be positive number")
+ if key in {"weekly_target_cash_pct", "fc_budget_pct_override"}:
+ pct = _as_number(value)
+ if pct is None or pct < 0:
+ errors.append(f"settings.{key} must be non-negative number")
+ if key in spec and spec[key].get("type") == "string":
+ if value is not None and not isinstance(value, str):
+ errors.append(f"settings.{key} must be string")
+ if key in optional_spec and optional_spec[key].get("format") == "YYYY-MM":
+ text = str(value).strip()
+ if text and not re.fullmatch(r"\d{4}-\d{2}(-.*)?", text):
+ errors.append(f"settings.{key} must use YYYY-MM")
+ if not total_asset_found:
+ errors.append("settings.total_asset_krw is required")
+ return errors
+
+
+def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
+ errors: list[str] = []
+ spec = _load_account_snapshot_spec().get("account_snapshot_contract") or {}
+ canonical = spec.get("canonical_fields") or {}
+ for idx, row in enumerate(rows, start=1):
+ captured_at = str(row.get("captured_at") or "").strip()
+ account = str(row.get("account") or "").strip()
+ ticker = str(row.get("ticker") or "").strip()
+ name = str(row.get("name") or "").strip()
+ account_type = str(row.get("account_type") or "").strip()
+ parse_status = str(row.get("parse_status") or "").strip()
+ holding_quantity = _as_number(row.get("holding_quantity"))
+ average_cost = _as_number(row.get("average_cost"))
+ stop_price = _as_number(row.get("stop_price"))
+ entry_stage = str(row.get("entry_stage") or "").strip()
+ position_type = str(row.get("position_type") or "").strip()
+ user_confirmed = str(row.get("user_confirmed") or "").strip().upper()
+ if not captured_at:
+ errors.append(f"account_snapshot row {idx}: captured_at required")
+ if not account:
+ errors.append(f"account_snapshot row {idx}: account required")
+ if not account_type:
+ errors.append(f"account_snapshot row {idx}: account_type required")
+ if account_type and canonical.get("account_type", {}).get("allowed") and account_type not in canonical["account_type"]["allowed"]:
+ errors.append(f"account_snapshot row {idx}: invalid account_type {account_type!r}")
+ if not ticker:
+ errors.append(f"account_snapshot row {idx}: ticker required")
+ if not name:
+ errors.append(f"account_snapshot row {idx}: name required")
+ if parse_status not in ALLOWED_PARSE_STATUS:
+ errors.append(f"account_snapshot row {idx}: invalid parse_status {parse_status!r}")
+ if holding_quantity is not None and holding_quantity < 0:
+ errors.append(f"account_snapshot row {idx}: holding_quantity must be >= 0")
+ if average_cost is not None and average_cost < 0:
+ errors.append(f"account_snapshot row {idx}: average_cost must be >= 0")
+ if user_confirmed and user_confirmed not in {"Y", "N"}:
+ errors.append(f"account_snapshot row {idx}: user_confirmed must be Y or N")
+ if parse_status == "CAPTURE_READ_OK" and user_confirmed != "Y":
+ errors.append(f"account_snapshot row {idx}: CAPTURE_READ_OK rows require user_confirmed=Y")
+ if entry_stage and canonical.get("entry_stage", {}).get("allowed") and entry_stage not in canonical["entry_stage"]["allowed"]:
+ errors.append(f"account_snapshot row {idx}: invalid entry_stage {entry_stage!r}")
+ if position_type and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]:
+ errors.append(f"account_snapshot row {idx}: invalid position_type {position_type!r}")
+ return errors
+
+
+def build_validation_suggestions(settings_rows: list[dict[str, Any]], snapshot_rows: list[dict[str, Any]]) -> list[str]:
+ suggestions: list[str] = []
+ settings_map = settings_rows_to_dict(settings_rows)
+ snapshot_count = len(snapshot_rows)
+ if "total_asset_krw" not in settings_map:
+ suggestions.append("settings: add total_asset_krw from current investable asset total")
+ if str(settings_map.get("weekly_target_cash_pct", "")).strip() == "":
+ suggestions.append("settings: weekly_target_cash_pct can stay blank unless weekly rebalance is active")
+ for row in snapshot_rows:
+ if str(row.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(row.get("user_confirmed") or "").strip().upper() != "Y":
+ suggestions.append(
+ f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: set user_confirmed=Y for CAPTURE_READ_OK"
+ )
+ account_type = str(row.get("account_type") or "").strip()
+ if account_type and account_type not in {"일반계좌", "ISA", "연금저축"}:
+ suggestions.append(
+ f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: account_type should be one of 일반계좌/ISA/연금저축"
+ )
+ if str(row.get("entry_stage") or "").strip() and str(row.get("position_type") or "").strip() == "":
+ suggestions.append(
+ f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: consider setting position_type when entry_stage is present"
+ )
+ if not snapshot_rows:
+ suggestions.append("account_snapshot: import TSV from HTS capture before saving snapshot")
+ return suggestions[:20]
+
+
+def build_safe_autofix_actions(settings_rows: list[dict[str, Any]], snapshot_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ actions: list[dict[str, Any]] = []
+ if any(str(row.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(row.get("user_confirmed") or "").strip().upper() != "Y" for row in snapshot_rows):
+ actions.append(
+ {
+ "action_id": "confirm_captured_rows",
+ "domain": "account_snapshot",
+ "label": "Set user_confirmed=Y for CAPTURE_READ_OK rows",
+ "description": "Safe autofix using the contract default confirmation flag.",
+ }
+ )
+ if any(str(row.get("position_type") or "").strip() == "" and str(row.get("entry_stage") or "").strip() for row in snapshot_rows):
+ actions.append(
+ {
+ "action_id": "default_position_type_satellite",
+ "domain": "account_snapshot",
+ "label": "Default blank position_type to satellite",
+ "description": "Uses the contract default when position_type is missing.",
+ }
+ )
+ if not any(str(row.get("key") or "").strip() == "total_asset_krw" for row in settings_rows):
+ actions.append(
+ {
+ "action_id": "required_total_asset_missing",
+ "domain": "settings",
+ "label": "Settings total_asset_krw missing",
+ "description": "Manual input required. No safe autofix.",
+ }
+ )
+ return actions
+
+
+def apply_safe_autofix_action(
+ conn: sqlite3.Connection,
+ action_id: str,
+ *,
+ actor: str = "ui",
+) -> dict[str, Any]:
+ ensure_schema(conn)
+ snapshot_rows = load_account_snapshot_rows_from_conn(conn)
+ if action_id == "confirm_captured_rows":
+ updated = []
+ for row in snapshot_rows:
+ candidate = dict(row)
+ if str(candidate.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(candidate.get("user_confirmed") or "").strip().upper() != "Y":
+ candidate["user_confirmed"] = "Y"
+ updated.append(candidate)
+ replace_account_snapshot(conn, updated)
+ return {"domain": SNAPSHOT_TABLE, "status": "AUTOFIXED", "action_id": action_id}
+ if action_id == "default_position_type_satellite":
+ updated = []
+ for row in snapshot_rows:
+ candidate = dict(row)
+ if str(candidate.get("entry_stage") or "").strip() and str(candidate.get("position_type") or "").strip() == "":
+ candidate["position_type"] = "satellite"
+ updated.append(candidate)
+ replace_account_snapshot(conn, updated)
+ return {"domain": SNAPSHOT_TABLE, "status": "AUTOFIXED", "action_id": action_id}
+ if action_id == "required_total_asset_missing":
+ return {"domain": SETTINGS_TABLE, "status": "MANUAL_REQUIRED", "action_id": action_id}
+ raise ValueError(f"unknown action_id={action_id}")
diff --git a/src/quant_engine/storage_backend_v1.py b/src/quant_engine/storage_backend_v1.py
new file mode 100644
index 00000000..5c3be474
--- /dev/null
+++ b/src/quant_engine/storage_backend_v1.py
@@ -0,0 +1,50 @@
+"""Generic storage backend contract for canonical time-series stores.
+
+The call sites use this as a small contract layer so SQLite is the executable
+backend today while PostgreSQL can be added later without changing callers.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class StoreSpec:
+ backend: str = "sqlite"
+ location: str | Path | None = None
+
+ def normalized_backend(self) -> str:
+ backend = (self.backend or "sqlite").strip().lower()
+ if backend in {"sqlite", "sqlite3"}:
+ return "sqlite"
+ if backend in {"postgres", "postgresql", "pg"}:
+ return "postgresql"
+ return backend
+
+
+def default_sqlite_store_path(root: Path, default_name: str) -> Path:
+ return root / "outputs" / default_name
+
+
+def normalize_store_spec(
+ spec: StoreSpec,
+ root: Path,
+ *,
+ default_sqlite_name: str = "store.db",
+) -> tuple[str, Path | str]:
+ backend = spec.normalized_backend()
+ if backend == "sqlite":
+ if spec.location is None:
+ return backend, default_sqlite_store_path(root, default_sqlite_name)
+ if isinstance(spec.location, Path):
+ return backend, spec.location
+ location = str(spec.location).strip()
+ if location.startswith("sqlite:///"):
+ return backend, Path(location.removeprefix("sqlite:///"))
+ return backend, Path(location)
+ if backend == "postgresql":
+ if not spec.location:
+ raise ValueError("postgresql backend requires a DSN/location string")
+ return backend, str(spec.location)
+ raise ValueError(f"unsupported backend: {spec.backend!r}")
diff --git a/tests/integration/test_kis_collection_to_snapshot_admin_and_sell_strategy_v1.py b/tests/integration/test_kis_collection_to_snapshot_admin_and_sell_strategy_v1.py
new file mode 100644
index 00000000..32c83653
--- /dev/null
+++ b/tests/integration/test_kis_collection_to_snapshot_admin_and_sell_strategy_v1.py
@@ -0,0 +1,138 @@
+"""WBS-7.7 — KIS 수집 → 스냅샷 어드민 적재 → 정성매도전략 평가 E2E 체인.
+
+단위 테스트(tests/unit)는 각 모듈을 독립적으로 검증하지만, 모듈 간 실제 데이터
+경로(kis_data_collection_v1 → data_collection_store_v1.db → snapshot_admin의
+collection dashboard / qualitative_sell_strategy_v1 → qualitative_sell_strategy_store_v1.db)
+를 연결해서 검증하는 테스트가 없었다(2026-06-21 비판적 리뷰 0c절, WBS-7.7).
+
+이 테스트는 네트워크를 전혀 사용하지 않는다(--no-live-kis --no-naver와 동일한 경로,
+또는 Naver 호출을 명시적으로 예외 처리시켜 graceful degradation을 검증).
+"""
+from __future__ import annotations
+
+import json
+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))
+
+import pytest
+
+from src.quant_engine import kis_data_collection_v1 as kdc
+from src.quant_engine.data_collection_store_v1 import load_collection_dashboard_state
+from src.quant_engine.qualitative_sell_strategy_v1 import compute_qualitative_sell_strategy
+from src.quant_engine.qualitative_sell_strategy_store_v1 import (
+ fetch_recent_sell_strategy_results,
+ insert_sell_strategy_result,
+)
+
+SEED_ROWS = [
+ {"Ticker": "005930", "Name": "삼성전자", "Sector": "반도체"},
+ {"Ticker": "000660", "Name": "SK하이닉스", "Sector": "반도체"},
+]
+
+
+@pytest.fixture()
+def seed_json(tmp_path: Path) -> Path:
+ path = tmp_path / "seed.json"
+ path.write_text(
+ json.dumps({"data": {"data_feed": SEED_ROWS}}, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ return path
+
+
+def test_kis_collection_writes_sqlite_that_snapshot_admin_dashboard_reads_back(tmp_path: Path, seed_json: Path):
+ """1단계: KIS 수집(네트워크 미사용) → SQLite 적재 → snapshot_admin 대시보드 read-back."""
+ db_path = tmp_path / "data_collection_store_v1.db"
+ output_json = tmp_path / "kis_data_collection_v1.json"
+
+ summary = kdc.collect_to_sqlite(
+ input_json=seed_json,
+ sqlite_db=db_path,
+ output_json=output_json,
+ kis_account="mock",
+ include_naver=False,
+ include_live_kis=False,
+ )
+
+ assert summary["status"] in {"PASS", "PASS_WITH_WARNINGS"}
+ assert summary["row_count"] == len(SEED_ROWS)
+ assert not summary["errors"]
+
+ dashboard = load_collection_dashboard_state(db_path=db_path, output_json_path=output_json)
+ assert dashboard["counts"]["collection_runs"] >= 1
+ assert dashboard["counts"]["collection_snapshots"] == len(SEED_ROWS)
+ assert dashboard["counts"]["collection_source_errors"] == 0
+ tickers_in_dashboard = {row["ticker"] for row in dashboard["recent_snapshots"]}
+ assert {"005930", "000660"} <= tickers_in_dashboard
+
+
+def test_naver_fetch_exception_degrades_gracefully_without_breaking_batch(tmp_path: Path, seed_json: Path, monkeypatch):
+ """Cloudflare 403 등 Naver 폴백 차단 시 graceful degradation 검증 (spec/exit/qualitative_sell_strategy_v1.yaml:81-82 명시 리스크)."""
+
+ def _raise_cloudflare_block(_session, _code):
+ raise RuntimeError("HTTP 403 Forbidden (Cloudflare)")
+
+ monkeypatch.setattr(kdc, "fetch_price_history", _raise_cloudflare_block)
+ # naver_session/fetch_price_history may be None on environments without the optional
+ # dependency wired; force both non-None so _normalize_naver_price_history actually tries.
+ monkeypatch.setattr(kdc, "naver_session", lambda: object())
+
+ db_path = tmp_path / "data_collection_store_v1.db"
+ output_json = tmp_path / "kis_data_collection_v1.json"
+
+ summary = kdc.collect_to_sqlite(
+ input_json=seed_json,
+ sqlite_db=db_path,
+ output_json=output_json,
+ kis_account="mock",
+ include_naver=True,
+ include_live_kis=False,
+ )
+
+ # 배치 전체가 죽지 않고 끝까지 진행되어야 한다 — 개별 ticker의 naver 보강 실패는
+ # collection_source_errors가 아니라 정상 row로 (naver 필드 없이) 기록된다.
+ assert summary["status"] in {"PASS", "PASS_WITH_WARNINGS"}
+ assert summary["row_count"] == len(SEED_ROWS)
+ assert not summary["errors"], "Naver 차단은 개별 ticker 처리 중 흡수되어야 하며 배치 errors로 전파되면 안 된다"
+
+
+def test_qualitative_sell_strategy_decision_round_trips_through_store(tmp_path: Path):
+ """2단계: 정성매도전략 평가(순수 함수, 네트워크 미사용) → SQLite 저장 → 조회 round-trip."""
+ ctx = {
+ "today": date(2026, 6, 21),
+ "macro_pressure": 0.5,
+ "fundamental_trajectory": 0.4,
+ "short_interest_pressure": 0.6,
+ "microstructure_pressure": 0.2,
+ "liquidity_rotation_risk": 0.5,
+ "rate_trend": "RISING",
+ }
+ decision = compute_qualitative_sell_strategy(ctx)
+ assert decision["action"] in {
+ "EXIT_REVIEW_FULL",
+ "TRIM_REVIEW_PARTIAL",
+ "HOLD_ADD_CONVICTION",
+ "HOLD_NO_CONFLUENCE",
+ "INSUFFICIENT_DATA_NO_ACTION",
+ }
+
+ result = {
+ "code": "005930",
+ "generated_at": "2026-06-21T15:30:00+09:00",
+ "decision": decision,
+ }
+
+ db_path = tmp_path / "qualitative_sell_strategy.db"
+ insert_sell_strategy_result(db_path, result)
+
+ fetched = fetch_recent_sell_strategy_results(db_path, "005930", limit=5)
+ assert len(fetched) == 1
+ assert fetched[0]["code"] == "005930"
+ assert fetched[0]["action"] == decision["action"]
+ assert fetched[0]["conviction"] == decision["conviction"]
+ assert fetched[0]["market_regime"] == decision["market_regime"]
diff --git a/tests/unit/test_calibration_priority_v1.py b/tests/unit/test_calibration_priority_v1.py
new file mode 100644
index 00000000..6696a195
--- /dev/null
+++ b/tests/unit/test_calibration_priority_v1.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def _run(script: str) -> None:
+ subprocess.run(
+ [sys.executable, script],
+ cwd=ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+
+
+def test_build_calibration_priority_and_change_ledger(tmp_path):
+ _run("tools/build_calibration_priority_v1.py")
+ _run("tools/build_calibration_change_ledger_v4.py")
+ _run("tools/validate_calibration_change_ledger_v1.py")
+
+ priority_path = ROOT / "Temp" / "calibration_priority_v1.json"
+ ledger_path = ROOT / "Temp" / "calibration_change_ledger_v4.json"
+
+ priority = json.loads(priority_path.read_text(encoding="utf-8"))
+ ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
+
+ assert priority["status"] == "CALIBRATION_PRIORITY_OK"
+ assert priority["priority_count"] >= 5
+ assert priority["priority_list"]
+ assert priority["priority_basis"] in {"alpha_feedback_loop_v2", "registry_warning_fallback"}
+
+ assert ledger["formula_id"] == "CALIBRATION_CHANGE_LEDGER_V4"
+ assert ledger["threshold_change_without_ledger_count"] == 0
+ assert len(ledger["changes"]) >= 5
+
+
+def test_calibration_backlog_workflow_and_script_exist():
+ workflow = ROOT / ".gitea" / "workflows" / "calibration_backlog.yml"
+ package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
+ assert workflow.exists()
+ assert "ops:calibration-backlog" in package["scripts"]
+ assert "ops:calibration-review-report" in package["scripts"]
+ assert "ops:calibration-approval-list" in package["scripts"]
+ assert "ops:calibration-decision-draft" in package["scripts"]
+
+
+def test_build_calibration_review_report(tmp_path):
+ _run("tools/build_calibration_priority_v1.py")
+ _run("tools/build_calibration_change_ledger_v4.py")
+ _run("tools/build_calibration_review_report_v1.py")
+
+ report_json = ROOT / "Temp" / "calibration_review_report_v1.json"
+ report_md = ROOT / "Temp" / "calibration_review_report_v1.md"
+ payload = json.loads(report_json.read_text(encoding="utf-8"))
+ text = report_md.read_text(encoding="utf-8")
+
+ assert payload["formula_id"] == "CALIBRATION_REVIEW_REPORT_V1"
+ assert payload["summary"]["total_thresholds"] >= 1
+ assert payload["top_priority_rows"]
+ assert "Calibration Review Report" in text
+ assert "Review Candidates" in text
+
+
+def test_build_calibration_approval_list(tmp_path):
+ _run("tools/build_calibration_priority_v1.py")
+ _run("tools/build_calibration_change_ledger_v4.py")
+ _run("tools/build_calibration_review_report_v1.py")
+ _run("tools/build_calibration_approval_list_v1.py")
+
+ approval_json = ROOT / "Temp" / "calibration_approval_list_v1.json"
+ approval_md = ROOT / "Temp" / "calibration_approval_list_v1.md"
+ payload = json.loads(approval_json.read_text(encoding="utf-8"))
+ text = approval_md.read_text(encoding="utf-8")
+
+ assert payload["formula_id"] == "CALIBRATION_APPROVAL_LIST_V1"
+ assert payload["approval_candidate_count"] >= 1
+ assert payload["approval_candidates"]
+ assert "Calibration Approval List" in text
+ assert "Approval Candidates" in text
+
+
+def test_build_calibration_decision_draft(tmp_path):
+ _run("tools/build_calibration_priority_v1.py")
+ _run("tools/build_calibration_change_ledger_v4.py")
+ _run("tools/build_calibration_review_report_v1.py")
+ _run("tools/build_calibration_approval_list_v1.py")
+ _run("tools/build_calibration_decision_draft_v1.py")
+
+ decision_json = ROOT / "Temp" / "calibration_decision_draft_v1.json"
+ decision_md = ROOT / "Temp" / "calibration_decision_draft_v1.md"
+ payload = json.loads(decision_json.read_text(encoding="utf-8"))
+ text = decision_md.read_text(encoding="utf-8")
+
+ assert payload["formula_id"] == "CALIBRATION_DECISION_DRAFT_V1"
+ assert payload["decision_count"] >= 1
+ assert payload["summary"]["APPROVE"] >= 1
+ assert payload["summary"]["HOLD"] >= 1
+ assert payload["summary"]["REJECT"] >= 0
+ assert "Calibration Decision Draft" in text
+ assert "Decision Table" in text
diff --git a/tests/unit/test_data_collection_store_v1.py b/tests/unit/test_data_collection_store_v1.py
new file mode 100644
index 00000000..6182b9e0
--- /dev/null
+++ b/tests/unit/test_data_collection_store_v1.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import sqlite3
+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.data_collection_store_v1 import (
+ CollectionRun,
+ append_collection_error,
+ fetch_latest_snapshots,
+ init_db,
+ iter_recent_snapshots,
+ upsert_collection_run,
+ upsert_collection_snapshot,
+)
+from src.quant_engine.data_collection_backend_v1 import CollectionStoreSpec, normalize_store_spec
+
+
+def test_store_writes_and_reads_snapshots(tmp_path):
+ db_path = tmp_path / "collector.db"
+ init_db(db_path)
+ upsert_collection_run(
+ db_path,
+ CollectionRun(
+ run_id="run-1",
+ collector_name="collector",
+ started_at="2026-06-21T12:00:00+09:00",
+ status="RUNNING",
+ input_source="GatherTradingData.json",
+ output_json_path="Temp/kis_data_collection_v1.json",
+ output_db_path=str(db_path),
+ ),
+ )
+ upsert_collection_snapshot(
+ db_path,
+ run_id="run-1",
+ dataset_name="data_feed",
+ ticker="005930",
+ name="삼성전자",
+ sector="반도체",
+ as_of_date="2026-06-21",
+ source_priority="kis_open_api>gathertradingdata_json",
+ source_status="OK",
+ payload={"ticker": "005930", "close": 1000},
+ provenance={"kis": {"status": "OK"}},
+ )
+ append_collection_error(
+ db_path,
+ run_id="run-1",
+ source_name="kis",
+ error_kind="TimeoutError",
+ error_message="timeout",
+ ticker="005930",
+ )
+
+ conn = sqlite3.connect(db_path)
+ try:
+ run_count = conn.execute("SELECT COUNT(*) FROM collection_runs").fetchone()[0]
+ snap_count = conn.execute("SELECT COUNT(*) FROM collection_snapshots").fetchone()[0]
+ err_count = conn.execute("SELECT COUNT(*) FROM collection_source_errors").fetchone()[0]
+ finally:
+ conn.close()
+
+ assert run_count == 1
+ assert snap_count == 1
+ assert err_count == 1
+ assert fetch_latest_snapshots(db_path, "005930")[0]["dataset_name"] == "data_feed"
+ assert len(list(iter_recent_snapshots(db_path, limit=5))) == 1
+
+
+def test_store_overwrites_same_run_and_ticker(tmp_path):
+ db_path = tmp_path / "collector.db"
+ upsert_collection_snapshot(
+ db_path,
+ run_id="run-1",
+ dataset_name="data_feed",
+ ticker="005930",
+ name="삼성전자",
+ sector="반도체",
+ as_of_date="2026-06-21",
+ source_priority="kis_open_api",
+ source_status="OK",
+ payload={"ticker": "005930", "close": 1000},
+ provenance={"source_priority": ["kis_open_api"]},
+ )
+ upsert_collection_snapshot(
+ db_path,
+ run_id="run-1",
+ dataset_name="data_feed",
+ ticker="005930",
+ name="삼성전자",
+ sector="반도체",
+ as_of_date="2026-06-21",
+ source_priority="kis_open_api>naver_finance",
+ source_status="OK",
+ payload={"ticker": "005930", "close": 2000},
+ provenance={"source_priority": ["kis_open_api", "naver_finance"]},
+ )
+ rows = fetch_latest_snapshots(db_path, "005930")
+ assert rows[0]["source_priority"] == "kis_open_api>naver_finance"
+
+
+def test_store_backend_normalization_supports_sqlite_paths(tmp_path):
+ backend, location = normalize_store_spec(CollectionStoreSpec(location=tmp_path / "collector.db"), ROOT)
+ assert backend == "sqlite"
+ assert str(location).endswith("collector.db")
diff --git a/tests/unit/test_evaluate_qualitative_sell_strategy_accuracy_v1.py b/tests/unit/test_evaluate_qualitative_sell_strategy_accuracy_v1.py
new file mode 100644
index 00000000..c805401e
--- /dev/null
+++ b/tests/unit/test_evaluate_qualitative_sell_strategy_accuracy_v1.py
@@ -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
diff --git a/tests/unit/test_execution_slippage_store_v1.py b/tests/unit/test_execution_slippage_store_v1.py
new file mode 100644
index 00000000..26b08f5d
--- /dev/null
+++ b/tests/unit/test_execution_slippage_store_v1.py
@@ -0,0 +1,90 @@
+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.execution_slippage_store_v1 import (
+ ASSUMED_SLIPPAGE_BPS,
+ MIN_SAMPLE_FOR_COMPARISON,
+ build_slippage_comparison_report,
+ fetch_all_samples,
+ insert_realized_slippage_sample,
+)
+
+
+def test_report_is_data_gated_below_minimum_sample(tmp_path):
+ db_path = tmp_path / "execution_slippage.db"
+ report = build_slippage_comparison_report(db_path)
+ assert report["status"] == "DATA_GATED"
+ assert report["sample_n"] == 0
+ assert report["actual_mean_slippage_bps"] is None
+
+
+def test_buy_slippage_sign_is_positive_when_filled_worse(tmp_path):
+ db_path = tmp_path / "execution_slippage.db"
+ result = insert_realized_slippage_sample(
+ db_path,
+ ticker="005930",
+ side="buy",
+ intended_price=70000,
+ actual_fill_price=70070,
+ recorded_at="2026-06-21",
+ )
+ # BUY 체결가가 의도가보다 비싸게 체결됐으면 양수 슬리피지(불리)
+ assert result["slippage_bps_actual"] > 0
+ assert abs(result["slippage_bps_actual"] - 10.0) < 1e-6 # 70/70000 = 10bps
+
+
+def test_sell_slippage_sign_is_positive_when_filled_worse(tmp_path):
+ db_path = tmp_path / "execution_slippage.db"
+ result = insert_realized_slippage_sample(
+ db_path,
+ ticker="000660",
+ side="SELL",
+ intended_price=200000,
+ actual_fill_price=199900,
+ recorded_at="2026-06-21",
+ )
+ # SELL 체결가가 의도가보다 싸게 체결됐으면 양수 슬리피지(불리)
+ assert result["slippage_bps_actual"] > 0
+
+
+def test_report_compares_against_assumed_bps_once_min_sample_reached(tmp_path):
+ db_path = tmp_path / "execution_slippage.db"
+ for i in range(MIN_SAMPLE_FOR_COMPARISON):
+ insert_realized_slippage_sample(
+ db_path,
+ ticker="005930",
+ side="BUY",
+ intended_price=70000,
+ actual_fill_price=70070, # 항상 10bps 불리하게 체결
+ recorded_at=f"2026-06-{21 + i}",
+ )
+
+ samples = fetch_all_samples(db_path)
+ assert len(samples) == MIN_SAMPLE_FOR_COMPARISON
+
+ report = build_slippage_comparison_report(db_path)
+ assert report["status"] == "OK"
+ assert abs(report["actual_mean_slippage_bps"] - 10.0) < 1e-6
+ assert abs(report["gap_bps"] - abs(10.0 - ASSUMED_SLIPPAGE_BPS)) < 1e-6
+ assert report["recommendation"]
+
+
+def test_intended_price_must_be_positive(tmp_path):
+ db_path = tmp_path / "execution_slippage.db"
+ import pytest
+
+ with pytest.raises(ValueError):
+ insert_realized_slippage_sample(
+ db_path,
+ ticker="005930",
+ side="BUY",
+ intended_price=0,
+ actual_fill_price=100,
+ recorded_at="2026-06-21",
+ )
diff --git a/tests/unit/test_kis_api_client_v1.py b/tests/unit/test_kis_api_client_v1.py
new file mode 100644
index 00000000..62abf3e5
--- /dev/null
+++ b/tests/unit/test_kis_api_client_v1.py
@@ -0,0 +1,98 @@
+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))
+
+import pytest
+
+from src.quant_engine.kis_api_client_v1 import (
+ KisCredentials,
+ OrderEndpointBlockedError,
+ _assert_read_only,
+)
+
+# governance/rules/06_no_direct_api_trading.yaml — 이 테스트는 절대 약화/삭제하지 않는다.
+
+FORBIDDEN_ORDER_PATHS = (
+ "/uapi/domestic-stock/v1/trading/order-cash",
+ "/uapi/domestic-stock/v1/trading/order-rvsecncl",
+ "/uapi/domestic-stock/v1/trading/order-credit",
+ "/uapi/domestic-stock/v1/trading/order-resv",
+ "/uapi/domestic-stock/v1/trading/inquire-balance", # governance/rules/07 — 계좌 보유종목 조회 금지
+)
+FORBIDDEN_ORDER_TR_IDS = (
+ "TTTC0802U", "TTTC0801U", "VTTC0802U", "VTTC0801U",
+ "TTTC8434R", "VTTC8434R", # governance/rules/07 — 주식잔고조회 금지
+)
+
+
+@pytest.mark.parametrize("path", FORBIDDEN_ORDER_PATHS)
+def test_order_path_is_blocked(path: str):
+ with pytest.raises(OrderEndpointBlockedError):
+ _assert_read_only(path, "FHKST01010100")
+
+
+@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)
+
+
+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"
diff --git a/tests/unit/test_qualitative_sell_strategy_store_v1.py b/tests/unit/test_qualitative_sell_strategy_store_v1.py
new file mode 100644
index 00000000..23ad288c
--- /dev/null
+++ b/tests/unit/test_qualitative_sell_strategy_store_v1.py
@@ -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")
diff --git a/tests/unit/test_qualitative_sell_strategy_v1.py b/tests/unit/test_qualitative_sell_strategy_v1.py
new file mode 100644
index 00000000..232326d9
--- /dev/null
+++ b/tests/unit/test_qualitative_sell_strategy_v1.py
@@ -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
diff --git a/tests/unit/test_snapshot_admin_store_v1.py b/tests/unit/test_snapshot_admin_store_v1.py
new file mode 100644
index 00000000..52bc790a
--- /dev/null
+++ b/tests/unit/test_snapshot_admin_store_v1.py
@@ -0,0 +1,249 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.quant_engine.snapshot_admin_server_v1 import build_ui_state
+from src.quant_engine.snapshot_admin_store_v1 import (
+ ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS,
+ export_payload,
+ import_seed_json,
+ load_approval_for_domain,
+ load_change_log_rows,
+ load_locks,
+ load_account_snapshot_rows,
+ load_settings_rows,
+ parse_account_snapshot_tsv,
+ open_connection,
+ lock_conflicts_for_rows,
+ validate_account_snapshot_rows,
+ validate_settings_rows,
+ build_validation_suggestions,
+ build_safe_autofix_actions,
+ apply_safe_autofix_action,
+ set_lock,
+ undo_last_change,
+ write_export_json,
+)
+
+
+def _seed_json(path: Path) -> None:
+ payload = {
+ "data": {
+ "settings": {
+ "total_asset_krw": 150000000,
+ "weekly_target_cash_pct": 14,
+ "orbit_start_yyyymm": "2026-01",
+ },
+ "account_snapshot": [
+ {
+ "captured_at": "2026-06-21T09:00:00+09:00",
+ "account": "real",
+ "account_type": "일반계좌",
+ "ticker": "005930",
+ "name": "삼성전자",
+ "holding_quantity": 10,
+ "available_quantity": 10,
+ "average_cost": 70000,
+ "total_cost": 700000,
+ "current_price": 71000,
+ "market_value": 710000,
+ "profit_loss": 10000,
+ "return_pct": 1.43,
+ "immediate_cash": 1000000,
+ "settlement_cash_d2": 1000000,
+ "available_cash": 1000000,
+ "open_order_amount": 0,
+ "monthly_contribution_limit": "",
+ "monthly_contribution_used": "",
+ "parse_status": "CAPTURE_READ_OK",
+ "user_confirmed": "Y",
+ "stop_price": 65000,
+ "highest_price_since_entry": 72000,
+ "entry_date": "2026-06-01",
+ "entry_stage": "stage_1",
+ "position_type": "core",
+ "last_updated": "2026-06-21T09:05:00+09:00",
+ }
+ ],
+ }
+ }
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def test_seed_import_and_export_round_trip(tmp_path):
+ db_path = tmp_path / "snapshot.db"
+ seed_path = tmp_path / "seed.json"
+ _seed_json(seed_path)
+
+ summary = import_seed_json(db_path, seed_path)
+ assert summary["settings_rows"] == 3
+ assert summary["account_snapshot_rows"] == 1
+
+ settings_rows = load_settings_rows(db_path)
+ assert settings_rows[0]["key"] == "total_asset_krw"
+ assert settings_rows[0]["value"] == 150000000
+
+ snapshot_rows = load_account_snapshot_rows(db_path)
+ assert snapshot_rows[0]["ticker"] == "005930"
+ assert snapshot_rows[0]["parse_status"] == "CAPTURE_READ_OK"
+
+ exported = export_payload(db_path)
+ assert exported["data"]["settings"]["weekly_target_cash_pct"] == 14
+ assert exported["data"]["account_snapshot"][0]["name"] == "삼성전자"
+
+ out = write_export_json(db_path, tmp_path / "export.json")
+ assert out.exists()
+
+
+def test_parse_account_snapshot_tsv_supports_headerless_and_header_rows():
+ headerless = "\n".join(
+ [
+ "\t".join(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS),
+ "\t".join(
+ [
+ "2026-06-21T09:00:00+09:00",
+ "real",
+ "일반계좌",
+ "005930",
+ "삼성전자",
+ "10",
+ "10",
+ "70000",
+ "700000",
+ "71000",
+ "710000",
+ "10000",
+ "1.43",
+ "1000000",
+ "1000000",
+ "1000000",
+ "0",
+ "",
+ "",
+ "CAPTURE_READ_OK",
+ "Y",
+ "65000",
+ "72000",
+ "2026-06-01",
+ "stage_1",
+ "core",
+ "2026-06-21T09:05:00+09:00",
+ ]
+ ),
+ ]
+ )
+ rows = parse_account_snapshot_tsv(headerless)
+ assert rows[0]["ticker"] == "005930"
+ assert rows[0]["holding_quantity"] == 10
+
+ with_header = "captured_at\taccount\tticker\n2026-06-21T09:00:00+09:00\treal\t005930"
+ rows2 = parse_account_snapshot_tsv(with_header)
+ assert rows2[0]["account"] == "real"
+ assert rows2[0]["ticker"] == "005930"
+
+
+def test_build_ui_state_reports_schema(tmp_path):
+ db_path = tmp_path / "snapshot.db"
+ seed_path = tmp_path / "seed.json"
+ _seed_json(seed_path)
+ import_seed_json(db_path, seed_path)
+
+ state = build_ui_state(db_path)
+ assert state["summary"]["settings_rows"] == 3
+ assert state["account_snapshot_columns"][: len(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS)] == ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS
+
+
+def test_change_log_approval_and_lock_workflow(tmp_path):
+ db_path = tmp_path / "snapshot.db"
+ seed_path = tmp_path / "seed.json"
+ _seed_json(seed_path)
+ import_seed_json(db_path, seed_path)
+
+ with open_connection(db_path) as conn:
+ set_lock(conn, "settings", "*", locked_by="tester", reason="review")
+ conn.commit()
+
+ locks = load_locks(db_path)
+ assert locks and locks[0]["domain"] == "settings"
+
+ approval = load_approval_for_domain(db_path, "settings")
+ assert approval["status"] == "PENDING"
+
+ changes = load_change_log_rows(db_path, limit=10)
+ assert changes
+
+
+def test_lock_conflicts_detect_row_targets(tmp_path):
+ db_path = tmp_path / "snapshot.db"
+ seed_path = tmp_path / "seed.json"
+ _seed_json(seed_path)
+ import_seed_json(db_path, seed_path)
+
+ with open_connection(db_path) as conn:
+ set_lock(conn, "settings", "total_asset_krw", locked_by="tester", reason="review")
+ set_lock(conn, "account_snapshot", "005930", locked_by="tester", reason="review")
+ conn.commit()
+
+ settings_conflicts = lock_conflicts_for_rows(
+ db_path,
+ "settings",
+ [{"key": "total_asset_krw", "value": 123, "note": ""}],
+ )
+ snapshot_conflicts = lock_conflicts_for_rows(
+ db_path,
+ "account_snapshot",
+ [{"ticker": "005930", "name": "삼성전자", "ordinal": 1}],
+ )
+
+ assert settings_conflicts and settings_conflicts[0]["target_ref"] == "total_asset_krw"
+ assert snapshot_conflicts and snapshot_conflicts[0]["target_ref"] == "005930"
+
+
+def test_undo_last_change_restores_previous_snapshot(tmp_path):
+ db_path = tmp_path / "snapshot.db"
+ seed_path = tmp_path / "seed.json"
+ _seed_json(seed_path)
+ import_seed_json(db_path, seed_path)
+
+ with open_connection(db_path) as conn:
+ from src.quant_engine.snapshot_admin_store_v1 import replace_settings
+
+ replace_settings(conn, [{"ordinal": 1, "key": "total_asset_krw", "value": 123, "note": "edited"}])
+
+ with open_connection(db_path) as conn:
+ undo_last_change(conn, "settings")
+
+ settings_rows = load_settings_rows(db_path)
+ assert settings_rows[0]["value"] == 150000000
+
+
+def test_validation_helpers_detect_invalid_rows():
+ assert "settings.total_asset_krw is required" in validate_settings_rows([{"key": "weekly_target_cash_pct", "value": 10}])
+ assert "account_snapshot row 1: ticker required" in validate_account_snapshot_rows(
+ [{"captured_at": "2026-06-21", "account": "real", "name": "삼성전자", "parse_status": "BAD"}]
+ )
+ suggestions = build_validation_suggestions(
+ [{"key": "weekly_target_cash_pct", "value": 10}],
+ [{"captured_at": "2026-06-21", "account": "real", "account_type": "일반계좌", "ticker": "005930", "name": "삼성전자", "parse_status": "CAPTURE_READ_OK", "user_confirmed": "N"}],
+ )
+ assert any("user_confirmed=Y" in item for item in suggestions)
+ actions = build_safe_autofix_actions(
+ [{"key": "total_asset_krw", "value": 150000000}],
+ [{"captured_at": "2026-06-21", "account": "real", "account_type": "일반계좌", "ticker": "005930", "name": "삼성전자", "parse_status": "CAPTURE_READ_OK", "user_confirmed": "N", "entry_stage": "stage_1", "position_type": ""}],
+ )
+ assert any(item["action_id"] == "confirm_captured_rows" for item in actions)
+
+
+def test_safe_autofix_updates_snapshot_defaults(tmp_path):
+ db_path = tmp_path / "snapshot.db"
+ seed_path = tmp_path / "seed.json"
+ _seed_json(seed_path)
+ import_seed_json(db_path, seed_path)
+
+ with open_connection(db_path) as conn:
+ result = apply_safe_autofix_action(conn, "confirm_captured_rows")
+ assert result["status"] == "AUTOFIXED"
+
+ snapshot_rows = load_account_snapshot_rows(db_path)
+ assert all(row.get("user_confirmed") == "Y" or str(row.get("parse_status")) != "CAPTURE_READ_OK" for row in snapshot_rows)
diff --git a/tests/unit/test_snapshot_admin_web_v1.py b/tests/unit/test_snapshot_admin_web_v1.py
new file mode 100644
index 00000000..8f7b0340
--- /dev/null
+++ b/tests/unit/test_snapshot_admin_web_v1.py
@@ -0,0 +1,144 @@
+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_snapshot_admin_web_v1 as validator
+from src.quant_engine.snapshot_admin_server_v1 import (
+ build_ui_state,
+ fetch_table_rows,
+ list_browsable_tables,
+ render_collection_html,
+ render_index_html,
+ render_tables_html,
+)
+from src.quant_engine.snapshot_admin_store_v1 import import_seed_json
+
+
+def test_render_index_html_contains_spreadsheet_surface():
+ html = render_index_html()
+ assert "Snapshot Admin" in html
+ assert "contenteditable" in html
+ assert "/api/settings/save" in html
+ assert "/api/account_snapshot/save" in html
+ assert "Lock target" in html
+ assert "Lock row" in html
+ assert "Approve pending" in html
+ assert "Refresh diff" in html
+ assert "Export approval packet" in html
+ assert "Selection Inspector" in html
+ assert "Recent row history" in html
+ assert "Save view" in html
+ assert "Apply TSV to selection" in html
+ assert "Ctrl+S" in html
+ assert "KIS Collection" in html
+ assert "Recent collector snapshots" in html
+ assert "Collection detail" in html
+ assert "Filter runs / snapshots / errors" in html
+ assert "Filter change log" in html
+ assert "Timeline" in html
+ assert "/collection" in html
+ assert "Open collection dashboard" in html
+
+
+def test_render_collection_html_contains_dashboard_surface():
+ html = render_collection_html()
+ assert "KIS Collection Dashboard" in html
+ assert "/api/state" in html
+ assert "Download raw JSON" in html
+ assert "Download CSV" in html
+ assert "Filter runs / snapshots / errors" in html
+ assert "Ticker quick search" in html
+ assert "Date quick search" in html
+
+
+def test_build_ui_state_exposes_expected_columns(tmp_path):
+ db_path = tmp_path / "snapshot_admin.db"
+ seed_path = ROOT / "GatherTradingData.json"
+ import_seed_json(db_path, seed_path)
+
+ state = build_ui_state(db_path)
+ assert state["summary"]["settings_rows"] > 0
+ assert state["summary"]["account_snapshot_rows"] > 0
+ assert state["summary"]["topology"]["mode"] == "single_workspace_sqlite"
+ assert state["summary"]["topology"]["settings_and_snapshot_share_db"] is True
+ assert state["summary"]["topology"]["collector_separate_db"] is True
+ assert state["account_snapshot_columns"][0] == "captured_at"
+ assert "settings" in state["validation"]
+ assert state["version"]["app"]
+ assert "fingerprint" in state["version"]["source"]
+ assert "collection" in state
+ assert "counts" in state["collection"]
+ assert "latest_report" in state["collection"]
+ assert state["summary"]["topology"]["mode"] == "single_workspace_sqlite"
+
+
+def test_snapshot_admin_workflow_and_script_exist():
+ workflow = ROOT / ".gitea" / "workflows" / "snapshot_admin.yml"
+ package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
+ assert workflow.exists()
+ assert "--reload" in package["scripts"]["ops:snapshot-web"]
+ assert "ops:snapshot-validate" in package["scripts"]
+ assert "ops:snapshot-web-validate" in package["scripts"]
+
+
+def test_render_tables_html_contains_tabler_grid_surface():
+ html = render_tables_html()
+ assert "tabler" in html.lower()
+ assert "tableSelect" in html
+ assert "/api/tables" in html
+ assert "/api/table_rows" in html
+ assert "gridTable" in html
+
+
+def test_list_browsable_tables_covers_all_three_databases(tmp_path):
+ db_path = tmp_path / "snapshot_admin.db"
+ import_seed_json(db_path, ROOT / "GatherTradingData.json")
+
+ tables = list_browsable_tables(db_path)
+ names = {row["table"] for row in tables}
+ assert {"settings", "account_snapshot", "workspace_change_log"} <= names
+ assert {"collection_runs", "collection_snapshots", "collection_source_errors"} <= names
+ assert {"sell_strategy_results", "satellite_recommendations"} <= names
+
+ settings_row = next(row for row in tables if row["table"] == "settings")
+ assert settings_row["exists"] is True
+ assert settings_row["row_count"] > 0
+
+
+def test_fetch_table_rows_paginates_and_rejects_unknown_table(tmp_path):
+ db_path = tmp_path / "snapshot_admin.db"
+ import_seed_json(db_path, ROOT / "GatherTradingData.json")
+
+ page1 = fetch_table_rows("settings", db_path, limit=2, offset=0)
+ assert page1["columns"]
+ assert len(page1["rows"]) == 2
+ assert page1["total"] > 2
+
+ page2 = fetch_table_rows("settings", db_path, limit=2, offset=2)
+ assert page1["rows"] != page2["rows"]
+
+ import pytest
+
+ with pytest.raises(ValueError):
+ fetch_table_rows("settings; DROP TABLE settings;--", db_path)
+
+
+def test_snapshot_admin_web_validation_script_passes():
+ out = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json"
+ if out.exists():
+ out.unlink()
+
+ rc = validator.main()
+ payload = json.loads(out.read_text(encoding="utf-8"))
+
+ assert rc == 0
+ assert payload["gate"] == "PASS"
+ assert payload["formula_id"] == "SNAPSHOT_ADMIN_WEB_VALIDATION_V1"
+ assert payload["settings_rows"] > 0
+ assert payload["account_snapshot_rows"] > 0
diff --git a/tests/unit/test_storage_backend_v1.py b/tests/unit/test_storage_backend_v1.py
new file mode 100644
index 00000000..b1d5ed3e
--- /dev/null
+++ b/tests/unit/test_storage_backend_v1.py
@@ -0,0 +1,32 @@
+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.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec
+
+
+def test_default_sqlite_store_path_uses_named_subdir(tmp_path):
+ path = default_sqlite_store_path(tmp_path, "qualitative_sell_strategy/qualitative_sell_strategy.db")
+ assert str(path).endswith("qualitative_sell_strategy.db")
+
+
+def test_normalize_store_spec_supports_sqlite_and_postgresql(tmp_path):
+ backend_sqlite, sqlite_location = normalize_store_spec(StoreSpec(location=tmp_path / "collector.db"), ROOT)
+ assert backend_sqlite == "sqlite"
+ assert str(sqlite_location).endswith("collector.db")
+
+ backend_pg, pg_location = normalize_store_spec(
+ StoreSpec(backend="postgresql", location="postgresql://user:pass@localhost/db"),
+ ROOT,
+ )
+ assert backend_pg == "postgresql"
+ assert "postgresql://" in str(pg_location)
+
+
+def test_postgresql_upgrade_stub_script_exists():
+ assert (ROOT / "tools" / "generate_postgresql_upgrade_stub_v1.py").exists()
diff --git a/tests/unit/test_validate_gitea_secrets_contract_v1.py b/tests/unit/test_validate_gitea_secrets_contract_v1.py
new file mode 100644
index 00000000..602d4627
--- /dev/null
+++ b/tests/unit/test_validate_gitea_secrets_contract_v1.py
@@ -0,0 +1,20 @@
+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_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"))
+
+ assert rc == 0
+ assert payload["gate"] == "PASS"
+ assert payload["evidence"][".gitea/workflows/kis_data_collection.yml"]["secrets.KIS_APP_KEY"] is True
diff --git a/tests/unit/test_validate_kis_api_credentials_v1.py b/tests/unit/test_validate_kis_api_credentials_v1.py
new file mode 100644
index 00000000..7511fa44
--- /dev/null
+++ b/tests/unit/test_validate_kis_api_credentials_v1.py
@@ -0,0 +1,52 @@
+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_kis_api_credentials_v1 as validator
+
+
+class _FakeCreds:
+ def __init__(self, account: str):
+ self.account = account
+ self.domain = "https://openapi.koreainvestment.com:9443" if account == "real" else "https://openapivts.koreainvestment.com:29443"
+ self.app_key = f"{account}-key"
+ 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"
+
+ 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: {"ticker": ticker, "price": 1000})
+ 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 == 0
+ assert payload["gate"] == "PASS"
+ assert payload["evidence"]["account"] == "mock"
+ assert payload["evidence"]["ticker"] == "005930"
+
+
+def test_validate_kis_api_credentials_fails_when_api_call_errors(tmp_path, monkeypatch):
+ out = tmp_path / "kis_api_credentials_validation_v1.json"
+
+ 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"]
diff --git a/tests/unit/test_validate_qualitative_sell_strategy_pipeline_v1.py b/tests/unit/test_validate_qualitative_sell_strategy_pipeline_v1.py
new file mode 100644
index 00000000..91e92671
--- /dev/null
+++ b/tests/unit/test_validate_qualitative_sell_strategy_pipeline_v1.py
@@ -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
diff --git a/tests/unit/test_validate_spec_code_sync_v1.py b/tests/unit/test_validate_spec_code_sync_v1.py
new file mode 100644
index 00000000..b9e259ea
--- /dev/null
+++ b/tests/unit/test_validate_spec_code_sync_v1.py
@@ -0,0 +1,69 @@
+"""WBS-7.11(2026-06-22) — spec-코드 동기화 게이트 단위 테스트."""
+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))
+
+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
+
+
+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)
+
+ 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
diff --git a/tools/build_calibration_approval_list_v1.py b/tools/build_calibration_approval_list_v1.py
new file mode 100644
index 00000000..c8332502
--- /dev/null
+++ b/tools/build_calibration_approval_list_v1.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""
+build_calibration_approval_list_v1.py
+───────────────────────────────────────────────────────────────────────────────
+calibration_review_report_v1.json을 읽어 PROVISIONAL 승격 승인 리스트를 만든다.
+
+목적:
+ - source=PROVISIONAL 인 임계값을 별도 승인 대상 리스트로 분리
+ - reviewer가 바로 볼 수 있는 Markdown/JSON 산출물 생성
+ - PROVISIONAL 승격과 provisional review를 분리해 운영 책임을 명확화
+
+출력:
+ Temp/calibration_approval_list_v1.json
+ Temp/calibration_approval_list_v1.md
+
+사용법:
+ python tools/build_calibration_approval_list_v1.py
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parent.parent
+REVIEW = ROOT / "Temp" / "calibration_review_report_v1.json"
+OUT_JSON = ROOT / "Temp" / "calibration_approval_list_v1.json"
+OUT_MD = ROOT / "Temp" / "calibration_approval_list_v1.md"
+
+if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
+ sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _table(rows: list[dict[str, Any]], keys: list[str], max_rows: int = 25) -> str:
+ if not rows:
+ return "_데이터 없음_"
+ header = "| " + " | ".join(keys) + " |"
+ sep = "| " + " | ".join(["---"] * len(keys)) + " |"
+ body = []
+ for row in rows[:max_rows]:
+ body.append("| " + " | ".join(str(row.get(k, "")).replace("|", "ㅣ") for k in keys) + " |")
+ suffix = f"\n\n_...총 {len(rows)}행 중 {max_rows}행 표시_" if len(rows) > max_rows else ""
+ return "\n".join([header, sep, *body]) + suffix
+
+
+def main() -> int:
+ review = _load_json(REVIEW)
+ rows = review.get("review_rows") if isinstance(review.get("review_rows"), list) else []
+
+ approval_candidates: list[dict[str, Any]] = []
+ provisional_review_candidates: list[dict[str, Any]] = []
+
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ source = str(row.get("source") or "")
+ readiness = str(row.get("readiness") or "")
+ sample_n = int(row.get("sample_n") or 0)
+ base = {
+ "id": row.get("id", ""),
+ "source": source,
+ "sample_n": sample_n,
+ "value": row.get("value"),
+ "unit": row.get("unit", ""),
+ "owner_formula": row.get("owner_formula", ""),
+ "readiness": readiness,
+ "reason": row.get("reason", ""),
+ }
+ if source == "PROVISIONAL":
+ approval_candidates.append(base)
+ elif readiness == "PROVISIONAL_CANDIDATE":
+ provisional_review_candidates.append(base)
+
+ approval_candidates.sort(key=lambda item: (-int(item.get("sample_n") or 0), str(item.get("id") or "")))
+ provisional_review_candidates.sort(key=lambda item: (-int(item.get("sample_n") or 0), str(item.get("id") or "")))
+
+ report = {
+ "formula_id": "CALIBRATION_APPROVAL_LIST_V1",
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "review_report_path": str(REVIEW),
+ "approval_candidate_count": len(approval_candidates),
+ "provisional_review_candidate_count": len(provisional_review_candidates),
+ "approval_candidates": approval_candidates,
+ "provisional_review_candidates": provisional_review_candidates,
+ }
+
+ OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ md_lines = [
+ "# Calibration Approval List",
+ "",
+ "## Summary",
+ "",
+ f"- approval candidates: {len(approval_candidates)}",
+ f"- provisional review candidates: {len(provisional_review_candidates)}",
+ "",
+ "## Approval Candidates",
+ "",
+ _table(approval_candidates, ["id", "source", "sample_n", "value", "unit", "owner_formula", "readiness", "reason"]),
+ "",
+ "## Provisional Review Candidates",
+ "",
+ _table(provisional_review_candidates, ["id", "source", "sample_n", "value", "unit", "owner_formula", "readiness", "reason"]),
+ "",
+ "## Evidence",
+ "",
+ f"- review report: {REVIEW}",
+ ]
+ OUT_MD.write_text("\n".join(md_lines), encoding="utf-8")
+
+ print(json.dumps({
+ "formula_id": report["formula_id"],
+ "gate": "PASS" if approval_candidates else "WARN",
+ "approval_candidate_count": len(approval_candidates),
+ "provisional_review_candidate_count": len(provisional_review_candidates),
+ "json_path": str(OUT_JSON),
+ "md_path": str(OUT_MD),
+ }, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/build_calibration_decision_draft_v1.py b/tools/build_calibration_decision_draft_v1.py
new file mode 100644
index 00000000..e25d86e9
--- /dev/null
+++ b/tools/build_calibration_decision_draft_v1.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""
+build_calibration_decision_draft_v1.py
+───────────────────────────────────────────────────────────────────────────────
+calibration_review_report_v1.json / calibration_approval_list_v1.json을 바탕으로
+운영 승인 초안(APPROVE / HOLD / REJECT)을 만든다.
+
+목적:
+ - 사람 검토 전 단계에서 결정 초안을 자동 생성
+ - source=PROVISIONAL은 원칙적으로 APPROVE
+ - PROVISIONAL_CANDIDATE는 HOLD
+ - 나머지는 REJECT 또는 HOLD로 사유를 명시
+
+출력:
+ Temp/calibration_decision_draft_v1.json
+ Temp/calibration_decision_draft_v1.md
+
+사용법:
+ python tools/build_calibration_decision_draft_v1.py
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parent.parent
+REVIEW = ROOT / "Temp" / "calibration_review_report_v1.json"
+APPROVAL = ROOT / "Temp" / "calibration_approval_list_v1.json"
+OUT_JSON = ROOT / "Temp" / "calibration_decision_draft_v1.json"
+OUT_MD = ROOT / "Temp" / "calibration_decision_draft_v1.md"
+
+if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
+ sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _table(rows: list[dict[str, Any]], keys: list[str], max_rows: int = 25) -> str:
+ if not rows:
+ return "_데이터 없음_"
+ header = "| " + " | ".join(keys) + " |"
+ sep = "| " + " | ".join(["---"] * len(keys)) + " |"
+ body = []
+ for row in rows[:max_rows]:
+ body.append("| " + " | ".join(str(row.get(k, "")).replace("|", "ㅣ") for k in keys) + " |")
+ suffix = f"\n\n_...총 {len(rows)}행 중 {max_rows}행 표시_" if len(rows) > max_rows else ""
+ return "\n".join([header, sep, *body]) + suffix
+
+
+def _decide(row: dict[str, Any]) -> tuple[str, str]:
+ source = str(row.get("source") or "")
+ readiness = str(row.get("readiness") or "")
+ sample_n = int(row.get("sample_n") or 0)
+ if source == "PROVISIONAL" and sample_n >= 30:
+ return "APPROVE", "source=PROVISIONAL and sample_n>=30"
+ if source == "PROVISIONAL":
+ return "APPROVE", "source=PROVISIONAL"
+ if readiness == "PROVISIONAL_CANDIDATE":
+ return "HOLD", "Needs provisional review"
+ if sample_n >= 10:
+ return "HOLD", "Sample present but not provisional"
+ return "REJECT", "Insufficient evidence"
+
+
+def main() -> int:
+ review = _load_json(REVIEW)
+ approval = _load_json(APPROVAL)
+
+ review_rows = review.get("review_rows") if isinstance(review.get("review_rows"), list) else []
+ decisions: list[dict[str, Any]] = []
+ summary = {"APPROVE": 0, "HOLD": 0, "REJECT": 0}
+
+ for row in review_rows:
+ if not isinstance(row, dict):
+ continue
+ decision, reason = _decide(row)
+ item = {
+ "id": row.get("id", ""),
+ "source": row.get("source", ""),
+ "sample_n": int(row.get("sample_n") or 0),
+ "value": row.get("value"),
+ "unit": row.get("unit", ""),
+ "owner_formula": row.get("owner_formula", ""),
+ "readiness": row.get("readiness", ""),
+ "decision": decision,
+ "reason": reason,
+ }
+ decisions.append(item)
+ summary[decision] += 1
+
+ decisions.sort(key=lambda item: ({"APPROVE": 0, "HOLD": 1, "REJECT": 2}.get(str(item.get("decision") or ""), 3), -int(item.get("sample_n") or 0), str(item.get("id") or "")))
+
+ report = {
+ "formula_id": "CALIBRATION_DECISION_DRAFT_V1",
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "review_report_path": str(REVIEW),
+ "approval_list_path": str(APPROVAL),
+ "summary": summary,
+ "decision_count": len(decisions),
+ "decisions": decisions,
+ "approval_candidate_count": int(approval.get("approval_candidate_count") or 0),
+ }
+
+ OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ md_lines = [
+ "# Calibration Decision Draft",
+ "",
+ "## Summary",
+ "",
+ f"- APPROVE: {summary['APPROVE']}",
+ f"- HOLD: {summary['HOLD']}",
+ f"- REJECT: {summary['REJECT']}",
+ f"- decision_count: {len(decisions)}",
+ "",
+ "## Decision Table",
+ "",
+ _table(decisions, ["id", "source", "sample_n", "decision", "reason", "owner_formula", "readiness"]),
+ "",
+ "## Evidence",
+ "",
+ f"- review report: {REVIEW}",
+ f"- approval list: {APPROVAL}",
+ ]
+ OUT_MD.write_text("\n".join(md_lines), encoding="utf-8")
+
+ print(json.dumps({
+ "formula_id": report["formula_id"],
+ "gate": "PASS" if summary["APPROVE"] else "WARN",
+ "approve_count": summary["APPROVE"],
+ "hold_count": summary["HOLD"],
+ "reject_count": summary["REJECT"],
+ "json_path": str(OUT_JSON),
+ "md_path": str(OUT_MD),
+ }, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/build_calibration_priority_v1.py b/tools/build_calibration_priority_v1.py
index d2e355aa..c0b794c9 100644
--- a/tools/build_calibration_priority_v1.py
+++ b/tools/build_calibration_priority_v1.py
@@ -29,6 +29,41 @@ ROOT = Path(__file__).resolve().parent.parent
AFL = ROOT / "Temp" / "alpha_feedback_loop_v2.json"
REG = ROOT / "spec" / "calibration_registry.yaml"
OUTPUT = ROOT / "Temp" / "calibration_priority_v1.json"
+PREDICTION_ACCURACY = ROOT / "Temp" / "prediction_accuracy_harness_v2.json"
+
+
+def registry_source_breakdown(reg_index: dict[str, dict]) -> dict:
+ """WBS-7.1(2026-06-21) — calibration_registry.yaml 전체의 source별 분포를 매 실행마다
+ 집계해 'CALIBRATED 비율이 실제로 몇 %인가'를 사람이 grep으로 직접 세지 않아도
+ 항상 최신 상태로 노출한다(2026-06-21 비판적 리뷰 0c절에서 0/190 발견 당시 수동 집계 필요했던 문제 해소)."""
+ counts: dict[str, int] = {"SPEC_DERIVED": 0, "EXPERT_PRIOR": 0, "PROVISIONAL": 0, "CALIBRATED": 0}
+ for entry in reg_index.values():
+ source = str(entry.get("source", "")).upper()
+ if source in counts:
+ counts[source] += 1
+ total = sum(counts.values())
+ return {
+ "total_thresholds": total,
+ "counts": counts,
+ "calibrated_pct": round(100.0 * counts["CALIBRATED"] / total, 2) if total else 0.0,
+ "unvalidated_pct": round(100.0 * (counts["SPEC_DERIVED"] + counts["EXPERT_PRIOR"]) / total, 2) if total else 0.0,
+ }
+
+
+def live_t5_status() -> dict:
+ """WBS-7.2/7.1(2026-06-21) — T+5 수치를 하드코딩하지 않고 항상 최신 산출물에서 읽는다.
+ Temp/prediction_accuracy_harness_v2.json이 없거나 sample=0이면 정직하게 DATA_GATED로 보고한다."""
+ if not PREDICTION_ACCURACY.exists():
+ return {"status": "ARTIFACT_MISSING", "t5_sample": 0, "t5_match_rate_pct": None}
+ data = load_json(PREDICTION_ACCURACY)
+ t5_sample = int(data.get("t5_sample") or 0)
+ t5_rate = data.get("t5_op_rate")
+ return {
+ "status": "DATA_GATED" if t5_sample == 0 else "OK",
+ "as_of_date": data.get("as_of_date"),
+ "t5_sample": t5_sample,
+ "t5_match_rate_pct": t5_rate,
+ }
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
@@ -90,6 +125,42 @@ def load_registry(p: Path) -> dict[str, dict]:
return {t["id"]: t for t in data.get("thresholds", []) if "id" in t}
+def _priority_from_registry_entry(entry: dict, source_tag: str, urgency_bias: int) -> dict:
+ sample_n = int(entry.get("sample_n", 0) or 0)
+ source = str(entry.get("source", "EXPERT_PRIOR"))
+ threshold_class = str(entry.get("threshold_class", "standard"))
+ urgency = urgency_bias
+ if source == "EXPERT_PRIOR":
+ urgency += 10
+ if source == "PROVISIONAL":
+ urgency += 20
+ if threshold_class == "live_critical":
+ urgency += 15
+ if sample_n == 0:
+ urgency += 5
+ if sample_n > 0:
+ urgency += max(0, 30 - sample_n)
+ return {
+ "calibration_id": entry.get("id", ""),
+ "current_value": entry.get("value"),
+ "owner_formula": entry.get("owner_formula", ""),
+ "source": source,
+ "sample_n": sample_n,
+ "linked_factor": source_tag,
+ "alpha_action": "registry_review",
+ "urgency_score": urgency,
+ "calibration_path": (
+ (
+ "표본 30건 이상 확보 후 PROVISIONAL 승격 → "
+ if sample_n >= 30
+ else f"표본 {30 - sample_n}건 추가 수집 후 PROVISIONAL 승격 → "
+ )
+ + "실측 T+5 승률 기반 최적값 backtest → CALIBRATED 확정"
+ ),
+ "rationale": f"source={source}, class={threshold_class}, sample_n={sample_n}",
+ }
+
+
def main() -> int:
afl_data = load_json(AFL)
reg_index = load_registry(REG)
@@ -112,48 +183,32 @@ def main() -> int:
priority_list: list[dict] = []
for adj in adjustments:
- factor = adj.get("factor", "")
- action = adj.get("action", "")
- rationale = adj.get("rationale", "")
- reg_ids = FACTOR_TO_REGISTRY.get(factor, [])
+ factor = str(adj.get("factor", ""))
+ action = str(adj.get("action", ""))
+ rationale = str(adj.get("rationale", ""))
+ reg_ids = FACTOR_TO_REGISTRY.get(factor, [])
for rid in reg_ids:
reg_entry = reg_index.get(rid)
if not reg_entry:
continue
- source = reg_entry.get("source", "EXPERT_PRIOR")
- sample_n = int(reg_entry.get("sample_n", 0) or 0)
- value = reg_entry.get("value")
- formula = reg_entry.get("owner_formula", "")
+ item = _priority_from_registry_entry(reg_entry, factor, miss5_count if factor == "passive_signal_quality" else 0)
+ item["alpha_action"] = action or "feedback_review"
+ if rationale:
+ item["rationale"] = rationale[:200]
+ priority_list.append(item)
- # 보정 우선도 점수: miss5_count 기여 + 미보정 가중
- urgency = 0
- if factor == "passive_signal_quality":
- urgency += miss5_count # miss가 많을수록 높은 urgency
- if source == "EXPERT_PRIOR":
- urgency += 10
- if sample_n == 0:
- urgency += 5
-
- priority_list.append({
- "calibration_id": rid,
- "current_value": value,
- "owner_formula": formula,
- "source": source,
- "sample_n": sample_n,
- "linked_factor": factor,
- "alpha_action": action,
- "urgency_score": urgency,
- "calibration_path": (
- (
- "표본 30건 이상 확보 후 PROVISIONAL 승격 → "
- if sample_n >= 30
- else f"표본 {30 - sample_n}건 추가 수집 후 PROVISIONAL 승격 → "
- )
- + "실측 T+5 승률 기반 최적값 backtest → CALIBRATED 확정"
- ),
- "rationale": rationale[:200] if rationale else "",
- })
+ if not priority_list:
+ # alpha_feedback_loop가 비어 있어도 registry 자체의 보정 debt를 추적할 수 있게 한다.
+ for reg_id, reg_entry in reg_index.items():
+ source = str(reg_entry.get("source", "EXPERT_PRIOR"))
+ if source not in {"EXPERT_PRIOR", "PROVISIONAL"}:
+ continue
+ tag = f"registry:{source.lower()}"
+ item = _priority_from_registry_entry(reg_entry, tag, 0)
+ if source == "PROVISIONAL":
+ item["urgency_score"] += 5
+ priority_list.append(item)
# 중복 제거 (같은 rid, 높은 urgency 유지)
seen: dict[str, dict] = {}
@@ -177,7 +232,19 @@ def main() -> int:
print(f" Step 2 (30건 후): ALEG_V2_GATE1_BLOCK_PCT 3.0% → 실측 최적값으로 PROVISIONAL 승격")
print(f" Step 3 (50건 후): DSD_V1 가중치 logistic regression 최적화")
print(f" Step 4 (100건 후): K2_SPLIT_RATIO backtest 비교 → CALIBRATED 확정")
- print(f" miss5_count={miss5_count}건 → passive_signal_quality 개선이 T+5 35.86%→50%+ 핵심")
+ registry_health = registry_source_breakdown(reg_index)
+ t5_status = live_t5_status()
+
+ print(f"\n [캘리브레이션 레지스트리 건강도] (WBS-7.1)")
+ print(f" total={registry_health['total_thresholds']} {registry_health['counts']}")
+ print(f" CALIBRATED={registry_health['calibrated_pct']}% 미검증(SPEC_DERIVED+EXPERT_PRIOR)={registry_health['unvalidated_pct']}%")
+
+ if t5_status["status"] == "DATA_GATED":
+ print(f" miss5_count={miss5_count}건 → T+5 현재 DATA_GATED(sample=0) — passive_signal_quality 개선 영향은 표본 누적 후 측정 가능")
+ elif t5_status["status"] == "ARTIFACT_MISSING":
+ print(f" miss5_count={miss5_count}건 → T+5 산출물 없음(Temp/prediction_accuracy_harness_v2.json) — 먼저 생성 필요")
+ else:
+ print(f" miss5_count={miss5_count}건 → T+5={t5_status['t5_match_rate_pct']}% (as_of={t5_status.get('as_of_date')}) → passive_signal_quality 개선 핵심")
result = {
"status": "CALIBRATION_PRIORITY_OK",
@@ -191,10 +258,14 @@ def main() -> int:
"step3": "50건 후: DSD_V1 가중치 logistic regression 최적화",
"step4": "100건 후: K2_SPLIT_RATIO 30/70~60/40 backtest → CALIBRATED",
},
+ "priority_basis": "alpha_feedback_loop_v2" if adjustments else "registry_warning_fallback",
+ "registry_health": registry_health,
"target_improvement": {
- "current_t5_pct": 35.86,
+ "t5_status": t5_status["status"],
+ "current_t5_pct": t5_status["t5_match_rate_pct"],
+ "t5_as_of_date": t5_status.get("as_of_date"),
"target_t5_pct": 55.0,
- "key_lever": "passive_signal_quality (miss5_count=51건 개선)",
+ "key_lever": f"passive_signal_quality (miss5_count={miss5_count}건 개선)",
},
}
diff --git a/tools/build_calibration_review_report_v1.py b/tools/build_calibration_review_report_v1.py
new file mode 100644
index 00000000..6be6442e
--- /dev/null
+++ b/tools/build_calibration_review_report_v1.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""
+build_calibration_review_report_v1.py
+───────────────────────────────────────────────────────────────────────────────
+calibration_registry.yaml + calibration_priority_v1.json + calibration_change_ledger_v4.json
+을 묶어 운영용 보정 리뷰 리포트를 만든다.
+
+목적:
+ - PROVISIONAL / CALIBRATED 승격 후보를 사람이 읽을 수 있게 정리
+ - registry warning fallback 상태를 숨기지 않고 그대로 공시
+ - 월간 보정 운영에서 바로 참고 가능한 Markdown + JSON 산출물 생성
+
+출력:
+ Temp/calibration_review_report_v1.json
+ Temp/calibration_review_report_v1.md
+
+사용법:
+ python tools/build_calibration_review_report_v1.py
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+ROOT = Path(__file__).resolve().parent.parent
+REGISTRY = ROOT / "spec" / "calibration_registry.yaml"
+PRIORITY = ROOT / "Temp" / "calibration_priority_v1.json"
+LEDGER = ROOT / "Temp" / "calibration_change_ledger_v4.json"
+OUT_JSON = ROOT / "Temp" / "calibration_review_report_v1.json"
+OUT_MD = ROOT / "Temp" / "calibration_review_report_v1.md"
+
+if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
+ sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _load_registry(path: Path) -> list[dict[str, Any]]:
+ if not path.exists():
+ return []
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
+ thresholds = data.get("thresholds", [])
+ return [t for t in thresholds if isinstance(t, dict)]
+
+
+def _readiness(entry: dict[str, Any]) -> tuple[str, str]:
+ source = str(entry.get("source") or "EXPERT_PRIOR")
+ sample_n = int(entry.get("sample_n") or 0)
+ if source == "CALIBRATED":
+ return "CALIBRATED", "Already calibrated"
+ if source == "PROVISIONAL" and sample_n >= 30:
+ return "CALIBRATION_READY", "Ready for calibrated review"
+ if source == "PROVISIONAL":
+ return "PROVISIONAL_ACTIVE", "Provisional with live samples"
+ if sample_n >= 10:
+ return "PROVISIONAL_CANDIDATE", "Candidate for provisional review"
+ return "WATCH", "Keep under watch"
+
+
+def _table(rows: list[dict[str, Any]], keys: list[str], max_rows: int = 25) -> str:
+ if not rows:
+ return "_데이터 없음_"
+ header = "| " + " | ".join(keys) + " |"
+ sep = "| " + " | ".join(["---"] * len(keys)) + " |"
+ body = []
+ for row in rows[:max_rows]:
+ body.append("| " + " | ".join(str(row.get(k, "")).replace("|", "ㅣ") for k in keys) + " |")
+ suffix = f"\n\n_...총 {len(rows)}행 중 {max_rows}행 표시_" if len(rows) > max_rows else ""
+ return "\n".join([header, sep, *body]) + suffix
+
+
+def main() -> int:
+ registry = _load_registry(REGISTRY)
+ priority = _load_json(PRIORITY)
+ ledger = _load_json(LEDGER)
+
+ source_counts: dict[str, int] = {}
+ readiness_counts: dict[str, int] = {}
+ reviewed_rows: list[dict[str, Any]] = []
+
+ for entry in registry:
+ source = str(entry.get("source") or "EXPERT_PRIOR")
+ source_counts[source] = source_counts.get(source, 0) + 1
+ readiness, reason = _readiness(entry)
+ readiness_counts[readiness] = readiness_counts.get(readiness, 0) + 1
+ if readiness in {"PROVISIONAL_CANDIDATE", "CALIBRATION_READY", "PROVISIONAL_ACTIVE"}:
+ reviewed_rows.append(
+ {
+ "id": entry.get("id", ""),
+ "source": source,
+ "sample_n": int(entry.get("sample_n") or 0),
+ "value": entry.get("value"),
+ "unit": entry.get("unit", ""),
+ "owner_formula": entry.get("owner_formula", ""),
+ "readiness": readiness,
+ "reason": reason,
+ "notes": str(entry.get("notes") or "")[:120],
+ }
+ )
+
+ priority_list = priority.get("priority_list") if isinstance(priority.get("priority_list"), list) else []
+ priority_rows = []
+ for item in priority_list[:20]:
+ if not isinstance(item, dict):
+ continue
+ priority_rows.append(
+ {
+ "calibration_id": item.get("calibration_id", ""),
+ "source": item.get("source", ""),
+ "sample_n": item.get("sample_n", 0),
+ "urgency_score": item.get("urgency_score", 0),
+ "linked_factor": item.get("linked_factor", ""),
+ "owner_formula": item.get("owner_formula", ""),
+ }
+ )
+
+ report = {
+ "formula_id": "CALIBRATION_REVIEW_REPORT_V1",
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "registry_path": str(REGISTRY),
+ "priority_path": str(PRIORITY),
+ "ledger_path": str(LEDGER),
+ "summary": {
+ "total_thresholds": len(registry),
+ "source_counts": source_counts,
+ "readiness_counts": readiness_counts,
+ "priority_count": int(priority.get("priority_count") or len(priority_rows)),
+ "ledger_change_count": len(ledger.get("changes", [])) if isinstance(ledger.get("changes"), list) else 0,
+ "ledger_without_change_count": int(ledger.get("threshold_change_without_ledger_count") or 0),
+ },
+ "top_priority_rows": priority_rows,
+ "review_rows": reviewed_rows,
+ }
+
+ OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ md_lines = [
+ "# Calibration Review Report",
+ "",
+ "## Summary",
+ "",
+ f"- total thresholds: {report['summary']['total_thresholds']}",
+ f"- priority count: {report['summary']['priority_count']}",
+ f"- ledger change count: {report['summary']['ledger_change_count']}",
+ f"- ledger without change count: {report['summary']['ledger_without_change_count']}",
+ "",
+ "### Source Counts",
+ "",
+ _table(
+ [{"source": k, "count": v} for k, v in sorted(source_counts.items())],
+ ["source", "count"],
+ max_rows=50,
+ ),
+ "",
+ "### Readiness Counts",
+ "",
+ _table(
+ [{"readiness": k, "count": v} for k, v in sorted(readiness_counts.items())],
+ ["readiness", "count"],
+ max_rows=50,
+ ),
+ "",
+ "## Top Priority Rows",
+ "",
+ _table(priority_rows, ["calibration_id", "source", "sample_n", "urgency_score", "linked_factor", "owner_formula"]),
+ "",
+ "## Review Candidates",
+ "",
+ _table(reviewed_rows, ["id", "source", "sample_n", "value", "unit", "owner_formula", "readiness", "reason"]),
+ "",
+ "## Evidence",
+ "",
+ f"- registry: {REGISTRY}",
+ f"- priority: {PRIORITY}",
+ f"- ledger: {LEDGER}",
+ ]
+ OUT_MD.write_text("\n".join(md_lines), encoding="utf-8")
+
+ print(json.dumps({
+ "formula_id": report["formula_id"],
+ "gate": "PASS" if reviewed_rows or priority_rows else "WARN",
+ "review_rows": len(reviewed_rows),
+ "priority_rows": len(priority_rows),
+ "json_path": str(OUT_JSON),
+ "md_path": str(OUT_MD),
+ }, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/build_macro_context_from_workbook_v1.py b/tools/build_macro_context_from_workbook_v1.py
new file mode 100644
index 00000000..d992f21f
--- /dev/null
+++ b/tools/build_macro_context_from_workbook_v1.py
@@ -0,0 +1,204 @@
+"""GatherTradingData.xlsx에서 실제 매크로/이벤트/포지션 컨텍스트를 추출.
+
+build_qualitative_sell_inputs_v1.py의 --context-json을 수동 작성하지 않고, 이미
+GAS 하네스가 산출/수집해 둔 시트 값을 그대로 읽어 자동 조립한다(중복 수집 금지
+원칙 — qualitative_sell_strategy_v1.yaml:data_sources 참조).
+
+실측 확인된 시트/컬럼(2026-06-21):
+ - macro 시트: Symbol='MRS_COMPUTED'.Close = market_risk_score(0~10, 하네스 산출).
+ Symbol='^TNX'(US10Y_Yield).Ret20D = 20일 금리추세 proxy(국내 기준금리 시트 없음 —
+ 한국은행 금통위 일정은 event_calendar Type='BOK'로 별도 포착).
+ - event_risk 시트: Date/DaysLeft/Event/Type/Impact(HIGH/MEDIUM/LOW)/Alert/AsOfDate.
+ - event_calendar 시트: Date/Event/Type(EARNINGS/FOMC/BOK/...)/Impact/DaysLeft 등.
+ Type='EARNINGS'에 종목명이 Event 텍스트에 포함된 행만 종목별 실적발표일로 매칭.
+ - account_snapshot 시트: ticker/name/holding_quantity/parse_status='CAPTURE_READ_OK'.
+"""
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+from openpyxl import load_workbook
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+RATE_RISING_THRESHOLD_PCT = 2.0
+RATE_FALLING_THRESHOLD_PCT = -2.0
+
+
+def _read_sheet_rows(xlsx_path: Path, sheet: str) -> tuple[tuple, list[dict[str, Any]]]:
+ """헤더 행을 탐색한다. 일부 시트(macro/event_risk)는 1행에 'updated: ...' 배너
+ 셀 1개만 있고 실제 헤더는 2행 — 비어있거나 단일 셀뿐인 선행 행은 건너뛴다."""
+ wb = load_workbook(xlsx_path, read_only=True, data_only=True)
+ ws = wb[sheet]
+ rows_iter = ws.iter_rows(min_row=1, values_only=True)
+ header: tuple = ()
+ for row in rows_iter:
+ non_empty = [c for c in row if c is not None]
+ if len(non_empty) >= 2:
+ header = row
+ break
+ rows = [dict(zip(header, row)) for row in rows_iter if any(c is not None for c in row)]
+ return header, rows
+
+
+def read_macro_pressure_and_regime(xlsx_path: Path) -> dict[str, Any]:
+ """MRS_COMPUTED.Close(0~10) -> macro_pressure(-1~+1, 위험도 높을수록 매도압력).
+
+ ^TNX Ret20D(%) -> rate_trend(RISING/FLAT/FALLING) — 국내 기준금리 시트가 없어
+ 미국채 10년물 20일 변화율을 proxy로 사용한다(국내 금리는 미 국채와 강한 동행성).
+ """
+ _, rows = _read_sheet_rows(xlsx_path, "macro")
+ by_symbol = {row.get("Symbol"): row for row in rows}
+
+ mrs_row = by_symbol.get("MRS_COMPUTED")
+ macro_pressure = None
+ market_risk_score = None
+ if mrs_row is not None and isinstance(mrs_row.get("Close"), (int, float)):
+ market_risk_score = float(mrs_row["Close"])
+ macro_pressure = max(-1.0, min(1.0, (market_risk_score / 10.0) * 2.0 - 1.0))
+
+ tnx_row = by_symbol.get("^TNX")
+ rate_trend = None
+ rate_ret20d_pct = None
+ if tnx_row is not None and tnx_row.get("Ret20D") not in (None, ""):
+ try:
+ rate_ret20d_pct = float(tnx_row["Ret20D"])
+ except (TypeError, ValueError):
+ rate_ret20d_pct = None
+ if rate_ret20d_pct is not None:
+ if rate_ret20d_pct >= RATE_RISING_THRESHOLD_PCT:
+ rate_trend = "RISING"
+ elif rate_ret20d_pct <= RATE_FALLING_THRESHOLD_PCT:
+ rate_trend = "FALLING"
+ else:
+ rate_trend = "FLAT"
+
+ regime_row = by_symbol.get("REGIME_PRELIM")
+ regime_prelim = regime_row.get("Close") if regime_row else None
+
+ return {
+ "macro_pressure": macro_pressure,
+ "market_risk_score": market_risk_score,
+ "rate_trend": rate_trend,
+ "rate_ret20d_pct": rate_ret20d_pct,
+ "regime_prelim": regime_prelim,
+ "macro_pressure_source": "GatherTradingData.xlsx:macro",
+ }
+
+
+def read_next_macro_event(xlsx_path: Path, today: dt.date | None = None) -> dict[str, Any]:
+ """event_risk 시트에서 오늘 이후 가장 가까운 HIGH 임팩트 이벤트일."""
+ today = today or dt.date.today()
+ _, rows = _read_sheet_rows(xlsx_path, "event_risk")
+ candidates = []
+ for row in rows:
+ event_date = row.get("Date")
+ if not isinstance(event_date, dt.datetime):
+ continue
+ event_date = event_date.date()
+ if event_date < today or row.get("Impact") not in {"HIGH"}:
+ continue
+ candidates.append((event_date, row.get("Event"), row.get("Impact")))
+ if not candidates:
+ return {"next_macro_event_date": None, "macro_event_impact": None}
+ candidates.sort(key=lambda item: item[0])
+ event_date, event_name, impact = candidates[0]
+ return {
+ "next_macro_event_date": event_date.isoformat(),
+ "macro_event_impact": impact,
+ "macro_event_name": event_name,
+ "macro_event_source": "GatherTradingData.xlsx:event_risk",
+ }
+
+
+def read_next_earnings_date(xlsx_path: Path, company_name: str, today: dt.date | None = None) -> dict[str, Any]:
+ """event_calendar에서 Type='EARNINGS'이며 Event 텍스트에 종목명이 포함된 가장 빠른 미래 일정."""
+ today = today or dt.date.today()
+ _, rows = _read_sheet_rows(xlsx_path, "event_calendar")
+ candidates = []
+ name = (company_name or "").strip()
+ if not name:
+ return {"next_earnings_date": None, "earnings_event_impact": None}
+ for row in rows:
+ if row.get("Type") != "EARNINGS":
+ continue
+ event_text = str(row.get("Event") or "")
+ if name not in event_text:
+ continue
+ event_date = row.get("Date")
+ if isinstance(event_date, dt.datetime):
+ event_date = event_date.date()
+ elif isinstance(event_date, str):
+ try:
+ event_date = dt.date.fromisoformat(event_date)
+ except ValueError:
+ continue
+ else:
+ continue
+ if event_date < today:
+ continue
+ candidates.append((event_date, row.get("Impact")))
+ if not candidates:
+ return {"next_earnings_date": None, "earnings_event_impact": None}
+ candidates.sort(key=lambda item: item[0])
+ event_date, impact = candidates[0]
+ return {
+ "next_earnings_date": event_date.isoformat(),
+ "earnings_event_impact": impact,
+ "earnings_source": "GatherTradingData.xlsx:event_calendar",
+ }
+
+
+def read_positions(xlsx_path: Path) -> list[dict[str, Any]]:
+ """account_snapshot에서 실제 보유 종목 목록(CAPTURE_READ_OK, 보유수량>0)."""
+ _, rows = _read_sheet_rows(xlsx_path, "account_snapshot")
+ positions: dict[str, dict[str, Any]] = {}
+ for row in rows:
+ if row.get("parse_status") != "CAPTURE_READ_OK":
+ continue
+ ticker_raw = row.get("ticker")
+ qty = row.get("holding_quantity") or 0
+ if ticker_raw is None or not isinstance(qty, (int, float)) or qty <= 0:
+ continue
+ ticker = str(ticker_raw)
+ ticker = ticker.zfill(6) if ticker.isdigit() else ticker
+ entry = positions.setdefault(ticker, {"ticker": ticker, "name": row.get("name"), "holding_quantity": 0.0})
+ entry["holding_quantity"] += float(qty) # 소수주 분리 행 합산
+ return list(positions.values())
+
+
+def build_context_for_ticker(xlsx_path: Path, ticker: str, company_name: str) -> dict[str, Any]:
+ today = dt.date.today()
+ ctx: dict[str, Any] = {}
+ ctx.update(read_macro_pressure_and_regime(xlsx_path))
+ ctx.update(read_next_macro_event(xlsx_path, today))
+ ctx.update(read_next_earnings_date(xlsx_path, company_name, today))
+ return ctx
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--xlsx", type=Path, default=ROOT / "GatherTradingData.xlsx")
+ ap.add_argument("--ticker", default=None)
+ ap.add_argument("--name", default=None, help="실적발표 일정 매칭용 종목명(한글)")
+ ap.add_argument("--list-positions", action="store_true")
+ args = ap.parse_args()
+
+ if args.list_positions:
+ print(json.dumps(read_positions(args.xlsx), ensure_ascii=False, indent=2))
+ return 0
+
+ result = build_context_for_ticker(args.xlsx, args.ticker or "", args.name or "")
+ print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/build_qualitative_sell_inputs_v1.py b/tools/build_qualitative_sell_inputs_v1.py
new file mode 100644
index 00000000..b066a20f
--- /dev/null
+++ b/tools/build_qualitative_sell_inputs_v1.py
@@ -0,0 +1,342 @@
+"""qualitative_sell_strategy_v1 입력 ctx 조립 오케스트레이터.
+
+데이터 출처 (2026-06-21 세션 실측 기준, KIS Open API 연동 이후):
+ - relative_return_20d, volume_ratio_5d ← tools/fetch_naver_market_data_v1.py (무인증, 동작 확인)
+ - sector_export_trend ← tools/fetch_trade_statistics_motie_v1.py (--csv 경로 권장)
+ - short_turnover_share ← [신규] KIS Open API daily-short-sale(FHPST04830000)
+ output2.ssts_vol_rlim — 실측 동작 확인(실전계좌 도메인,
+ 모의계좌 도메인은 500 에러). --kis-account real 필요.
+ - short_balance_ratio(잔고율) ← 여전히 미확보. KIS API도 제공하지 않음(KRX 공매도종합
+ 포털 대량보유 공시 전용 데이터) — --short-csv 수동
+ 다운로드로만 가능.
+ - microstructure_pressure(호가10단계) ← [신규] KIS Open API inquire-asking-price-exp-ccn
+ (FHKST01010200) output1.total_askp_rsqn/total_bidp_rsqn
+ — 실측 동작 확인(실전+모의 도메인 모두). --kis-account
+ {real,mock}로 활성화.
+ - macro_pressure, rate_trend, next_earnings_date, next_macro_event_date, macro_event_impact
+ ← 기존 GAS 하네스(macro_event_synchronizer_v2,
+ gas_event_calendar.gs)가 이미 산출/수집 중 —
+ 이 스크립트가 중복 수집하지 않고 --context-json/
+ --workbook으로 그 결과를 주입받는다.
+ - investing.com ← 직접 스크래핑 403(Cloudflare) 차단 확인. 사용 안 함.
+
+[CRITICAL] KIS API는 조회(read-only)로만 사용한다 — 매수/매도 주문은 어떤 경우에도 이 코드를
+통해 실행하지 않는다(governance/rules/06_no_direct_api_trading.yaml, CI 강제 게이트
+tools/validate_no_direct_api_trading_v1.py).
+
+사용 예:
+ python tools/build_qualitative_sell_inputs_v1.py \
+ --ticker 005930 --benchmark-code 069500 --sector 반도체 \
+ --kis-account real --short-csv Temp/krx_short_balance_manual.csv \
+ --context-json Temp/macro_context.json --apply
+"""
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from tools.fetch_naver_market_data_v1 import (
+ _session,
+ compute_relative_return_20d,
+ compute_volume_ratio_5d,
+ fetch_price_history,
+)
+from tools.fetch_trade_statistics_motie_v1 import (
+ compute_sector_export_trend,
+ load_trade_statistics_csv,
+)
+from src.quant_engine.qualitative_sell_strategy_v1 import (
+ compute_microstructure_pressure_from_orderbook,
+ compute_qualitative_sell_strategy,
+ compute_short_interest_composite,
+)
+from src.quant_engine.qualitative_sell_strategy_store_v1 import (
+ QualitativeSellStoreSpec,
+ insert_sell_strategy_result,
+ resolve_store_path,
+)
+
+DEFAULT_OUTPUT_DIR = ROOT / "outputs" / "qualitative_sell_strategy"
+DEFAULT_SQLITE_DB = DEFAULT_OUTPUT_DIR / "qualitative_sell_strategy.db"
+
+
+def _kst_now_iso() -> str:
+ return dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat()
+
+
+def _parse_date(value: str | None) -> dt.date | None:
+ if not value:
+ return None
+ try:
+ return dt.date.fromisoformat(value)
+ except ValueError:
+ return None
+
+
+def load_short_interest_csv(path: Path, code: str) -> dict[str, Any]:
+ """KRX 공매도종합포털 수동 다운로드 CSV. 컬럼: 종목코드, 잔고율, 잔고율변화20일, 거래비중."""
+ import csv
+
+ with path.open(encoding="utf-8-sig", newline="") as f:
+ for row in csv.DictReader(f):
+ row_code = str(row.get("종목코드") or row.get("code") or "").strip().zfill(6)
+ if row_code == code:
+ return {
+ "short_balance_ratio": float(row.get("잔고율") or row.get("short_balance_ratio") or 0),
+ "short_balance_ratio_chg_20d": float(row.get("잔고율변화20일") or row.get("short_balance_ratio_chg_20d") or 0),
+ "short_turnover_share": float(row.get("거래비중") or row.get("short_turnover_share") or 0),
+ }
+ return {}
+
+
+def fetch_kis_supplement(code: str, kis_account: str | None) -> dict[str, Any]:
+ """KIS Open API에서 short_turnover_share(공매도거래비중)와 microstructure_pressure
+ (호가10단계)를 조회한다. 조회(read-only)만 수행 — 주문 관련 호출 없음."""
+ if not kis_account:
+ return {}
+ from src.quant_engine.kis_api_client_v1 import KisCredentials, get_asking_price_10_level, get_daily_short_sale
+
+ result: dict[str, Any] = {}
+ try:
+ creds = KisCredentials.load(kis_account)
+ except RuntimeError as exc:
+ return {"kis_error": str(exc)}
+
+ try:
+ ob = get_asking_price_10_level(creds, code)
+ micro = compute_microstructure_pressure_from_orderbook(ob.get("output1", {}))
+ if micro.get("status") == "OK":
+ result["microstructure_pressure"] = micro["microstructure_pressure"]
+ except Exception as exc: # noqa: BLE001 — KIS 호출 실패가 전체 파이프라인을 막지 않음
+ result["kis_orderbook_error"] = str(exc)
+
+ try:
+ today = dt.date.today()
+ start = (today - dt.timedelta(days=10)).strftime("%Y%m%d")
+ end = today.strftime("%Y%m%d")
+ ss = get_daily_short_sale(creds, code, start, end)
+ rows = ss.get("output2") or []
+ if rows:
+ latest = rows[0]
+ ssts_vol_rlim = latest.get("ssts_vol_rlim")
+ if ssts_vol_rlim is not None:
+ result["short_turnover_share"] = float(ssts_vol_rlim)
+ except Exception as exc: # noqa: BLE001
+ result["kis_short_sale_error"] = str(exc)
+
+ return result
+
+
+def build_ctx_for_ticker(
+ code: str,
+ benchmark_code: str,
+ sector: str | None,
+ earnings_outlook: str,
+ trade_csv: Path | None,
+ short_csv: Path | None,
+ external_context: dict[str, Any],
+ kis_account: str | None = None,
+) -> dict[str, Any]:
+ session = _session()
+ price = fetch_price_history(session, code)
+ benchmark = fetch_price_history(session, benchmark_code)
+
+ relative_return_20d = compute_relative_return_20d(price.get("rows", []), benchmark.get("rows", []))
+ volume_ratio_5d = compute_volume_ratio_5d(price.get("rows", []))
+ kis_supplement = fetch_kis_supplement(code, kis_account)
+
+ short_inputs: dict[str, Any] = {}
+ if short_csv and short_csv.exists():
+ short_inputs = load_short_interest_csv(short_csv, code)
+ if "short_turnover_share" in kis_supplement:
+ short_inputs["short_turnover_share"] = kis_supplement["short_turnover_share"]
+ short_inputs.setdefault("relative_return_20d", relative_return_20d)
+ short_inputs.setdefault("volume_ratio_5d", volume_ratio_5d)
+ short_inputs.setdefault("earnings_outlook", earnings_outlook)
+ short_interest = compute_short_interest_composite(short_inputs)
+
+ sector_export_trend = None
+ if trade_csv and trade_csv.exists() and sector:
+ rows = load_trade_statistics_csv(trade_csv)
+ export_result = compute_sector_export_trend(rows, sector, compare="yoy")
+ if export_result.get("status") == "OK":
+ sector_export_trend = export_result["sector_export_trend"]
+
+ fundamental_trajectory = external_context.get("fundamental_trajectory")
+ if fundamental_trajectory is None and sector_export_trend is not None:
+ fundamental_trajectory = max(-1.0, min(1.0, -sector_export_trend / 15.0))
+
+ ctx: dict[str, Any] = {
+ "today": dt.date.today(),
+ "macro_pressure": external_context.get("macro_pressure"),
+ "fundamental_trajectory": fundamental_trajectory,
+ "short_interest_pressure": short_interest.get("short_interest_pressure"),
+ "microstructure_pressure": kis_supplement.get("microstructure_pressure", external_context.get("microstructure_pressure")),
+ "liquidity_rotation_risk": external_context.get("liquidity_rotation_risk"),
+ "earnings_outlook": earnings_outlook,
+ "next_earnings_date": _parse_date(external_context.get("next_earnings_date")),
+ "next_macro_event_date": _parse_date(external_context.get("next_macro_event_date")),
+ "macro_event_impact": external_context.get("macro_event_impact"),
+ "rate_trend": external_context.get("rate_trend"),
+ }
+ return {
+ "code": code,
+ "ctx": ctx,
+ "short_interest_composite": short_interest,
+ "sector_export_trend": sector_export_trend,
+ "relative_return_20d": relative_return_20d,
+ "volume_ratio_5d": volume_ratio_5d,
+ "kis_supplement": kis_supplement,
+ "generated_at": _kst_now_iso(),
+ }
+
+
+def process_one(
+ ticker: str,
+ name: str,
+ benchmark_code: str,
+ sector: str | None,
+ earnings_outlook: str,
+ trade_csv: Path | None,
+ short_csv: Path | None,
+ workbook: Path | None,
+ context_json: Path | None,
+ kis_account: str | None = None,
+) -> dict[str, Any]:
+ external_context: dict[str, Any] = {}
+ if context_json and context_json.exists():
+ external_context = json.loads(context_json.read_text(encoding="utf-8"))
+ elif workbook and workbook.exists():
+ from tools.build_macro_context_from_workbook_v1 import build_context_for_ticker
+ external_context = build_context_for_ticker(workbook, ticker, name)
+
+ assembled = build_ctx_for_ticker(
+ code=ticker,
+ benchmark_code=benchmark_code,
+ sector=sector,
+ earnings_outlook=earnings_outlook,
+ trade_csv=trade_csv,
+ short_csv=short_csv,
+ external_context=external_context,
+ kis_account=kis_account,
+ )
+ decision = compute_qualitative_sell_strategy(assembled["ctx"])
+ result = {**assembled, "decision": decision}
+ result["ctx"] = {k: (v.isoformat() if isinstance(v, dt.date) else v) for k, v in result["ctx"].items()}
+ return result
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--ticker", default=None, help="6자리 종목코드(단일 실행 시 필수)")
+ ap.add_argument("--name", default=None, help="실적발표 매칭용 종목명(한글)")
+ ap.add_argument("--benchmark-code", default="069500")
+ ap.add_argument("--sector", default=None, help="fetch_trade_statistics_motie_v1.SECTOR_HS_MAP 키")
+ ap.add_argument("--earnings-outlook", default="STABLE", choices=["IMPROVING", "STABLE", "DETERIORATING"])
+ ap.add_argument("--trade-csv", type=Path, default=None)
+ ap.add_argument("--short-csv", type=Path, default=None, help="KRX 공매도종합포털 수동 다운로드 CSV")
+ ap.add_argument("--context-json", type=Path, default=None, help="macro_pressure/rate_trend/이벤트일 등 외부 산출값 JSON(수동)")
+ ap.add_argument("--workbook", type=Path, default=None, help="GatherTradingData.xlsx — macro/event_risk/event_calendar 시트에서 컨텍스트 자동 추출(권장)")
+ ap.add_argument("--batch", action="store_true", help="--workbook의 account_snapshot 실보유 종목 전체 순회(국내 6자리 코드만)")
+ ap.add_argument("--kis-account", choices=["real", "mock"], default=None,
+ help="KIS Open API로 호가10단계/공매도거래비중 보강 조회(read-only). "
+ "공매도 일별추이는 real 도메인만 동작 확인됨(mock은 500 에러).")
+ ap.add_argument("--apply", action="store_true", help="outputs/qualitative_sell_strategy/.json 저장")
+ ap.add_argument("--sqlite-db", type=Path, default=DEFAULT_SQLITE_DB,
+ help="JSON 저장과 병행해 시계열 SQLite에도 기록(GAS/xlsx와 무관한 추가 저장소)")
+ ap.add_argument("--store-backend", default="sqlite", help="Storage backend contract placeholder (sqlite today, postgresql planned)")
+ ap.add_argument("--store-location", default=None, help="Backend location/DSN. sqlite path or future postgres DSN.")
+ ap.add_argument("--no-sqlite", action="store_true", help="SQLite 기록 비활성화")
+ args = ap.parse_args()
+ store_db = resolve_store_path(
+ QualitativeSellStoreSpec(
+ backend=args.store_backend,
+ location=args.store_location or args.sqlite_db,
+ ),
+ ROOT,
+ )
+
+ if args.batch:
+ if not args.workbook or not args.workbook.exists():
+ raise SystemExit("--batch는 --workbook 경로가 필요합니다")
+ from tools.build_macro_context_from_workbook_v1 import read_positions
+ positions = [p for p in read_positions(args.workbook) if str(p["ticker"]).isdigit() and len(str(p["ticker"])) == 6]
+ if args.apply:
+ DEFAULT_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ results = []
+ for pos in positions:
+ try:
+ result = process_one(
+ ticker=pos["ticker"], name=str(pos.get("name") or ""),
+ benchmark_code=args.benchmark_code, sector=args.sector,
+ earnings_outlook=args.earnings_outlook, trade_csv=args.trade_csv,
+ short_csv=args.short_csv, workbook=args.workbook, context_json=None,
+ kis_account=args.kis_account,
+ )
+ except Exception as exc: # noqa: BLE001 — 종목 1건 실패가 배치 전체를 막지 않음
+ result = {"code": pos["ticker"], "status": "FETCH_ERROR", "note": str(exc)}
+ results.append(result)
+ if args.apply:
+ out_path = DEFAULT_OUTPUT_DIR / f"{pos['ticker']}.json"
+ out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+ if not args.no_sqlite and result.get("status") != "FETCH_ERROR":
+ insert_sell_strategy_result(store_db, result)
+ error_count = sum(1 for r in results if r.get("status") == "FETCH_ERROR")
+ action_counts: dict[str, int] = {}
+ for r in results:
+ action = (r.get("decision") or {}).get("action", "N/A")
+ action_counts[action] = action_counts.get(action, 0) + 1
+ summary = {
+ "generated_at": _kst_now_iso(),
+ "ticker_count": len(results),
+ "error_count": error_count,
+ "action_counts": action_counts,
+ }
+ print(f"SUMMARY: {json.dumps(summary, ensure_ascii=False)}")
+ if args.apply:
+ (DEFAULT_OUTPUT_DIR / "_batch_summary.json").write_text(
+ json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ print(f"written {len(results)} files to {DEFAULT_OUTPUT_DIR}")
+ else:
+ print(json.dumps(results, ensure_ascii=False, indent=2))
+ # 절반 이상 실패면 CI에서 빨간불로 보이도록 — 호출결과를 로그만으로 확인 가능하게 함
+ if results and error_count / len(results) >= 0.5:
+ print(f"BATCH_GATE: FAIL — error_count={error_count}/{len(results)}")
+ return 1
+ print("BATCH_GATE: PASS")
+ return 0
+
+ if not args.ticker:
+ raise SystemExit("--ticker 또는 --batch 중 하나는 필수입니다")
+
+ result = process_one(
+ ticker=args.ticker, name=args.name or "",
+ benchmark_code=args.benchmark_code, sector=args.sector,
+ earnings_outlook=args.earnings_outlook, trade_csv=args.trade_csv,
+ short_csv=args.short_csv, workbook=args.workbook, context_json=args.context_json,
+ kis_account=args.kis_account,
+ )
+
+ if args.apply:
+ DEFAULT_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ out_path = DEFAULT_OUTPUT_DIR / f"{args.ticker}.json"
+ out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+ if not args.no_sqlite:
+ insert_sell_strategy_result(store_db, result)
+ print(f"written: {out_path}")
+ else:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/build_satellite_candidate_recommendations_v1.py b/tools/build_satellite_candidate_recommendations_v1.py
new file mode 100644
index 00000000..93408135
--- /dev/null
+++ b/tools/build_satellite_candidate_recommendations_v1.py
@@ -0,0 +1,139 @@
+"""universe 시트(미보유 위성 유니버스) 전체를 SATELLITE_CANDIDATE_SCORE_V1로 평가.
+
+WBS-6 후속 — qualitative_sell_strategy_v1.compute_satellite_candidate_score를 실제
+GatherTradingData.xlsx universe 시트(Ticker/Name/Sector/AddedDate, 실측 확인됨)에 연동.
+보유 종목(account_snapshot)은 제외하고 미보유 후보만 평가한다.
+
+universe.Sector 한글 라벨은 fetch_trade_statistics_motie_v1.SECTOR_HS_MAP 키와 1:1로
+일치하지 않으므로 부분 문자열 매칭으로 연결한다. 매칭 실패 종목은 sector_export_trend를
+추정하지 않고 None으로 두어 컨플루언스 부족(INSUFFICIENT_DATA_NO_ACTION)으로 자연 처리된다
+(추정 금지 원칙 — qualitative_sell_strategy_v1.yaml과 동일).
+"""
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+from openpyxl import load_workbook
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from tools.build_macro_context_from_workbook_v1 import _read_sheet_rows, read_positions, read_macro_pressure_and_regime
+from tools.fetch_naver_market_data_v1 import _session, compute_relative_return_20d, fetch_price_history
+from tools.fetch_trade_statistics_motie_v1 import SECTOR_HS_MAP, compute_sector_export_trend, load_trade_statistics_csv
+from src.quant_engine.qualitative_sell_strategy_v1 import compute_satellite_candidate_score
+from src.quant_engine.qualitative_sell_strategy_store_v1 import (
+ QualitativeSellStoreSpec,
+ insert_satellite_recommendation,
+ resolve_store_path,
+)
+
+DEFAULT_OUTPUT = ROOT / "outputs" / "qualitative_sell_strategy" / "satellite_recommendations.json"
+DEFAULT_SQLITE_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db"
+
+
+def map_universe_sector_to_hs_sector(universe_sector: str) -> str | None:
+ text = str(universe_sector or "")
+ for hs_sector in SECTOR_HS_MAP:
+ if hs_sector in text:
+ return hs_sector
+ return None
+
+
+def read_universe_candidates(xlsx_path: Path, exclude_tickers: set[str]) -> list[dict[str, Any]]:
+ _, rows = _read_sheet_rows(xlsx_path, "universe")
+ candidates = []
+ for row in rows:
+ ticker = str(row.get("Ticker") or "").strip()
+ if not ticker or ticker in exclude_tickers:
+ continue
+ candidates.append({
+ "ticker": ticker,
+ "name": row.get("Name"),
+ "universe_sector": row.get("Sector"),
+ "hs_sector": map_universe_sector_to_hs_sector(row.get("Sector")),
+ })
+ return candidates
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--workbook", type=Path, default=ROOT / "GatherTradingData.xlsx")
+ ap.add_argument("--benchmark-code", default="069500")
+ ap.add_argument("--trade-csv", type=Path, default=None, help="관세청/산업통상부 수출입통계 CSV — 없으면 sector_export_trend는 전부 DATA_MISSING")
+ ap.add_argument("--apply", action="store_true", help=str(DEFAULT_OUTPUT) + " 저장")
+ ap.add_argument("--sqlite-db", type=Path, default=DEFAULT_SQLITE_DB,
+ help="JSON 저장과 병행해 시계열 SQLite에도 기록(GAS/xlsx와 무관한 추가 저장소)")
+ ap.add_argument("--store-backend", default="sqlite", help="Storage backend contract placeholder (sqlite today, postgresql planned)")
+ ap.add_argument("--store-location", default=None, help="Backend location/DSN. sqlite path or future postgres DSN.")
+ ap.add_argument("--no-sqlite", action="store_true", help="SQLite 기록 비활성화")
+ args = ap.parse_args()
+ store_db = resolve_store_path(
+ QualitativeSellStoreSpec(
+ backend=args.store_backend,
+ location=args.store_location or args.sqlite_db,
+ ),
+ ROOT,
+ )
+
+ held = {p["ticker"] for p in read_positions(args.workbook) if str(p["ticker"]).isdigit()}
+ candidates = read_universe_candidates(args.workbook, held)
+
+ trade_rows = load_trade_statistics_csv(args.trade_csv) if args.trade_csv and args.trade_csv.exists() else []
+ macro = read_macro_pressure_and_regime(args.workbook)
+ rate_trend = macro.get("rate_trend")
+
+ session = _session()
+ benchmark = fetch_price_history(session, args.benchmark_code)
+
+ results = []
+ for cand in candidates:
+ sector_export_trend = None
+ if cand["hs_sector"] and trade_rows:
+ export_result = compute_sector_export_trend(trade_rows, cand["hs_sector"], compare="yoy")
+ if export_result.get("status") == "OK":
+ sector_export_trend = export_result["sector_export_trend"]
+
+ relative_return_20d = None
+ if cand["ticker"].isdigit() and len(cand["ticker"]) == 6:
+ try:
+ price = fetch_price_history(session, cand["ticker"])
+ relative_return_20d = compute_relative_return_20d(price.get("rows", []), benchmark.get("rows", []))
+ except Exception: # noqa: BLE001 — 개별 종목 수집 실패가 전체 배치를 막지 않음
+ relative_return_20d = None
+
+ score = compute_satellite_candidate_score({
+ "sector_export_trend": sector_export_trend,
+ "fundamental_trajectory": None, # universe 시트에 펀더멘털 추세 없음 — 추정 금지
+ "relative_return_20d": relative_return_20d,
+ "rate_trend": rate_trend,
+ })
+ results.append({**cand, "sector_export_trend": sector_export_trend, "relative_return_20d": relative_return_20d, "score": score})
+
+ output = {
+ "generated_at": dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat(),
+ "rate_trend": rate_trend,
+ "candidate_count": len(results),
+ "results": results,
+ }
+
+ if args.apply:
+ DEFAULT_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+ DEFAULT_OUTPUT.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
+ if not args.no_sqlite:
+ for cand in results:
+ insert_satellite_recommendation(store_db, output["generated_at"], cand)
+ print(f"written: {DEFAULT_OUTPUT} ({len(results)} candidates)")
+ else:
+ print(json.dumps(output, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/evaluate_execution_slippage_v1.py b/tools/evaluate_execution_slippage_v1.py
new file mode 100644
index 00000000..48907550
--- /dev/null
+++ b/tools/evaluate_execution_slippage_v1.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""WBS-7.6(2026-06-21) — 실거래 슬리피지 실측 캡처/비교 CLI.
+
+사용법:
+ 실측 1건 기록(주문 실행은 여전히 사람이 HTS에서 수동 실행 — 이 도구는 API로
+ 체결을 가져오지 않는다. governance/rules/06_no_direct_api_trading.yaml 준수):
+ python tools/evaluate_execution_slippage_v1.py record --ticker 005930 --side BUY \
+ --intended-price 71000 --actual-price 71050 --recorded-at 2026-06-21
+
+ 누적 표본과 가정치(5bps) 비교 리포트:
+ python tools/evaluate_execution_slippage_v1.py report
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
+ sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
+
+from src.quant_engine.execution_slippage_store_v1 import (
+ build_slippage_comparison_report,
+ default_execution_slippage_store_path,
+ insert_realized_slippage_sample,
+)
+
+OUTPUT = ROOT / "Temp" / "execution_slippage_report_v1.json"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--db", type=Path, default=None)
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ record = sub.add_parser("record")
+ record.add_argument("--ticker", required=True)
+ record.add_argument("--side", required=True, choices=["BUY", "SELL", "buy", "sell"])
+ record.add_argument("--intended-price", type=float, required=True)
+ record.add_argument("--actual-price", type=float, required=True)
+ record.add_argument("--recorded-at", required=True)
+ record.add_argument("--note", default=None)
+
+ sub.add_parser("report")
+
+ args = parser.parse_args()
+ db_path = args.db or default_execution_slippage_store_path(ROOT)
+
+ if args.command == "record":
+ result = insert_realized_slippage_sample(
+ db_path,
+ ticker=args.ticker,
+ side=args.side,
+ intended_price=args.intended_price,
+ actual_fill_price=args.actual_price,
+ recorded_at=args.recorded_at,
+ note=args.note,
+ )
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0
+
+ report = build_slippage_comparison_report(db_path)
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+ OUTPUT.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(report, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/evaluate_qualitative_sell_strategy_accuracy_v1.py b/tools/evaluate_qualitative_sell_strategy_accuracy_v1.py
new file mode 100644
index 00000000..f6129b94
--- /dev/null
+++ b/tools/evaluate_qualitative_sell_strategy_accuracy_v1.py
@@ -0,0 +1,143 @@
+"""qualitative_sell_strategy_v1 자체 평가 루프 — "한 번 만들고 끝"이 아니라 결정이
+실제로 가치를 보존했는지 사후 검증한다(30년 시니어 퀀트의 핵심 습관: 판단 → 결과 →
+재보정). 기존 T+5/T+20 outcome ledger(proposal_evaluation_history)와 별개로,
+qualitative_sell_strategy_store_v1.db에 쌓인 SQLite 시계열을 사용한다 — GAS/xlsx와
+무관하므로 이 모듈만의 독립 평가 루프를 구성해도 기존 시스템과 충돌하지 않는다.
+
+판정 기준(가치보존 관점, 기계적 승률 게임이 아님):
+ - EXIT_REVIEW_FULL / TRIM_REVIEW_PARTIAL(매도방향) → 이후 가격이 하락했으면
+ "가치보존 성공"(매도가 손실을 막았다). 상승했으면 "기회비용 발생"(조급한 매도).
+ - HOLD_ADD_CONVICTION(지지방향) → 이후 가격이 상승했으면 성공.
+ - HOLD_NO_CONFLUENCE / INSUFFICIENT_DATA_NO_ACTION → 방향성 주장이 없으므로 평가 대상 제외.
+
+표본이 부족하면(DATA_GATED) 추정하지 않고 명시적으로 보류한다 — honest_proof_score와
+동일한 원칙(spec/algorithm_guidance_proof 계열).
+"""
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import sqlite3
+import sys
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from src.quant_engine.qualitative_sell_strategy_store_v1 import QualitativeSellStoreSpec, resolve_store_path
+
+MIN_HOLDING_DAYS = 5 # T+5 수준 — 너무 짧으면 노이즈, 너무 길면 표본 희소
+MIN_SAMPLE_FOR_HIT_RATE = 10 # 이보다 적으면 hit_rate를 신뢰 구간 없이 표기하지 않음(DATA_GATED)
+
+
+def _scoreable_direction(action: str) -> int | None:
+ if action in {"EXIT_REVIEW_FULL", "TRIM_REVIEW_PARTIAL"}:
+ return -1 # 매도 방향 — 가격 하락이 "성공"
+ if action == "HOLD_ADD_CONVICTION":
+ return 1 # 지지 방향 — 가격 상승이 "성공"
+ return None # HOLD_NO_CONFLUENCE / INSUFFICIENT_DATA_NO_ACTION — 평가 제외
+
+
+def load_scoreable_decisions(db_path: Path, min_age_days: int = MIN_HOLDING_DAYS) -> list[dict[str, Any]]:
+ if not db_path.exists():
+ return []
+ cutoff = (dt.date.today() - dt.timedelta(days=min_age_days)).isoformat()
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ try:
+ rows = conn.execute(
+ "SELECT code, generated_at, action, conviction, market_regime, composite_score "
+ "FROM sell_strategy_results WHERE generated_at <= ? ORDER BY generated_at",
+ (cutoff,),
+ ).fetchall()
+ return [dict(row) for row in rows]
+ finally:
+ conn.close()
+
+
+def evaluate_decision(decision: dict[str, Any], price_at_decision: float, price_after: float) -> dict[str, Any] | None:
+ direction = _scoreable_direction(decision["action"])
+ if direction is None or not price_at_decision or price_at_decision <= 0:
+ return None
+ realized_return_pct = (price_after / price_at_decision - 1.0) * 100.0
+ success = (direction * realized_return_pct) > 0 # 방향 일치 시 성공
+ return {
+ **decision,
+ "price_at_decision": price_at_decision,
+ "price_after": price_after,
+ "realized_return_pct": round(realized_return_pct, 4),
+ "success": success,
+ }
+
+
+def build_accuracy_report(db_path: Path, price_lookup: dict[str, dict[str, float]]) -> dict[str, Any]:
+ """price_lookup: {code: {generated_at_date_iso: close_price}} — 호출측이 실제 가격
+ 히스토리(fetch_naver_market_data_v1 등)로 조립해 주입한다. 이 함수는 가격을 추정하지
+ 않는다 — 주어진 값만 사용."""
+ decisions = load_scoreable_decisions(db_path)
+ evaluated: list[dict[str, Any]] = []
+ skipped_no_price = 0
+ for decision in decisions:
+ prices = price_lookup.get(decision["code"], {})
+ decision_date = decision["generated_at"][:10]
+ price_at = prices.get(decision_date)
+ future_date = (dt.date.fromisoformat(decision_date) + dt.timedelta(days=MIN_HOLDING_DAYS)).isoformat()
+ price_after = prices.get(future_date)
+ if price_at is None or price_after is None:
+ skipped_no_price += 1
+ continue
+ result = evaluate_decision(decision, price_at, price_after)
+ if result is not None:
+ evaluated.append(result)
+
+ scored = [e for e in evaluated if e is not None]
+ if len(scored) < MIN_SAMPLE_FOR_HIT_RATE:
+ return {
+ "status": "DATA_GATED",
+ "scored_sample_count": len(scored),
+ "min_sample_required": MIN_SAMPLE_FOR_HIT_RATE,
+ "note": "표본 부족 — hit_rate를 산출하지 않음(추정 금지). 결정 누적과 가격 매칭이 더 필요.",
+ "skipped_no_price": skipped_no_price,
+ }
+
+ hit_rate_pct = round(100.0 * sum(1 for e in scored if e["success"]) / len(scored), 2)
+ return {
+ "status": "OK",
+ "scored_sample_count": len(scored),
+ "hit_rate_pct": hit_rate_pct,
+ "evaluations": scored,
+ "skipped_no_price": skipped_no_price,
+ }
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--sqlite-db", type=Path,
+ default=ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db")
+ ap.add_argument("--store-backend", default="sqlite", help="Storage backend contract placeholder (sqlite today, postgresql planned)")
+ ap.add_argument("--store-location", default=None, help="Backend location/DSN. sqlite path or future postgres DSN.")
+ ap.add_argument("--price-lookup-json", type=Path, default=None,
+ help='{"code": {"YYYY-MM-DD": close_price, ...}} 형식 — 미지정 시 가격 매칭 없이 표본 카운트만 보고')
+ args = ap.parse_args()
+ db_path = resolve_store_path(
+ QualitativeSellStoreSpec(
+ backend=args.store_backend,
+ location=args.store_location or args.sqlite_db,
+ ),
+ ROOT,
+ )
+
+ price_lookup: dict[str, dict[str, float]] = {}
+ if args.price_lookup_json and args.price_lookup_json.exists():
+ price_lookup = json.loads(args.price_lookup_json.read_text(encoding="utf-8"))
+
+ report = build_accuracy_report(db_path, price_lookup)
+ print(json.dumps(report, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/fetch_naver_market_data_v1.py b/tools/fetch_naver_market_data_v1.py
new file mode 100644
index 00000000..d8693074
--- /dev/null
+++ b/tools/fetch_naver_market_data_v1.py
@@ -0,0 +1,168 @@
+"""Naver Finance 시세/수급 수집기 — qualitative_sell_strategy_v1 입력용.
+
+확인된 무인증 엔드포인트만 사용한다(2026-06-21 세션 실측):
+ - https://finance.naver.com/item/sise_day.naver?code={code}&page=N (일별 시세/거래량)
+ - https://finance.naver.com/item/frgn.naver?code={code}&page=N (외국인/기관 수급)
+ - https://polling.finance.naver.com/api/realtime/domestic/stock/{code} (실시간 스냅샷, JSON)
+
+investing.com 직접 스크래핑은 403(Cloudflare 차단) 확인됨 — 시도하지 않는다.
+KRX 공매도 잔고(data.krx.co.kr)는 OTP 세션 필요(LOGOUT 응답) — 시도하지 않는다.
+이미 GAS(gdc_01_fetch_fundamentals.gs/gas_event_calendar.gs)에서 수집 중인
+외국인/기관 수급·실적발표 일정·경제지표 일정은 보유종목에 대해서는 account_snapshot/
+GatherTradingData.xlsx에서 재사용하고, 이 스크립트는 그 시트에 없는 위성 후보군
+티커를 평가할 때만 직접 호출한다(중복 수집 금지).
+"""
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+import requests
+from bs4 import BeautifulSoup
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
+NAVER_REFERER = "https://finance.naver.com/"
+
+
+def _session() -> requests.Session:
+ s = requests.Session()
+ s.headers.update({
+ "User-Agent": USER_AGENT,
+ "Referer": NAVER_REFERER,
+ "Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
+ })
+ return s
+
+
+def _num(text: str) -> float:
+ cleaned = text.replace(",", "").replace("+", "").strip()
+ try:
+ return float(cleaned)
+ except ValueError:
+ return 0.0
+
+
+def fetch_price_history(session: requests.Session, code: str, pages: int = 3) -> dict[str, Any]:
+ """일별 [date, close, change, open, high, low, volume] 최신순. 페이지당 10행."""
+ rows: list[dict[str, Any]] = []
+ for page in range(1, pages + 1):
+ url = f"https://finance.naver.com/item/sise_day.naver?code={code}&page={page}"
+ resp = session.get(url, timeout=10)
+ resp.encoding = "euc-kr"
+ soup = BeautifulSoup(resp.text, "html.parser")
+ table = soup.find("table", {"class": "type2"})
+ if table is None:
+ break
+ for tr in table.find_all("tr"):
+ cells = [td.get_text(strip=True) for td in tr.find_all("td")]
+ if len(cells) != 7 or not cells[0]:
+ continue
+ rows.append({
+ "date": cells[0].replace(".", "-"),
+ "close": _num(cells[1]),
+ "open": _num(cells[3]),
+ "high": _num(cells[4]),
+ "low": _num(cells[5]),
+ "volume": _num(cells[6]),
+ })
+ if not rows:
+ return {"status": "DATA_MISSING", "rows": [], "source_url": NAVER_REFERER}
+ return {
+ "status": "OK",
+ "rows": rows,
+ "source_url": f"https://finance.naver.com/item/sise_day.naver?code={code}",
+ "source_as_of": dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat(),
+ }
+
+
+def fetch_foreign_institution_flow(session: requests.Session, code: str, pages: int = 2) -> dict[str, Any]:
+ """외국인/기관 5일·20일 수급. tds: [date, close, change, ret_pct, volume, inst, frgn, frgn_ratio]."""
+ rows: list[dict[str, Any]] = []
+ for page in range(1, pages + 1):
+ url = f"https://finance.naver.com/item/frgn.naver?code={code}&page={page}"
+ resp = session.get(url, timeout=10)
+ resp.encoding = "euc-kr"
+ soup = BeautifulSoup(resp.text, "html.parser")
+ for table in soup.find_all("table", {"class": "type2"}):
+ for tr in table.find_all("tr"):
+ cells = [td.get_text(strip=True) for td in tr.find_all("td")]
+ if len(cells) < 8 or not cells[0] or "." not in cells[0]:
+ continue
+ rows.append({
+ "date": cells[0].replace(".", "-"),
+ "close": _num(cells[1]),
+ "inst_net": _num(cells[5]),
+ "frgn_net": _num(cells[6]),
+ })
+ if not rows:
+ return {"status": "DATA_MISSING", "rows": []}
+ return {
+ "status": "OK",
+ "rows": rows,
+ "source_url": f"https://finance.naver.com/item/frgn.naver?code={code}",
+ "source_as_of": dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat(),
+ }
+
+
+def compute_relative_return_20d(stock_rows: list[dict[str, Any]], benchmark_rows: list[dict[str, Any]]) -> float | None:
+ """종목수익률(최신 vs 20거래일전) - 벤치마크(섹터ETF/KOSPI)수익률, %p."""
+ def _ret(rows: list[dict[str, Any]]) -> float | None:
+ closes = [r["close"] for r in rows if r.get("close")]
+ if len(closes) < 2:
+ return None
+ recent, past = closes[0], closes[min(len(closes) - 1, 19)]
+ if not past:
+ return None
+ return (recent / past - 1.0) * 100.0
+
+ stock_ret = _ret(stock_rows)
+ bench_ret = _ret(benchmark_rows)
+ if stock_ret is None or bench_ret is None:
+ return None
+ return round(stock_ret - bench_ret, 4)
+
+
+def compute_volume_ratio_5d(rows: list[dict[str, Any]]) -> float | None:
+ """오늘 거래량 / 직전 5일 평균거래량."""
+ volumes = [r["volume"] for r in rows if r.get("volume")]
+ if len(volumes) < 6:
+ return None
+ today_vol = volumes[0]
+ avg5 = sum(volumes[1:6]) / 5.0
+ if avg5 <= 0:
+ return None
+ return round(today_vol / avg5, 4)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--code", required=True, help="6자리 종목코드")
+ ap.add_argument("--benchmark-code", default="069500", help="비교 벤치마크 코드(기본 KODEX200 069500)")
+ args = ap.parse_args()
+
+ session = _session()
+ price = fetch_price_history(session, args.code)
+ benchmark = fetch_price_history(session, args.benchmark_code)
+ flow = fetch_foreign_institution_flow(session, args.code)
+
+ result = {
+ "code": args.code,
+ "price_history": price,
+ "foreign_institution_flow": flow,
+ "relative_return_20d": compute_relative_return_20d(price.get("rows", []), benchmark.get("rows", [])),
+ "volume_ratio_5d": compute_volume_ratio_5d(price.get("rows", [])),
+ }
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/fetch_trade_statistics_motie_v1.py b/tools/fetch_trade_statistics_motie_v1.py
new file mode 100644
index 00000000..810754d0
--- /dev/null
+++ b/tools/fetch_trade_statistics_motie_v1.py
@@ -0,0 +1,186 @@
+"""관세청/산업통상부 수출입동향 → 섹터별 수출 추세(sector_export_trend) 산출기.
+
+실측 결과(2026-06-21 세션): investing.com 직접 스크래핑은 403(Cloudflare)으로 차단되고,
+관세청·산업통상부는 실시간 무인증 JSON API를 공개하지 않는다(통계청/관세청 수출입통계는
+data.go.kr 공공데이터포털의 서비스키 기반 OpenAPI 또는 매월 발표되는 보도자료 첨부
+XLSX/CSV로만 배포). 따라서 이 모듈은 두 경로를 모두 지원한다:
+
+ 1) API 경로 — data.go.kr 관세청 수출입통계 API. CUSTOMS_API_KEY 환경변수(또는
+ --api-key) 필요. 키가 없거나 호출 실패 시 추정하지 않고 DATA_MISSING 반환.
+ 2) CSV 경로(권장, 안정적) — 관세청 수출입무역통계(https://unipass.customs.go.kr/ets/)
+ 또는 산업통상부 보도자료에서 사용자가 다운로드한 월별 HS코드별 수출입 CSV를
+ --csv 인자로 입력. 이 경로가 실패할 일이 없어 1차 권장 경로다.
+
+산출물 sector_export_trend(%, MoM 또는 YoY)는 qualitative_sell_strategy_v1의
+fundamental_trajectory 보강 입력 및 compute_satellite_candidate_score의 1차 팩터로 쓰인다.
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import os
+import sys
+from collections import defaultdict
+from pathlib import Path
+from typing import Any
+
+import requests
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+# 섹터 → HS코드 prefix(2~4자리). 위성종목 추천/매도판단에 쓰는 핵심 수출 섹터만 우선 등록.
+SECTOR_HS_MAP: dict[str, tuple[str, ...]] = {
+ "반도체": ("8541", "8542"),
+ "자동차": ("8701", "8702", "8703", "8704"),
+ "2차전지": ("8507",),
+ "조선": ("8901", "8902", "8905"),
+ "철강": ("72",),
+ "석유화학": ("29", "39"),
+ "디스플레이": ("8524", "9013"),
+ "기계": ("84",),
+ "바이오": ("30",), # universe.Sector 실측 라벨이 "바이오"(헬스 접미사 없음) — 그대로 매칭
+ "방산": ("93",), # 무기류·탄약(HS Ch.93) — 현대로템 등 보유종목 K-방산 테마 대응
+}
+
+CUSTOMS_API_BASE = "https://apis.data.go.kr/1220000/nitemtrade/getNitemtradeList"
+
+
+def fetch_customs_trade_api(
+ session: requests.Session,
+ api_key: str | None,
+ hs_code: str,
+ start_ym: str,
+ end_ym: str,
+) -> dict[str, Any]:
+ """data.go.kr 관세청 수출입통계 API 호출. 키 없거나 실패 시 DATA_MISSING(추정 금지)."""
+ if not api_key:
+ return {"status": "DATA_MISSING", "note": "CUSTOMS_API_KEY 미설정 — --csv 경로 사용 권장"}
+ try:
+ resp = session.get(
+ CUSTOMS_API_BASE,
+ params={
+ "serviceKey": api_key,
+ "strtYymm": start_ym,
+ "endYymm": end_ym,
+ "hsSgn": hs_code,
+ "type": "json",
+ },
+ timeout=15,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as exc: # noqa: BLE001 — 외부 API 실패는 광범위하게 잡아 DATA_MISSING 처리
+ return {"status": "API_ERROR", "note": str(exc)}
+ return {"status": "OK", "raw": data, "source_url": CUSTOMS_API_BASE}
+
+
+def load_trade_statistics_csv(path: Path) -> list[dict[str, Any]]:
+ """관세청/산업통상부 배포 CSV. 컬럼: 기간(YYYYMM), HS코드, 수출액(달러), 수입액(달러).
+
+ 헤더명은 배포처마다 다를 수 있어 한글/영문 별칭을 모두 허용한다.
+ """
+ alias = {
+ "기간": "period", "year_month": "period", "period": "period",
+ "hs코드": "hs_code", "hs_code": "hs_code", "hscode": "hs_code",
+ "수출액": "export_usd", "export": "export_usd", "export_usd": "export_usd",
+ "수입액": "import_usd", "import": "import_usd", "import_usd": "import_usd",
+ }
+ rows: list[dict[str, Any]] = []
+ with path.open(encoding="utf-8-sig", newline="") as f:
+ reader = csv.DictReader(f)
+ for raw_row in reader:
+ row: dict[str, Any] = {}
+ for key, value in raw_row.items():
+ norm_key = alias.get(str(key).strip().lower())
+ if norm_key:
+ row[norm_key] = value
+ if {"period", "hs_code"}.issubset(row):
+ for money_field in ("export_usd", "import_usd"):
+ if money_field in row:
+ try:
+ row[money_field] = float(str(row[money_field]).replace(",", ""))
+ except ValueError:
+ row[money_field] = 0.0
+ rows.append(row)
+ return rows
+
+
+def compute_sector_export_trend(
+ rows: list[dict[str, Any]],
+ sector: str,
+ compare: str = "yoy",
+) -> dict[str, Any]:
+ """sector_export_trend(%) = 최신월 수출액 / 비교월 수출액 - 1.
+
+ compare="yoy": 12개월 전 동월 대비. compare="mom": 직전월 대비.
+ 데이터 부족 시 추정하지 않고 DATA_MISSING.
+ """
+ hs_prefixes = SECTOR_HS_MAP.get(sector)
+ if not hs_prefixes:
+ return {"status": "UNKNOWN_SECTOR", "sector": sector, "known_sectors": list(SECTOR_HS_MAP)}
+
+ by_period: dict[str, float] = defaultdict(float)
+ for row in rows:
+ hs_code = str(row.get("hs_code") or "")
+ if any(hs_code.startswith(prefix) for prefix in hs_prefixes):
+ period = str(row.get("period") or "")
+ by_period[period] += float(row.get("export_usd") or 0.0)
+
+ if len(by_period) < 2:
+ return {"status": "DATA_MISSING", "sector": sector, "note": "기간별 수출액 표본 부족"}
+
+ periods_sorted = sorted(by_period)
+ latest_period = periods_sorted[-1]
+ latest_value = by_period[latest_period]
+
+ if compare == "mom":
+ compare_period = periods_sorted[-2]
+ else:
+ latest_ym = int(latest_period)
+ target_ym = latest_ym - 100 # YYYYMM에서 12개월 전 = -100
+ compare_period = str(target_ym)
+ if compare_period not in by_period:
+ return {"status": "DATA_MISSING", "sector": sector, "note": f"YoY 비교월({compare_period}) 데이터 없음 — MoM으로 재시도 권장"}
+
+ compare_value = by_period.get(compare_period, 0.0)
+ if compare_value <= 0:
+ return {"status": "DATA_MISSING", "sector": sector, "note": "비교월 수출액이 0 이하"}
+
+ trend_pct = round((latest_value / compare_value - 1.0) * 100.0, 4)
+ return {
+ "status": "OK",
+ "sector": sector,
+ "compare": compare,
+ "latest_period": latest_period,
+ "compare_period": compare_period,
+ "sector_export_trend": trend_pct,
+ }
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--csv", type=Path, help="관세청/산업통상부 배포 수출입 CSV 경로(권장 경로)")
+ ap.add_argument("--sector", default="반도체", choices=list(SECTOR_HS_MAP))
+ ap.add_argument("--compare", default="yoy", choices=["yoy", "mom"])
+ ap.add_argument("--api-key", default=os.environ.get("CUSTOMS_API_KEY"))
+ ap.add_argument("--hs-code", default="", help="API 경로 사용 시 HS코드")
+ ap.add_argument("--start-ym", default="")
+ ap.add_argument("--end-ym", default="")
+ args = ap.parse_args()
+
+ if args.csv:
+ rows = load_trade_statistics_csv(args.csv)
+ result = compute_sector_export_trend(rows, args.sector, args.compare)
+ else:
+ session = requests.Session()
+ result = fetch_customs_trade_api(session, args.api_key, args.hs_code, args.start_ym, args.end_ym)
+
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/generate_postgresql_upgrade_stub_v1.py b/tools/generate_postgresql_upgrade_stub_v1.py
new file mode 100644
index 00000000..8e3c5e65
--- /dev/null
+++ b/tools/generate_postgresql_upgrade_stub_v1.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[1]
+
+TABLE_SCHEMAS: dict[str, str] = {
+ "collection_runs": """
+CREATE TABLE collection_runs (
+ run_id TEXT PRIMARY KEY,
+ collector_name TEXT NOT NULL,
+ started_at TEXT NOT NULL,
+ finished_at TEXT,
+ status TEXT NOT NULL,
+ input_source TEXT,
+ output_json_path TEXT,
+ output_db_path TEXT,
+ notes TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+""".strip(),
+ "collection_snapshots": """
+CREATE TABLE collection_snapshots (
+ run_id TEXT NOT NULL,
+ dataset_name TEXT NOT NULL,
+ ticker TEXT NOT NULL,
+ name TEXT,
+ sector TEXT,
+ as_of_date TEXT,
+ source_priority TEXT,
+ source_status TEXT,
+ payload_json TEXT NOT NULL,
+ provenance_json TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ PRIMARY KEY (run_id, dataset_name, ticker)
+);
+""".strip(),
+ "collection_source_errors": """
+CREATE TABLE collection_source_errors (
+ run_id TEXT NOT NULL,
+ ticker TEXT,
+ source_name TEXT NOT NULL,
+ error_kind TEXT NOT NULL,
+ error_message TEXT NOT NULL,
+ payload_json TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+""".strip(),
+ "sell_strategy_results": """
+CREATE TABLE sell_strategy_results (
+ id BIGSERIAL PRIMARY KEY,
+ code TEXT NOT NULL,
+ generated_at TEXT NOT NULL,
+ action TEXT,
+ conviction TEXT,
+ market_regime TEXT,
+ composite_score DOUBLE PRECISION,
+ rationale TEXT,
+ raw_json TEXT NOT NULL,
+ inserted_at TIMESTAMPTZ DEFAULT NOW()
+);
+""".strip(),
+ "satellite_recommendations": """
+CREATE TABLE satellite_recommendations (
+ id BIGSERIAL PRIMARY KEY,
+ ticker TEXT NOT NULL,
+ generated_at TEXT NOT NULL,
+ satellite_action TEXT,
+ attractiveness_score DOUBLE PRECISION,
+ market_regime TEXT,
+ raw_json TEXT NOT NULL,
+ inserted_at TIMESTAMPTZ DEFAULT NOW()
+);
+""".strip(),
+}
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Emit PostgreSQL migration stub from current canonical row contract.")
+ ap.add_argument("--output-json", type=Path, default=ROOT / "Temp" / "postgresql_upgrade_stub_v1.json")
+ ap.add_argument("--output-sql", type=Path, default=ROOT / "Temp" / "postgresql_upgrade_stub_v1.sql")
+ args = ap.parse_args()
+
+ sql_lines = [
+ "-- PostgreSQL upgrade stub",
+ "-- This file is a contract placeholder only. It is not executed by CI.",
+ "",
+ ]
+ for name, ddl in TABLE_SCHEMAS.items():
+ sql_lines.append(f"-- {name}")
+ sql_lines.append(ddl)
+ sql_lines.append("")
+
+ sql_text = "\n".join(sql_lines).rstrip() + "\n"
+ args.output_sql.parent.mkdir(parents=True, exist_ok=True)
+ args.output_sql.write_text(sql_text, encoding="utf-8")
+
+ payload: dict[str, Any] = {
+ "formula_id": "POSTGRESQL_UPGRADE_STUB_V1",
+ "gate": "DATA_GATED",
+ "tables": sorted(TABLE_SCHEMAS.keys()),
+ "output_sql": str(args.output_sql),
+ "note": "DDL stub only; execution deferred until PostgreSQL rollout.",
+ }
+ args.output_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/run_kis_data_collection_v1.py b/tools/run_kis_data_collection_v1.py
new file mode 100644
index 00000000..61f75d82
--- /dev/null
+++ b/tools/run_kis_data_collection_v1.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from src.quant_engine.kis_data_collection_v1 import main
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/run_snapshot_admin_server_v1.py b/tools/run_snapshot_admin_server_v1.py
new file mode 100644
index 00000000..7683ea40
--- /dev/null
+++ b/tools/run_snapshot_admin_server_v1.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SERVER_MODULE = "src.quant_engine.snapshot_admin_server_v1"
+WATCH_DIRS = (
+ ROOT / "src",
+ ROOT / "tools",
+ ROOT / "spec",
+ ROOT / "governance",
+ ROOT / "docs",
+ ROOT / ".gitea",
+)
+WATCH_FILES = (
+ ROOT / "package.json",
+ ROOT / "AGENTS.md",
+ ROOT / "GatherTradingData.json",
+)
+WATCH_EXTENSIONS = {".py", ".yaml", ".yml", ".json", ".md", ".gs"}
+IGNORED_DIR_NAMES = {"Temp", "outputs", ".git", "__pycache__", ".pytest_cache"}
+
+
+def _server_cmd(args: argparse.Namespace) -> list[str]:
+ cmd = [
+ sys.executable,
+ "-m",
+ SERVER_MODULE,
+ "--host",
+ args.host,
+ "--port",
+ str(args.port),
+ "--db",
+ args.db,
+ "--seed",
+ args.seed,
+ ]
+ if args.no_bootstrap:
+ cmd.append("--no-bootstrap")
+ return cmd
+
+
+def _iter_watch_files() -> list[Path]:
+ seen: set[Path] = set()
+ files: list[Path] = []
+ for path in WATCH_FILES:
+ if path.exists() and path.is_file():
+ resolved = path.resolve()
+ if resolved not in seen:
+ seen.add(resolved)
+ files.append(resolved)
+ for root in WATCH_DIRS:
+ if not root.exists():
+ continue
+ for path in root.rglob("*"):
+ if not path.is_file():
+ continue
+ if any(part in IGNORED_DIR_NAMES for part in path.parts):
+ continue
+ if path.suffix.lower() not in WATCH_EXTENSIONS:
+ continue
+ resolved = path.resolve()
+ if resolved not in seen:
+ seen.add(resolved)
+ files.append(resolved)
+ return files
+
+
+def _snapshot_mtimes() -> dict[Path, float]:
+ mtimes: dict[Path, float] = {}
+ for path in _iter_watch_files():
+ try:
+ mtimes[path] = path.stat().st_mtime
+ except FileNotFoundError:
+ continue
+ return mtimes
+
+
+def _changed_files(previous: dict[Path, float]) -> list[Path]:
+ current = _snapshot_mtimes()
+ changed: list[Path] = []
+ for path, mtime in current.items():
+ if previous.get(path) != mtime:
+ changed.append(path)
+ for path in previous:
+ if path not in current:
+ changed.append(path)
+ return changed
+
+
+def _run_once(args: argparse.Namespace) -> int:
+ proc = subprocess.Popen(_server_cmd(args), cwd=str(ROOT), env=os.environ.copy())
+ try:
+ return proc.wait()
+ except KeyboardInterrupt:
+ proc.terminate()
+ try:
+ return proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ return proc.wait()
+
+
+def _run_reload(args: argparse.Namespace, interval: float) -> int:
+ last_mtimes = _snapshot_mtimes()
+ child: subprocess.Popen[str] | None = None
+ try:
+ while True:
+ if child is None or child.poll() is not None:
+ if child is not None:
+ code = child.returncode or 0
+ print(f"[snapshot-admin] server exited with code {code}; restarting...")
+ child = subprocess.Popen(_server_cmd(args), cwd=str(ROOT), env=os.environ.copy())
+ print("[snapshot-admin] hot reload watcher active")
+ print("[snapshot-admin] watching:", ", ".join(str(path) for path in WATCH_DIRS))
+ time.sleep(interval)
+ changed = _changed_files(last_mtimes)
+ if changed:
+ print("[snapshot-admin] changes detected:")
+ for path in changed[:20]:
+ print(f" - {path}")
+ last_mtimes = _snapshot_mtimes()
+ if child is not None and child.poll() is None:
+ child.terminate()
+ try:
+ child.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ child.kill()
+ child.wait()
+ child = None
+ except KeyboardInterrupt:
+ if child is not None and child.poll() is None:
+ child.terminate()
+ try:
+ child.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ child.kill()
+ child.wait()
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Run the snapshot admin web server.")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8787)
+ parser.add_argument("--db", default=str(ROOT / "outputs" / "snapshot_admin" / "snapshot_admin.db"))
+ parser.add_argument("--seed", default=str(ROOT / "GatherTradingData.json"))
+ parser.add_argument("--no-bootstrap", action="store_true")
+ parser.add_argument("--reload", action="store_true", help="Restart the server when watched files change.")
+ parser.add_argument("--reload-interval", type=float, default=1.0, help="Seconds between file-system polls.")
+ args = parser.parse_args()
+ if args.reload:
+ return _run_reload(args, max(0.25, args.reload_interval))
+ return _run_once(args)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/trigger_gas_run_all_v1.py b/tools/trigger_gas_run_all_v1.py
new file mode 100644
index 00000000..d703ce0a
--- /dev/null
+++ b/tools/trigger_gas_run_all_v1.py
@@ -0,0 +1,51 @@
+"""GAS run_all()을 Gitea CI 스케줄러에서 원격 트리거.
+
+언어 선택: Python — 이미 이 저장소의 모든 CI/도구가 Python이고(requests만으로 HTTP POST
+한 번이면 충분), 새 언어를 도입할 이유가 없다(불필요한 복잡성 증가 경계).
+
+대상 엔드포인트: src/gas/core/gas_lib.gs:doPost action="trigger_run_all" — 공유 비밀키로
+보호된 GAS 웹앱. run_all()은 데이터 갱신/분석만 수행하며 매수/매도 주문을 실행하지
+않는다(governance/rules/06,07과 동일 원칙).
+
+필요한 자격정보(Windows 환경변수, KIS와 동일한 레지스트리 폴백 사용):
+ GAS_WEBAPP_URL — Apps Script 배포 웹앱 URL
+ RUN_ALL_TRIGGER_SECRET — gas_lib.gs Script Properties에 설정한 것과 동일한 값
+"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import requests
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from src.quant_engine.kis_api_client_v1 import _read_env_var # 동일한 env+registry 폴백 재사용
+
+
+def trigger_run_all(timeout_sec: int = 280) -> dict:
+ webapp_url = _read_env_var("GAS_WEBAPP_URL")
+ secret = _read_env_var("RUN_ALL_TRIGGER_SECRET")
+ if not webapp_url or not secret:
+ return {"status": "ERROR", "message": "GAS_WEBAPP_URL/RUN_ALL_TRIGGER_SECRET 환경변수 없음"}
+
+ resp = requests.post(
+ webapp_url,
+ json={"action": "trigger_run_all", "secret": secret},
+ timeout=timeout_sec,
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+
+def main() -> int:
+ result = trigger_run_all()
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0 if result.get("status") == "OK" else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/validate_gitea_secrets_contract_v1.py b/tools/validate_gitea_secrets_contract_v1.py
new file mode 100644
index 00000000..ef29a26e
--- /dev/null
+++ b/tools/validate_gitea_secrets_contract_v1.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+REQUIRED_PATTERNS = {
+ ".gitea/workflows/kis_data_collection.yml": [
+ "secrets.KIS_APP_KEY_TEST",
+ "secrets.KIS_APP_SECRET_TEST",
+ "secrets.KIS_APP_KEY",
+ "secrets.KIS_APP_SECRET",
+ ],
+ ".gitea/workflows/qualitative_sell_strategy.yml": [
+ "secrets.KIS_APP_KEY_TEST",
+ "secrets.KIS_APP_SECRET_TEST",
+ "secrets.KIS_APP_KEY",
+ "secrets.KIS_APP_SECRET",
+ ],
+ ".gitea/workflows/ci.yml": [
+ "secrets.KIS_APP_KEY_TEST",
+ "secrets.KIS_APP_SECRET_TEST",
+ ],
+}
+
+
+def main() -> int:
+ errors: list[str] = []
+ evidence: dict[str, dict[str, bool]] = {}
+
+ for rel, patterns in REQUIRED_PATTERNS.items():
+ path = ROOT / rel
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
+ file_evidence: dict[str, bool] = {}
+ if not path.exists():
+ errors.append(f"missing:{rel}")
+ evidence[rel] = file_evidence
+ continue
+ for pattern in patterns:
+ found = pattern in text
+ file_evidence[pattern] = found
+ if not found:
+ errors.append(f"{rel}:{pattern}")
+ evidence[rel] = file_evidence
+
+ result = {
+ "formula_id": "GITEA_SECRETS_CONTRACT_V1",
+ "gate": "PASS" if not errors else "FAIL",
+ "evidence": evidence,
+ "errors": errors,
+ }
+ out = ROOT / "Temp" / "gitea_secrets_contract_v1.json"
+ out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0 if not errors else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/validate_kis_api_credentials_v1.py b/tools/validate_kis_api_credentials_v1.py
new file mode 100644
index 00000000..5940fac9
--- /dev/null
+++ b/tools/validate_kis_api_credentials_v1.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+try:
+ from src.quant_engine.kis_api_client_v1 import (
+ KisCredentials,
+ MOCK_DOMAIN,
+ REAL_DOMAIN,
+ _read_env_var,
+ get_current_price,
+ )
+except Exception as exc: # pragma: no cover - import failure is a hard validation error
+ KisCredentials = None # type: ignore[assignment]
+ MOCK_DOMAIN = ""
+ REAL_DOMAIN = ""
+ _read_env_var = None # type: ignore[assignment]
+ get_current_price = None # type: ignore[assignment]
+ _IMPORT_ERROR = str(exc)
+else:
+ _IMPORT_ERROR = ""
+
+
+def _payload(gate: str, **extra: Any) -> dict[str, Any]:
+ return {
+ "formula_id": "KIS_API_CREDENTIALS_VALIDATION_V1",
+ "gate": gate,
+ **extra,
+ }
+
+
+def _expected_env_names(account: str) -> tuple[str, str]:
+ if account == "real":
+ return ("KIS_APP_Key", "KIS_APP_Secret")
+ if account == "mock":
+ return ("KIS_APP_Key_TEST", "KIS_APP_Secret_TEST")
+ raise ValueError("account must be 'mock' or 'real'")
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Validate KIS API credentials using the read-only quotations API.")
+ ap.add_argument("--account", choices=["mock", "real"], default="mock")
+ ap.add_argument("--ticker", default="005930")
+ ap.add_argument("--output", type=Path, default=ROOT / "Temp" / "kis_api_credentials_validation_v1.json")
+ args = ap.parse_args()
+
+ if KisCredentials is None or get_current_price is None:
+ result = _payload("FAIL", error=f"import_error: {_IMPORT_ERROR}")
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 1
+
+ errors: list[str] = []
+ evidence: dict[str, Any] = {
+ "account": args.account,
+ "ticker": args.ticker,
+ }
+
+ try:
+ key_name, secret_name = _expected_env_names(args.account)
+ creds = KisCredentials.load(args.account)
+ evidence["domain"] = creds.domain
+ evidence["expected_env"] = {"app_key": key_name, "app_secret": secret_name}
+ expected_key = _read_env_var(key_name) if _read_env_var is not None else None
+ expected_secret = _read_env_var(secret_name) if _read_env_var is not None else None
+ other_key = _read_env_var("KIS_APP_Key_TEST" if args.account == "real" else "KIS_APP_Key") if _read_env_var is not None else None
+ other_secret = _read_env_var("KIS_APP_Secret_TEST" if args.account == "real" else "KIS_APP_Secret") if _read_env_var is not None else None
+ actual_key = getattr(creds, "app_key", None)
+ actual_secret = getattr(creds, "app_secret", None)
+ evidence["env_match"] = {
+ "app_key": bool(expected_key and actual_key == expected_key),
+ "app_secret": bool(expected_secret and actual_secret == expected_secret),
+ "other_key_present": bool(other_key),
+ "other_secret_present": bool(other_secret),
+ }
+ if creds.domain != (REAL_DOMAIN if args.account == "real" else MOCK_DOMAIN):
+ errors.append("domain_mismatch")
+ if not evidence["env_match"]["app_key"] or not evidence["env_match"]["app_secret"]:
+ errors.append("selected_env_mismatch")
+ response = get_current_price(creds, args.ticker)
+ evidence["response_keys"] = sorted(response.keys())
+ if not isinstance(response, dict) or not response:
+ errors.append("empty_response")
+ except Exception as exc: # noqa: BLE001
+ errors.append(str(exc))
+
+ gate = "PASS" if not errors else "FAIL"
+ result = _payload(gate, evidence=evidence, errors=errors)
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0 if gate == "PASS" else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/validate_no_direct_api_trading_v1.py b/tools/validate_no_direct_api_trading_v1.py
new file mode 100644
index 00000000..fa27e993
--- /dev/null
+++ b/tools/validate_no_direct_api_trading_v1.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""[CRITICAL] governance/rules/06_no_direct_api_trading.yaml 강제 게이트.
+
+이 검증기는 순수 stdlib(re, pathlib)만 사용한다 — Synology CI(ARMv7, Python 3.8,
+requests/pytest 미설치)에서도 항상 실행 가능해야 하는 하드 블로킹 게이트이기 때문이다.
+문서·테스트만으로는 막을 수 없다는 사용자 지시(2026-06-21)에 따라 정적 소스 스캔으로
+주문 제출/정정/취소 경로·TR_ID가 코드베이스 어디에도 존재하지 않음을 매 커밋마다 강제한다.
+
+FAIL 시 CI 전체를 막는다(strict, warn_only 아님) — 다른 데이터 품질 게이트와 다르게
+이 게이트는 완화 대상이 아니다.
+"""
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+# 이 문자열들이 "데이터"로 등장해도 되는 파일(블록리스트 정의/테스트/이 검증기 자신).
+# 그 외 모든 .py 파일에서 발견되면 FAIL.
+ALLOWLISTED_FILES = {
+ "src/quant_engine/kis_api_client_v1.py",
+ "tests/unit/test_kis_api_client_v1.py",
+ "tools/validate_no_direct_api_trading_v1.py",
+}
+
+FORBIDDEN_ORDER_PATH_SUBSTRINGS = (
+ "/trading/order-cash",
+ "/trading/order-rvsecncl",
+ "/trading/order-credit",
+ "/trading/order-resv",
+ "/trading/inquire-balance", # governance/rules/07 — 계좌 보유종목 조회 금지
+)
+FORBIDDEN_ORDER_TR_IDS = (
+ "TTTC0802U", "TTTC0801U", "VTTC0802U", "VTTC0801U",
+ "TTTC8434R", "VTTC8434R", # governance/rules/07 — 주식잔고조회 금지
+)
+BANNED_FUNCTION_NAME_SUBSTRINGS = (
+ "place_order", "submit_order", "cancel_order", "revise_order", "send_order",
+ "order_cash", "order_credit", "order_rvsecncl",
+ "inquire_balance", "account_balance", # governance/rules/07 — 계좌 보유종목 조회 금지
+)
+
+
+def _scan_python_files() -> list[str]:
+ violations: list[str] = []
+ for dir_name in ("src", "tools"):
+ for path in (ROOT / dir_name).rglob("*.py"):
+ rel = path.relative_to(ROOT).as_posix()
+ if rel in ALLOWLISTED_FILES:
+ continue
+ text = path.read_text(encoding="utf-8", errors="ignore")
+ for forbidden in FORBIDDEN_ORDER_PATH_SUBSTRINGS:
+ if forbidden in text:
+ violations.append(f"{rel}: 주문 엔드포인트 경로 발견 — {forbidden!r}")
+ for tr_id in FORBIDDEN_ORDER_TR_IDS:
+ if tr_id in text:
+ violations.append(f"{rel}: 주문 TR_ID 발견 — {tr_id!r}")
+ for match in re.finditer(r"def\s+(\w+)\s*\(", text):
+ name = match.group(1).lower()
+ for banned in BANNED_FUNCTION_NAME_SUBSTRINGS:
+ if banned in name:
+ violations.append(f"{rel}: 주문 제출/정정/취소로 의심되는 함수명 — def {match.group(1)}(")
+ return violations
+
+
+def _check_kis_client_guard_intact() -> list[str]:
+ """kis_api_client_v1.py가 실제로 존재하면, 가드 코드가 그대로 있는지 + _send_request가
+ HTTP 호출 전에 _assert_read_only를 부르는지 순서를 확인한다."""
+ client_path = ROOT / "src" / "quant_engine" / "kis_api_client_v1.py"
+ if not client_path.exists():
+ return [] # 클라이언트가 아직 없으면 이 검사는 스킵(다른 검사로 충분)
+
+ text = client_path.read_text(encoding="utf-8")
+ violations: list[str] = []
+ required_markers = ("_assert_read_only", "OrderEndpointBlockedError", "FORBIDDEN_PATH_SUBSTRINGS", "FORBIDDEN_TR_ID_PREFIXES")
+ for marker in required_markers:
+ if marker not in text:
+ violations.append(f"kis_api_client_v1.py: 필수 가드 구성요소 누락 — {marker!r}")
+
+ send_request_match = re.search(r"def _send_request\(.*?\)\s*(?:->[^:]*)?:(.*?)(?=\ndef |\Z)", text, re.S)
+ if send_request_match:
+ body = send_request_match.group(1)
+ guard_pos = body.find("_assert_read_only(")
+ http_pos = min(
+ (pos for pos in (body.find("requests.get("), body.find("requests.post(")) if pos != -1),
+ default=-1,
+ )
+ if guard_pos == -1:
+ violations.append("kis_api_client_v1.py: _send_request가 _assert_read_only를 호출하지 않음")
+ elif http_pos != -1 and guard_pos > http_pos:
+ violations.append("kis_api_client_v1.py: _assert_read_only 호출이 HTTP 전송보다 늦음(순서 위반)")
+ else:
+ violations.append("kis_api_client_v1.py: _send_request 함수를 찾을 수 없음")
+
+ return violations
+
+
+def main() -> int:
+ violations = _scan_python_files() + _check_kis_client_guard_intact()
+ if violations:
+ print("NO_DIRECT_API_TRADING_GATE: FAIL")
+ for v in violations:
+ print(f" - {v}")
+ return 1
+ print("NO_DIRECT_API_TRADING_GATE: PASS")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/validate_platform_transition_wbs_v1.py b/tools/validate_platform_transition_wbs_v1.py
new file mode 100644
index 00000000..b22bec5f
--- /dev/null
+++ b/tools/validate_platform_transition_wbs_v1.py
@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import sqlite3
+import sys
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+SPEC_PATH = ROOT / "spec" / "16_data_gaps_roadmap.yaml"
+ROADMAP_DOC_PATH = ROOT / "docs" / "ROADMAP_WBS.md"
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {}
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _read_text(path: Path) -> str:
+ if not path.exists():
+ return ""
+ return path.read_text(encoding="utf-8", errors="replace")
+
+
+def _sqlite_counts(db_path: Path) -> dict[str, int]:
+ if not db_path.exists():
+ return {}
+ conn = sqlite3.connect(db_path)
+ try:
+ return {
+ "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],
+ }
+ finally:
+ conn.close()
+
+
+def _load_spec() -> dict[str, Any]:
+ return yaml.safe_load(SPEC_PATH.read_text(encoding="utf-8"))
+
+
+def _check_p1() -> dict[str, Any]:
+ summary_path = ROOT / "Temp" / "test_kis_data_collection.json"
+ db_path = ROOT / "Temp" / "test_kis_data_collection.db"
+ summary = _read_json(summary_path)
+ counts = _sqlite_counts(db_path)
+ errors: list[str] = []
+
+ if summary.get("status") != "PASS":
+ errors.append(f"summary_status={summary.get('status')!r}")
+ if int(summary.get("row_count") or 0) <= 0:
+ errors.append("summary_row_count<=0")
+ if int(counts.get("collection_runs") or 0) <= 0:
+ errors.append("collection_runs<=0")
+ if int(counts.get("collection_snapshots") or 0) <= 0:
+ errors.append("collection_snapshots<=0")
+
+ source_counts = summary.get("source_counts") if isinstance(summary.get("source_counts"), dict) else {}
+ source_count = len([k for k, v in source_counts.items() if int(v or 0) > 0])
+ if source_count < 1:
+ errors.append(f"provenance_source_count={source_count}")
+
+ return {
+ "gate": "PASS" if not errors else "FAIL",
+ "expected_success_value": {
+ "collector_gate": "PASS",
+ "output_json_gate": "PASS",
+ "collection_runs_min": 1,
+ "collection_snapshots_min": 1,
+ "provenance_source_count_min": 1,
+ },
+ "evidence": {
+ "summary_path": str(summary_path),
+ "db_path": str(db_path),
+ "sqlite_counts": counts,
+ },
+ "errors": errors,
+ }
+
+
+def _check_p2() -> dict[str, Any]:
+ from src.quant_engine.data_collection_backend_v1 import CollectionStoreSpec, normalize_store_spec
+
+ db_path = ROOT / "Temp" / "test_kis_data_collection.db"
+ counts = _sqlite_counts(db_path)
+ sqlite_backend, sqlite_location = normalize_store_spec(CollectionStoreSpec(location=db_path), ROOT)
+ pg_backend, pg_location = normalize_store_spec(
+ CollectionStoreSpec(backend="postgresql", location="postgresql://user:pass@localhost/db"),
+ ROOT,
+ )
+ errors: list[str] = []
+
+ if sqlite_backend != "sqlite":
+ errors.append(f"sqlite_backend={sqlite_backend!r}")
+ if pg_backend != "postgresql":
+ errors.append(f"postgres_backend={pg_backend!r}")
+ if not isinstance(pg_location, str) or "postgresql://" not in pg_location:
+ errors.append("postgres_location_invalid")
+ if int(counts.get("collection_runs") or 0) <= 0 or int(counts.get("collection_snapshots") or 0) <= 0:
+ errors.append("sqlite_round_trip_missing")
+
+ return {
+ "gate": "PASS" if not errors else "FAIL",
+ "expected_success_value": {
+ "sqlite_schema_tables_min": 3,
+ "round_trip_snapshot_lookup": "PASS",
+ "backend_contract_sqlite": "PASS",
+ "backend_contract_postgresql": "READY",
+ },
+ "evidence": {
+ "db_path": str(db_path),
+ "sqlite_location": str(sqlite_location),
+ "postgres_location": pg_location,
+ "sqlite_counts": counts,
+ },
+ "errors": errors,
+ }
+
+
+def _check_p3() -> dict[str, Any]:
+ workflow = ROOT / ".gitea" / "workflows" / "kis_data_collection.yml"
+ text = _read_text(workflow)
+ errors: list[str] = []
+
+ if not text:
+ errors.append("workflow_missing")
+ if "tools/run_kis_data_collection_v1.py" not in text:
+ errors.append("collector_step_missing")
+ if "tools/validate_kis_api_credentials_v1.py" not in text:
+ errors.append("mock_validation_step_missing")
+ if "GatherTradingData.json" not in text:
+ errors.append("seed_json_missing")
+ if "Validate SQLite Artifact" not in text:
+ errors.append("sqlite_validation_step_missing")
+ if ".xlsx" in text or "GatherTradingData.xlsx" in text:
+ errors.append("xlsx_dependency_present")
+ if "validate_no_direct_api_trading_v1.py" not in text:
+ errors.append("no_direct_trading_gate_missing")
+ if text.count("KIS_APP_Key_TEST") != 1 or text.count("KIS_APP_Secret_TEST") != 1:
+ errors.append("mock_env_vars_not_isolated")
+ if text.count("KIS_APP_Key:") != 1 or text.count("KIS_APP_Secret:") != 1:
+ errors.append("real_env_vars_not_isolated")
+
+ return {
+ "gate": "PASS" if not errors else "FAIL",
+ "expected_success_value": {
+ "xlsx_dependency_removed": True,
+ "json_seed_input": True,
+ "sqlite_output": True,
+ "mock_api_validation": "PASS",
+ "no_direct_trading_gate": "PASS",
+ },
+ "evidence": {
+ "workflow_path": str(workflow),
+ },
+ "errors": errors,
+ }
+
+
+def _check_p4() -> dict[str, Any]:
+ validation_path = ROOT / "Temp" / "gas_thin_adapter_validation_v1.json"
+ payload = _read_json(validation_path)
+ errors: list[str] = []
+
+ if payload.get("gate") != "PASS":
+ errors.append(f"gate={payload.get('gate')!r}")
+ if float(payload.get("function_inventory_coverage_pct") or 0.0) < 100.0:
+ errors.append("function_inventory_coverage_pct<100")
+ if not (ROOT / "src" / "gas" / "core" / "gas_lib.gs").exists():
+ errors.append("gas_lib_missing")
+
+ return {
+ "gate": "PASS" if not errors else "FAIL",
+ "expected_success_value": {
+ "allowed_responsibilities_only": True,
+ "forbidden_responsibilities_present": False,
+ "thin_adapter_gate": "PASS",
+ },
+ "evidence": {
+ "validation_path": str(validation_path),
+ "payload": payload,
+ },
+ "errors": errors,
+ }
+
+
+def _check_p5() -> dict[str, Any]:
+ from src.quant_engine.data_collection_backend_v1 import CollectionStoreSpec, normalize_store_spec
+
+ backend_path = ROOT / "src" / "quant_engine" / "data_collection_backend_v1.py"
+ collector_path = ROOT / "src" / "quant_engine" / "kis_data_collection_v1.py"
+ test_path = ROOT / "tests" / "unit" / "test_data_collection_store_v1.py"
+ wrapper_path = ROOT / "tools" / "run_kis_data_collection_v1.py"
+ migration_stub_path = ROOT / "tools" / "generate_postgresql_upgrade_stub_v1.py"
+ errors: list[str] = []
+
+ try:
+ backend, location = normalize_store_spec(
+ CollectionStoreSpec(backend="postgresql", location="postgresql://user:pass@localhost/db"),
+ ROOT,
+ )
+ if backend != "postgresql":
+ errors.append(f"backend={backend!r}")
+ if not isinstance(location, str) or "postgresql://" not in location:
+ errors.append("postgres_location_invalid")
+ except Exception as exc: # noqa: BLE001
+ errors.append(f"normalize_failed={exc}")
+
+ for path in (backend_path, collector_path, test_path, wrapper_path):
+ if not path.exists():
+ errors.append(f"missing={path.relative_to(ROOT)}")
+ if not migration_stub_path.exists():
+ errors.append(f"missing={migration_stub_path.relative_to(ROOT)}")
+
+ return {
+ "gate": "PASS" if not errors else "FAIL",
+ "expected_success_value": {
+ "sqlite_schema_parity": "PASS",
+ "backend_contract_present": True,
+ "postgres_execution": "DATA_GATED",
+ "caller_compatibility_preserved": True,
+ },
+ "evidence": {
+ "backend_path": str(backend_path),
+ "collector_path": str(collector_path),
+ "test_path": str(test_path),
+ "wrapper_path": str(wrapper_path),
+ "migration_stub_path": str(migration_stub_path),
+ },
+ "errors": errors,
+ }
+
+
+def main() -> int:
+ spec = _load_spec()
+ phase = spec.get("phase_5_platform_transition") or {}
+ roadmap_text = _read_text(ROADMAP_DOC_PATH)
+ checks = {
+ "P1_kis_core_api_collector": _check_p1(),
+ "P2_sqlite_canonical_store": _check_p2(),
+ "P3_ci_scheduler_cutover": _check_p3(),
+ "P4_gas_thin_adapter_minimize": _check_p4(),
+ "P5_postgresql_upgrade_path": _check_p5(),
+ }
+
+ missing_criteria: list[str] = []
+ for key, result in checks.items():
+ spec_row = phase.get(key) or {}
+ criteria = spec_row.get("success_criteria") or {}
+ if not criteria:
+ missing_criteria.append(key)
+ if "expected_success_value" not in criteria:
+ missing_criteria.append(f"{key}.expected_success_value")
+ if "evidence_artifacts" not in criteria:
+ missing_criteria.append(f"{key}.evidence_artifacts")
+ if "verification_commands" not in criteria:
+ missing_criteria.append(f"{key}.verification_commands")
+ if result["gate"] != "PASS":
+ missing_criteria.append(f"{key}.evidence_gate")
+
+ roadmap_mentions = [
+ "Phase 5 데이터 플랫폼 전환 WBS 성공값",
+ "P1 KIS core collector",
+ "P2 SQLite canonical store",
+ "P3 CI scheduler cutover",
+ "P4 GAS thin adapter minimize",
+ "P5 PostgreSQL upgrade path",
+ ]
+ roadmap_missing = [item for item in roadmap_mentions if item.lower() not in roadmap_text.lower()]
+
+ payload = {
+ "formula_id": "PLATFORM_TRANSITION_WBS_V1",
+ "gate": "PASS" if not missing_criteria and not roadmap_missing else "FAIL",
+ "spec_path": str(SPEC_PATH),
+ "roadmap_doc_path": str(ROADMAP_DOC_PATH),
+ "missing_criteria": missing_criteria,
+ "roadmap_missing": roadmap_missing,
+ "checks": checks,
+ }
+ out = ROOT / "Temp" / "platform_transition_wbs_v1.json"
+ out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ return 0 if payload["gate"] == "PASS" else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/validate_qualitative_sell_strategy_pipeline_v1.py b/tools/validate_qualitative_sell_strategy_pipeline_v1.py
new file mode 100644
index 00000000..babd5fc5
--- /dev/null
+++ b/tools/validate_qualitative_sell_strategy_pipeline_v1.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def _read(path: Path) -> str:
+ return path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
+
+
+def main() -> int:
+ files = {
+ "workflow": ROOT / ".gitea" / "workflows" / "qualitative_sell_strategy.yml",
+ "build_inputs": ROOT / "tools" / "build_qualitative_sell_inputs_v1.py",
+ "build_satellite": ROOT / "tools" / "build_satellite_candidate_recommendations_v1.py",
+ "evaluate": ROOT / "tools" / "evaluate_qualitative_sell_strategy_accuracy_v1.py",
+ "store": ROOT / "src" / "quant_engine" / "qualitative_sell_strategy_store_v1.py",
+ "package": ROOT / "package.json",
+ }
+ errors: list[str] = []
+
+ for name, path in files.items():
+ if not path.exists():
+ errors.append(f"missing:{name}")
+
+ checks = {
+ "build_inputs_flags": ("--store-backend" in _read(files["build_inputs"]) and "--store-location" in _read(files["build_inputs"])),
+ "build_satellite_flags": ("--store-backend" in _read(files["build_satellite"]) and "--store-location" in _read(files["build_satellite"])),
+ "evaluate_flags": ("--store-backend" in _read(files["evaluate"]) and "--store-location" in _read(files["evaluate"])),
+ "store_contract": ("resolve_store_path" in _read(files["store"]) and "QualitativeSellStoreSpec" in _read(files["store"])),
+ "workflow_mentions_mock_validation": ("validate_kis_api_credentials_v1.py" in _read(files["workflow"])),
+ "workflow_has_schedule": ("schedule:" in _read(files["workflow"]) and "workflow_dispatch:" in _read(files["workflow"])),
+ "package_scripts": ("ops:sell-build" in _read(files["package"]) and "ops:sell-eval" in _read(files["package"]) and "ops:sell-validate" in _read(files["package"])),
+ }
+
+ for key, ok in checks.items():
+ if not ok:
+ errors.append(key)
+
+ result = {
+ "formula_id": "QUALITATIVE_SELL_STRATEGY_PIPELINE_V1",
+ "gate": "PASS" if not errors else "FAIL",
+ "checks": checks,
+ "errors": errors,
+ }
+ out = ROOT / "Temp" / "qualitative_sell_strategy_pipeline_v1.json"
+ out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0 if not errors else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/validate_snapshot_admin_web_v1.py b/tools/validate_snapshot_admin_web_v1.py
new file mode 100644
index 00000000..5220f4d9
--- /dev/null
+++ b/tools/validate_snapshot_admin_web_v1.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import socket
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+OUT = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json"
+
+
+def _read_json(url: str) -> dict[str, Any]:
+ with urllib.request.urlopen(url, timeout=5) as response:
+ payload = response.read().decode("utf-8")
+ data = json.loads(payload)
+ return data if isinstance(data, dict) else {}
+
+
+def _read_text(url: str) -> str:
+ with urllib.request.urlopen(url, timeout=5) as response:
+ return response.read().decode("utf-8")
+
+
+def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ request = urllib.request.Request(
+ url,
+ data=data,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urllib.request.urlopen(request, timeout=5) as response:
+ return json.loads(response.read().decode("utf-8"))
+
+
+def _wait_for_server(url: str, timeout_s: float = 15.0) -> None:
+ deadline = time.time() + timeout_s
+ last_error: Exception | None = None
+ while time.time() < deadline:
+ try:
+ _read_text(url)
+ return
+ except Exception as exc: # noqa: BLE001
+ last_error = exc
+ time.sleep(0.25)
+ raise RuntimeError(f"server did not start: {last_error}")
+
+
+def _pick_free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def main() -> int:
+ port = _pick_free_port()
+ db_path = ROOT / "Temp" / "snapshot_admin_web_validation.db"
+ seed_path = ROOT / "GatherTradingData.json"
+ server_cmd = [
+ sys.executable,
+ str(ROOT / "tools" / "run_snapshot_admin_server_v1.py"),
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(port),
+ "--db",
+ str(db_path),
+ "--seed",
+ str(seed_path),
+ ]
+
+ proc = subprocess.Popen(
+ server_cmd,
+ cwd=ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding="utf-8",
+ )
+ base_url = f"http://127.0.0.1:{port}"
+ errors: list[str] = []
+ html = ""
+ state: dict[str, Any] = {}
+
+ try:
+ _wait_for_server(base_url)
+ html = _read_text(f"{base_url}/")
+ state = _read_json(f"{base_url}/api/state")
+ export_payload = _read_json(f"{base_url}/api/export")
+ approval_packet = {
+ "formula_id": "SNAPSHOT_ADMIN_APPROVAL_PACKET_V1",
+ "generated_at": state.get("generated_at") or "",
+ "summary": {
+ "settings_changed": 0,
+ "account_snapshot_changed": 0,
+ "pending_target_count": 0,
+ },
+ "pending_targets": [],
+ "diff_preview": {"settings": {"added": [], "removed": [], "changed": []}, "account_snapshot": {"added": [], "removed": [], "changed": []}},
+ "approvals": state.get("approval_rows", []),
+ "locks": state.get("locks", []),
+ "workspace": state.get("summary", {}),
+ }
+ packet_response = _post_json(f"{base_url}/api/approval_packet", {"packet": approval_packet})
+ if "Snapshot Admin" not in html:
+ errors.append("html_title_missing")
+ if "contenteditable" not in html:
+ errors.append("sheet_editor_missing")
+ if "settings" not in html or "Account Snapshot" not in html:
+ errors.append("section_missing")
+ if "/api/settings/save" not in html or "/api/account_snapshot/save" not in html:
+ errors.append("api_binding_missing")
+ if "Approve pending" not in html or "Refresh diff" not in html:
+ errors.append("diff_or_approval_ui_missing")
+ if "Export approval packet" not in html:
+ errors.append("approval_packet_ui_missing")
+ if "Selection Inspector" not in html or "Apply TSV to selection" not in html or "Save view" not in html:
+ errors.append("sheet_facade_ui_missing")
+ if "Recent row history" not in html or "Ctrl+S" not in html:
+ errors.append("sheet_shortcuts_ui_missing")
+ if "KIS Collection" not in html or "collector:" not in html:
+ errors.append("collection_dashboard_ui_missing")
+ if "Recent collector snapshots" not in html or "Collection detail" not in html or "Filter runs / snapshots / errors" not in html:
+ errors.append("collection_detail_ui_missing")
+ if "Filter change log" not in html:
+ errors.append("change_log_filter_ui_missing")
+ if "Timeline" not in html or "/collection" not in html or "Open collection dashboard" not in html:
+ errors.append("collection_page_link_missing")
+ if "Open collection dashboard" not in html:
+ errors.append("collection_dashboard_link_missing")
+ collection_html = _read_text(f"{base_url}/collection")
+ if "KIS Collection Dashboard" not in collection_html or "Download CSV" not in collection_html or "Ticker quick search" not in collection_html or "Date quick search" not in collection_html:
+ errors.append("collection_dashboard_page_missing")
+ if int(state.get("summary", {}).get("settings_rows") or 0) <= 0:
+ errors.append("settings_rows_missing")
+ if int(state.get("summary", {}).get("account_snapshot_rows") or 0) <= 0:
+ errors.append("account_snapshot_rows_missing")
+ topology = state.get("summary", {}).get("topology", {})
+ if not isinstance(topology, dict):
+ errors.append("topology_missing")
+ else:
+ if topology.get("mode") != "single_workspace_sqlite":
+ errors.append("topology_mode_invalid")
+ if not topology.get("settings_and_snapshot_share_db"):
+ errors.append("topology_workspace_split_invalid")
+ if not topology.get("collector_separate_db"):
+ errors.append("topology_collector_split_invalid")
+ if not isinstance(state.get("version"), dict) or not state.get("version", {}).get("app"):
+ errors.append("version_metadata_missing")
+ if not isinstance(state.get("collection"), dict):
+ errors.append("collection_state_missing")
+ collection = state.get("collection", {})
+ if not isinstance(collection.get("counts"), dict):
+ errors.append("collection_counts_missing")
+ if "latest_report" not in collection:
+ errors.append("collection_latest_report_missing")
+ if "data" not in export_payload:
+ errors.append("export_missing_data")
+ if packet_response.get("gate") != "PASS":
+ errors.append("approval_packet_export_failed")
+ packet_path = Path(packet_response.get("packet_path") or "")
+ md_path = Path(packet_response.get("md_path") or "")
+ if not packet_path.exists():
+ errors.append("approval_packet_json_missing")
+ if not md_path.exists():
+ errors.append("approval_packet_md_missing")
+
+ payload = {
+ "formula_id": "SNAPSHOT_ADMIN_WEB_VALIDATION_V1",
+ "gate": "PASS" if not errors else "FAIL",
+ "port": port,
+ "db_path": str(db_path),
+ "base_url": base_url,
+ "errors": errors,
+ "summary": state.get("summary", {}),
+ "version": state.get("version", {}),
+ "settings_rows": int(state.get("summary", {}).get("settings_rows") or 0),
+ "account_snapshot_rows": int(state.get("summary", {}).get("account_snapshot_rows") or 0),
+ "approval_packet_path": str(packet_path),
+ }
+ OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ return 0 if payload["gate"] == "PASS" else 1
+ except urllib.error.URLError as exc:
+ errors.append(str(exc))
+ payload = {
+ "formula_id": "SNAPSHOT_ADMIN_WEB_VALIDATION_V1",
+ "gate": "FAIL",
+ "port": port,
+ "db_path": str(db_path),
+ "base_url": base_url,
+ "errors": errors,
+ "summary": state.get("summary", {}),
+ "version": state.get("version", {}),
+ }
+ OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ return 1
+ finally:
+ if proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait(timeout=5)
+ if proc.stdout is not None:
+ proc.stdout.close()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/validate_snapshot_admin_workflow_v1.py b/tools/validate_snapshot_admin_workflow_v1.py
new file mode 100644
index 00000000..cc7f989c
--- /dev/null
+++ b/tools/validate_snapshot_admin_workflow_v1.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from src.quant_engine.snapshot_admin_store_v1 import (
+ DEFAULT_DB,
+ DEFAULT_SEED_JSON,
+ import_seed_json,
+ load_account_snapshot_rows,
+ load_settings_rows,
+ parse_account_snapshot_tsv,
+ validate_account_snapshot_rows,
+ validate_settings_rows,
+ write_export_json,
+)
+
+OUT = ROOT / "Temp" / "snapshot_admin_workflow_v1.json"
+
+
+def main() -> int:
+ db_path = DEFAULT_DB
+ seed_path = DEFAULT_SEED_JSON
+ summary = import_seed_json(db_path, seed_path)
+ settings_rows = load_settings_rows(db_path)
+ snapshot_rows = load_account_snapshot_rows(db_path)
+ settings_errors = validate_settings_rows(settings_rows)
+ snapshot_errors = validate_account_snapshot_rows(snapshot_rows)
+ exported = write_export_json(db_path, ROOT / "Temp" / "snapshot_admin_export_v1.json")
+ tsv_rows = parse_account_snapshot_tsv(
+ "\n".join(
+ [
+ "captured_at\taccount\taccount_type\tticker\tname\tholding_quantity\tavailable_quantity\taverage_cost\ttotal_cost\tcurrent_price\tmarket_value\tprofit_loss\treturn_pct\timmediate_cash\tsettlement_cash_d2\tavailable_cash\topen_order_amount\tmonthly_contribution_limit\tmonthly_contribution_used\tparse_status\tuser_confirmed\tstop_price\thighest_price_since_entry\tentry_date\tentry_stage\tposition_type\tlast_updated",
+ "2026-06-21T09:00:00+09:00\treal\t일반계좌\t005930\t삼성전자\t10\t10\t70000\t700000\t71000\t710000\t10000\t1.43\t1000000\t1000000\t1000000\t0\t\t\tCAPTURE_READ_OK\tY\t65000\t72000\t2026-06-01\tstage_1\tcore\t2026-06-21T09:05:00+09:00",
+ ]
+ )
+ )
+ payload = {
+ "status": "PASS",
+ "db_path": str(db_path),
+ "seed_path": str(seed_path),
+ "summary": summary,
+ "settings_rows": len(settings_rows),
+ "account_snapshot_rows": len(snapshot_rows),
+ "settings_errors": settings_errors,
+ "snapshot_errors": snapshot_errors,
+ "export_path": str(exported),
+ "tsv_parse_rows": len(tsv_rows),
+ }
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ if settings_errors or snapshot_errors:
+ print("FAIL")
+ return 1
+ print("PASS")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/validate_specs.py b/tools/validate_specs.py
index e3d81010..3d27deaa 100644
--- a/tools/validate_specs.py
+++ b/tools/validate_specs.py
@@ -117,6 +117,10 @@ def validate_formula_registry(errors: list[str]) -> None:
"ALPHA_FEEDBACK_LOOP_V2", "ALPHA_LEAD_THRESHOLD_OPTIMIZER_V1",
# ENGINE_AUDIT — Python-tool-only 감사 게이트 (GAS 런타임 비개입)
"IMPUTED_DATA_EXPOSURE_GATE_V1",
+ # Phase-8 비기계적 매도전략 — confluence 기반 판단 게이트 (output_contract 구조)
+ "SHORT_INTEREST_RISK_GAUGE_V1", "QUALITATIVE_SELL_STRATEGY_V1",
+ "MARKET_REGIME_CLASSIFIER_V1", "SATELLITE_CANDIDATE_SCORE_V1",
+ "MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1",
}
for formula_id, formula in all_formulas.items():
if not isinstance(formula, dict):
@@ -619,6 +623,62 @@ def validate_harness_contract_consistency(errors: list[str]) -> None:
fail(errors, f"harness_contract collection_key not checked in validator: {key}")
+def validate_spec_code_sync(errors: list[str]) -> dict:
+ """WBS-7.11(2026-06-22) — spec YAML이 code_path로 가리키는 파일이 실제로 존재하는지 검사.
+
+ has_code_implementation 필드가 있는 파일만 검사한다(점진적 롤아웃 — 필드가 없는
+ 파일은 스킵되므로 1차 태깅이 기존 PASS 상태를 절대 깨지 않는다). redirect_only:true인
+ 파일은 의도적으로 코드가 없는 순수 호환 인덱스이므로 code_path 검사 대상이 아니며,
+ has_code_implementation:true와 동시에 있으면 그 자체로 모순이라 fail한다.
+ """
+ all_yaml_paths = sorted((ROOT / "spec").rglob("*.yaml")) + sorted((ROOT / "governance").rglob("*.yaml"))
+ total_files = len(all_yaml_paths)
+ checked = 0
+ missing = 0
+ for path in all_yaml_paths:
+ try:
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ except Exception:
+ continue
+ if not isinstance(data, dict):
+ continue
+ meta = data.get("meta") if isinstance(data.get("meta"), dict) else data
+ has_code = meta.get("has_code_implementation")
+ if has_code is None:
+ continue
+ redirect_only = bool(meta.get("redirect_only"))
+ checked += 1
+ if redirect_only and has_code:
+ fail(errors, f"spec_code_sync contradiction: {path} has redirect_only=true AND has_code_implementation=true")
+ missing += 1
+ continue
+ if not has_code:
+ continue
+ code_path = meta.get("code_path")
+ candidates = code_path if isinstance(code_path, list) else [code_path] if code_path else []
+ if not candidates:
+ fail(errors, f"spec_code_sync: {path} declares has_code_implementation=true but no code_path")
+ missing += 1
+ continue
+ for rel in candidates:
+ if not (ROOT / str(rel)).exists():
+ fail(errors, f"spec declares code_path that does not exist: {path} -> {rel}")
+ missing += 1
+
+ result = {
+ "formula_id": "SPEC_CODE_SYNC_V1",
+ "total_spec_files": total_files,
+ "checked_count": checked,
+ "missing_code_path_count": missing,
+ "sync_field_coverage_pct": round(100.0 * checked / total_files, 2) if total_files else 0.0,
+ "gate": "PASS" if missing == 0 else "FAIL",
+ }
+ out = ROOT / "Temp" / "spec_code_sync_v1.json"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+ return result
+
+
def main() -> int:
errors: list[str] = []
@@ -660,10 +720,9 @@ def main() -> int:
manifest_text = (ROOT / "RetirementAssetPortfolio.yaml").read_text(encoding="utf-8")
for path in sorted((ROOT / "spec").rglob("*.yaml")):
rel = path.relative_to(ROOT).as_posix()
- if rel not in manifest_text and rel not in {"spec/03_risk_policy.yaml", "spec/04_strategy_rules.yaml"}:
+ if rel not in manifest_text:
fail(errors, f"spec file not registered in manifest: {rel}")
if path.stat().st_size > MAX_SPEC_BYTES and path.name not in {
- "03_risk_policy.yaml", "04_strategy_rules.yaml",
"13_formula_registry.yaml", "13b_harness_formulas.yaml",
"12_field_dictionary.yaml",
"51_formula_lifecycle_registry.yaml", # 290+ formula lifecycle registry (Proposal51-P1)
@@ -770,13 +829,12 @@ def main() -> int:
validate_formula_registry(errors)
validate_output_rendering_contract(schema, errors)
validate_harness_contract_consistency(errors)
+ validate_spec_code_sync(errors)
aliases = load_yaml(ROOT / "spec" / "aliases.yaml", errors) or {}
alias_map = aliases.get("aliases") or {}
alias_files = {
ROOT / "spec" / "aliases.yaml",
- ROOT / "spec" / "03_risk_policy.yaml",
- ROOT / "spec" / "04_strategy_rules.yaml",
ROOT / "spec" / "06_exit_policy.yaml",
ROOT / "spec" / "risk" / "risk_control.yaml",
ROOT / "spec" / "strategy" / "entry_gates.yaml",