diff --git a/src/quant_engine/snapshot_admin_server_v1.py b/src/quant_engine/snapshot_admin_server_v1.py
index 1bea589..d2b397d 100644
--- a/src/quant_engine/snapshot_admin_server_v1.py
+++ b/src/quant_engine/snapshot_admin_server_v1.py
@@ -1,9 +1,12 @@
from __future__ import annotations
import argparse
+import datetime as dt
import json
import sqlite3
import subprocess
+import time
+import sys
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -15,6 +18,8 @@ ROOT = Path(__file__).resolve().parents[2]
SNAPSHOT_ADMIN_VERSION = "snapshot-admin-web-v6"
KIS_COLLECTION_DB = ROOT / "src" / "quant_engine" / "kis_data_collection.db"
KIS_COLLECTION_REPORT = ROOT / "Temp" / "kis_data_collection_v1.json"
+WORKBOOK_JSON = ROOT / "GatherTradingData.json"
+WORKBOOK_XLSX = ROOT / "GatherTradingData.xlsx"
QUALITATIVE_SELL_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db"
# WBS-7.9 부속 — 테이블별 그리드 조회(Tabler). 화이트리스트에 없는 테이블명은
@@ -285,9 +290,15 @@ def _source_fingerprint() -> dict[str, Any]:
latest_mtime = max(latest_mtime, path.stat().st_mtime)
except OSError:
continue
+ latest_updated_at = ""
+ if latest_mtime:
+ latest_updated_at = dt.datetime.fromtimestamp(latest_mtime, tz=dt.timezone.utc).astimezone(
+ dt.timezone(dt.timedelta(hours=9))
+ ).isoformat()
return {
"fingerprint": digest.hexdigest()[:16],
"latest_mtime": latest_mtime,
+ "latest_updated_at": latest_updated_at,
}
@@ -331,6 +342,7 @@ def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]:
collection = load_collection_dashboard_state(KIS_COLLECTION_DB, KIS_COLLECTION_REPORT)
except Exception:
collection = {}
+ workbook_registry = load_workbook_sheet_registry()
return {
"version": {
"app": SNAPSHOT_ADMIN_VERSION,
@@ -358,10 +370,246 @@ def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]:
},
"autofix_actions": autofix_actions,
"collection": collection,
+ "workbook_registry": workbook_registry,
"generated_at": now_kst_iso(),
}
+def load_workbook_sheet_registry() -> dict[str, Any]:
+ try:
+ payload = json.loads(WORKBOOK_JSON.read_text(encoding="utf-8"))
+ except Exception:
+ payload = {}
+ metadata = payload.get("metadata") if isinstance(payload, dict) else {}
+ sheets = metadata.get("sheets_included") if isinstance(metadata, dict) else []
+ sheet_headers = metadata.get("sheet_headers") if isinstance(metadata, dict) else {}
+ data = payload.get("data") if isinstance(payload, dict) else {}
+ if not isinstance(sheets, list):
+ sheets = []
+ if not isinstance(data, dict):
+ data = {}
+ entries: list[dict[str, Any]] = []
+ for sheet in sheets:
+ sheet_name = str(sheet)
+ source_role = "derived_report_evidence"
+ if sheet_name in {"settings", "account_snapshot"}:
+ destination = "snapshot_admin.db"
+ kind = "workspace_db"
+ purpose = "workspace_edit"
+ source_role = "canonical_db"
+ elif sheet_name == "data_feed":
+ destination = "kis_data_collection.db"
+ kind = "collector_db"
+ purpose = "collector_run"
+ source_role = "collector_db"
+ elif sheet_name in {"sector_universe_refresh_audit", "daily_history", "event_calendar", "pa1_feedback", "alpha_history", "backdata_feature_bank", "sell_priority", "harness_context", "monthly_history", "sector_flow_history", "sector_universe", "core_satellite", "universe", "event_risk", "macro", "sector_flow"}:
+ destination = "GatherTradingData.json"
+ if sheet_name in {"sector_universe_refresh_audit"}:
+ purpose = "refresh_audit"
+ elif sheet_name in {"daily_history"}:
+ purpose = "history_ledger"
+ elif sheet_name in {"event_calendar", "pa1_feedback"}:
+ purpose = "audit_history"
+ elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}:
+ purpose = "analysis_report"
+ elif sheet_name in {"harness_context"}:
+ purpose = "execution_context"
+ elif sheet_name in {"monthly_history", "sector_flow_history"}:
+ purpose = "history_ledger"
+ elif sheet_name in {"sector_universe", "core_satellite", "universe"}:
+ purpose = "universe_registry"
+ elif sheet_name in {"event_risk", "macro"}:
+ purpose = "macro_risk_context"
+ elif sheet_name in {"sector_flow"}:
+ purpose = "flow_leadership"
+ else:
+ purpose = "json_payload"
+ kind = "json_payload"
+ else:
+ destination = "unknown"
+ kind = "unmapped"
+ purpose = "unmapped"
+ header_meta = sheet_headers.get(sheet_name) if isinstance(sheet_headers, dict) else {}
+ entries.append(
+ {
+ "sheet": sheet_name,
+ "destination": destination,
+ "kind": kind,
+ "purpose": purpose,
+ "source_role": source_role,
+ "has_json_payload": sheet_name in data,
+ "row_count": _sheet_payload_row_count(data.get(sheet_name), header_meta),
+ }
+ )
+ explicit_target_sheets = [
+ "sector_universe_refresh_audit",
+ "daily_history",
+ "event_calendar",
+ "pa1_feedback",
+ "alpha_history",
+ "backdata_feature_bank",
+ "sell_priority",
+ "harness_context",
+ "monthly_history",
+ "sector_flow_history",
+ "sector_universe",
+ "core_satellite",
+ "universe",
+ "event_risk",
+ "macro",
+ "sector_flow",
+ "data_feed",
+ ]
+ existing_sheets = {item["sheet"] for item in entries}
+ for sheet_name in explicit_target_sheets:
+ if sheet_name in existing_sheets:
+ continue
+ if sheet_name == "data_feed":
+ destination = "kis_data_collection.db"
+ kind = "collector_db"
+ purpose = "collector_run"
+ source_role = "collector_db"
+ else:
+ destination = "GatherTradingData.json"
+ kind = "json_payload"
+ source_role = "derived_report_evidence"
+ if sheet_name == "sector_universe_refresh_audit":
+ purpose = "refresh_audit"
+ elif sheet_name == "daily_history":
+ purpose = "history_ledger"
+ elif sheet_name in {"event_calendar", "pa1_feedback"}:
+ purpose = "audit_history"
+ elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}:
+ purpose = "analysis_report"
+ elif sheet_name == "harness_context":
+ purpose = "execution_context"
+ elif sheet_name in {"monthly_history", "sector_flow_history"}:
+ purpose = "history_ledger"
+ elif sheet_name in {"sector_universe", "core_satellite", "universe"}:
+ purpose = "universe_registry"
+ elif sheet_name in {"event_risk", "macro"}:
+ purpose = "macro_risk_context"
+ elif sheet_name == "sector_flow":
+ purpose = "flow_leadership"
+ else:
+ purpose = "json_payload"
+ entries.append(
+ {
+ "sheet": sheet_name,
+ "destination": destination,
+ "kind": kind,
+ "purpose": purpose,
+ "source_role": source_role,
+ "has_json_payload": kind == "json_payload",
+ "row_count": 0,
+ }
+ )
+ return {
+ "xlsx_path": str(WORKBOOK_XLSX),
+ "json_path": str(WORKBOOK_JSON),
+ "json_role": "derived_report_evidence",
+ "sheet_count": len(entries),
+ "entries": entries,
+ "unmapped_sheets": [item["sheet"] for item in entries if item["kind"] == "unmapped"],
+ }
+
+
+def _sheet_payload_row_count(value: Any, header_meta: Any | None = None) -> int | None:
+ if isinstance(header_meta, dict) and isinstance(header_meta.get("row_count"), int):
+ return int(header_meta["row_count"])
+ if isinstance(value, list):
+ return len(value)
+ if isinstance(value, dict):
+ return len(value)
+ if value is None:
+ return 0
+ return None
+
+
+def run_collection_job(
+ *,
+ sqlite_db: Path | None = None,
+ input_json: Path | None = None,
+ output_json: Path | None = None,
+ allow_naver_fallback: bool = False,
+ include_live_kis: bool = False,
+ kis_account: str = "real",
+) -> dict[str, Any]:
+ sqlite_db = sqlite_db or KIS_COLLECTION_DB
+ input_json = input_json or (ROOT / "GatherTradingData.json")
+ output_json = output_json or KIS_COLLECTION_REPORT
+ cmd = [
+ sys.executable,
+ str(ROOT / "tools" / "run_kis_data_collection_v1.py"),
+ "--input-json",
+ str(input_json),
+ "--sqlite-db",
+ str(sqlite_db),
+ "--output-json",
+ str(output_json),
+ "--kis-account",
+ kis_account,
+ ]
+ if allow_naver_fallback:
+ cmd.append("--allow-naver-fallback")
+ if not include_live_kis:
+ cmd.append("--no-live-kis")
+
+ started_at = now_kst_iso()
+ started = time.perf_counter()
+ proc = subprocess.run(
+ cmd,
+ cwd=str(ROOT),
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ finished_at = now_kst_iso()
+ elapsed_ms = round((time.perf_counter() - started) * 1000.0, 1)
+ summary = {}
+ if output_json.exists():
+ try:
+ loaded = json.loads(output_json.read_text(encoding="utf-8"))
+ summary = loaded if isinstance(loaded, dict) else {}
+ except Exception:
+ summary = {}
+ state = {}
+ try:
+ state = load_collection_dashboard_state(sqlite_db, output_json)
+ except Exception:
+ state = {}
+ return {
+ "status": "PASS" if proc.returncode == 0 else "FAIL",
+ "started_at": started_at,
+ "finished_at": finished_at,
+ "elapsed_ms": elapsed_ms,
+ "command": cmd,
+ "returncode": proc.returncode,
+ "stdout": proc.stdout,
+ "stderr": proc.stderr,
+ "summary": summary,
+ "state": state,
+ }
+
+
+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 _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)
@@ -389,6 +637,118 @@ def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
return payload
+def render_home_html() -> str:
+ return """
+
+
+
+
+ Snapshot Admin Home
+
+
+
+
+
+ Snapshot Admin Home
+ 이 화면은 운영 판단용입니다. 편집, 승인, 수집이 모두 한 곳에 섞여 있으면 목적이 흐려지므로, 먼저 해야 할 일만 보여줍니다.
+
+
+
1. Workspace
+
데이터를 고치고 저장
+
settings와 account_snapshot 편집, TSV 적용, 검증 확인은 workspace에서 처리합니다.
+
+
+
2. Collection
+
수집 실행과 결과 확인
+
수집 버튼, 진행상태, 결과 로그는 collection 화면에 둡니다.
+
+
+
3. Tables
+
DB와 JSON 증빙을 분리 검토
+
DB별 조회, JSON별 조회, 시계열 이력은 tables에서 확인합니다.
+
+
+
+
+
+
+
+
+"""
+
+
+def render_inspector_html() -> str:
+ return """
+
+
+
+
+ Snapshot Inspector
+
+
+
+
+
+ Snapshot Inspector
+ Row-level operations move here so the workspace stays focused on editing.
+
+ Select a domain and target_ref, then load.
+
+
+
+
+
+
+"""
+
+
def render_index_html() -> str:
return """
@@ -397,53 +757,84 @@ def render_index_html() -> str:
Snapshot Admin
@@ -949,6 +1400,7 @@ def render_index_html() -> str:
@@ -972,6 +1424,29 @@ def render_index_html() -> str:
Snapshot approval state and lock state are pinned here for immediate review.
+
+
+
+ Now
+ Loading current workspace status...
+ This page summarizes the canonical SQLite workspace and collection state.
+
+
+
Next action
+
Loading...
+
The page will tell you whether to edit, approve, unlock, or collect.
+
+
+
+ Collection
+ Loading...
+ Latest collector run and result summary.
+
+
+
@@ -1004,10 +1479,15 @@ def render_index_html() -> str:
-
-
+
+
Approval & Locks
+ open when you need to approve or lock rows
+
+
+
+
@@ -1015,8 +1495,6 @@ def render_index_html() -> str:
-
-
settings approval
@@ -1046,22 +1524,29 @@ def render_index_html() -> str:
-
+
+
-
-
+
+
KIS Collection
+ open when you want to run or inspect collector output
+
+
+
+
-
-
collection: loading...
+
Collection trend
+
+
@@ -1079,20 +1564,23 @@ def render_index_html() -> str:
-
+
-
-
+
+
Selection Inspector
+ open when you need row-level operations
+
+
+
+
-
-
No row selected.
@@ -1115,7 +1603,7 @@ def render_index_html() -> str:
-
+
@@ -2230,9 +2718,71 @@ def render_index_html() -> str:
].join("\n");
}
+ function summarizeNextAction() {
+ const settingsErrors = (state.validation?.settings || []).length;
+ const snapshotErrors = (state.validation?.account_snapshot || []).length;
+ const settingsApproval = state.approvalSettings?.status || "MISSING";
+ const snapshotApproval = state.approvalSnapshot?.status || "MISSING";
+ const lockCount = (state.locks || []).length;
+ if (settingsErrors || snapshotErrors) {
+ return {
+ title: "Fix validation issues",
+ detail: `${settingsErrors + snapshotErrors} validation issue(s) remain. Edit the workspace before approving or exporting.`,
+ };
+ }
+ if (settingsApproval !== "APPROVED" || snapshotApproval !== "APPROVED") {
+ return {
+ title: "Approve the current workspace",
+ detail: `Approval status is settings=${settingsApproval}, snapshot=${snapshotApproval}. Review and approve the rows that are ready.`,
+ };
+ }
+ if (lockCount > 0) {
+ return {
+ title: "Resolve active locks",
+ detail: `${lockCount} lock(s) are active. Unlock only after confirming the affected rows are stable.`,
+ };
+ }
+ return {
+ title: "Workspace is ready",
+ detail: "Approvals are clear and no active locks remain. Export or continue with downstream work.",
+ };
+ }
+
+ function summarizeCollectionAction(collection, latestRun, latestReport) {
+ const runStatus = String(latestRun?.status || "").trim();
+ const runId = String(latestRun?.run_id || "").trim();
+ if (!runId) {
+ return {
+ title: "No collection run yet",
+ detail: "Use Collect now to create the first collector run and populate the run history.",
+ };
+ }
+ if (runStatus === "FAIL") {
+ return {
+ title: "Collector failed",
+ detail: `Latest run ${runId} failed. Check the run log and error rows before retrying.`,
+ };
+ }
+ return {
+ title: `Latest collection ${runStatus || "PASS"}`,
+ detail: `Run ${runId} finished at ${latestRun?.finished_at || "N/A"}. Report status is ${latestReport?.status || "N/A"}.`,
+ };
+ }
+
+ function setPanelOpen(panelId, shouldOpen) {
+ const panel = document.getElementById(panelId);
+ if (panel) panel.open = Boolean(shouldOpen);
+ }
+
function renderMeta() {
const settingsApproval = state.approvalSettings || {};
const snapshotApproval = state.approvalSnapshot || {};
+ const settingsErrors = state.validation?.settings || [];
+ const snapshotErrors = state.validation?.account_snapshot || [];
+ const lockCount = (state.locks || []).length;
+ const collection = state.collection || {};
+ const latestRun = collection.latest_run || {};
+ const latestReport = collection.latest_report || {};
const version = state.version || {};
const git = version.git || {};
const source = version.source || {};
@@ -2272,6 +2822,40 @@ def render_index_html() -> str:
`change_log=${historyCounts.changes ?? 0}, approvals=${historyCounts.approvals ?? 0}, locks=${historyCounts.locks ?? 0}` +
(changeLogFilter ? `, filtered=${visibleChanges.length}` : "") +
(recent ? ` | latest=${recent.domain}:${recent.action}:${recent.target_ref} @ ${recent.created_at}` : "");
+ const nextAction = summarizeNextAction();
+ const collectionAction = summarizeCollectionAction(collection, latestRun, latestReport);
+ const heroNow = document.getElementById("heroNow");
+ const heroNowDetail = document.getElementById("heroNowDetail");
+ const heroNextAction = document.getElementById("heroNextAction");
+ const heroNextDetail = document.getElementById("heroNextDetail");
+ const heroPrimaryAction = document.getElementById("heroPrimaryAction");
+ const heroCollection = document.getElementById("heroCollection");
+ const heroCollectionDetail = document.getElementById("heroCollectionDetail");
+ if (heroNow) {
+ heroNow.textContent = `Workspace rows: settings=${state.summary?.settings_rows ?? 0}, snapshot=${state.summary?.account_snapshot_rows ?? 0}`;
+ }
+ if (heroNowDetail) {
+ heroNowDetail.textContent = `Approval: settings=${settingsApproval.status || "MISSING"}, snapshot=${snapshotApproval.status || "MISSING"} | locks=${locks.length}`;
+ }
+ if (heroNextAction) heroNextAction.textContent = nextAction.title;
+ if (heroNextDetail) heroNextDetail.textContent = nextAction.detail;
+ if (heroPrimaryAction) {
+ heroPrimaryAction.textContent = nextAction.title;
+ const workspaceNeedsAttention = settingsErrors.length > 0 || snapshotErrors.length > 0 || settingsApproval.status !== "APPROVED" || snapshotApproval.status !== "APPROVED" || lockCount > 0;
+ heroPrimaryAction.href = workspaceNeedsAttention ? "/" : "/collection";
+ }
+ if (heroCollection) {
+ heroCollection.textContent = collectionAction.title;
+ }
+ if (heroCollectionDetail) {
+ heroCollectionDetail.textContent = collectionAction.detail;
+ }
+ const shouldOpenWorkspace = settingsErrors.length > 0 || snapshotErrors.length > 0;
+ const shouldOpenApproval = !shouldOpenWorkspace && (settingsApproval.status !== "APPROVED" || snapshotApproval.status !== "APPROVED" || lockCount > 0);
+ const shouldOpenCollection = !shouldOpenWorkspace && !shouldOpenApproval && (!latestRun.run_id || latestRun.status === "FAIL");
+ setPanelOpen("approvalPanel", shouldOpenApproval);
+ setPanelOpen("collectionPanel", shouldOpenCollection);
+ setPanelOpen("selectionPanel", Boolean(state.selected?.domain) && !shouldOpenWorkspace && !shouldOpenApproval && !shouldOpenCollection);
document.getElementById("bannerApprovalSummary").textContent =
`settings=${settingsApproval.status || "MISSING"} | snapshot=${snapshotApproval.status || "MISSING"}`;
document.getElementById("bannerLockSummary").textContent =
@@ -2290,17 +2874,27 @@ def render_index_html() -> str:
}).join("\n");
const timelineEl = document.getElementById("changeTimeline");
if (timelineEl) {
+ const changeSummary = visibleChanges.slice(0, 12).map((item) => ({
+ ts: String(item.created_at || "").slice(5, 16),
+ label: `${item.domain}:${item.action}`,
+ }));
timelineEl.innerHTML = visibleChanges.length
- ? visibleChanges.map((item) => {
- const before = Array.isArray(item.before_json) ? item.before_json : [];
- const after = Array.isArray(item.after_json) ? item.after_json : [];
- return `
-
- ${esc(item.domain)} · ${esc(item.action)} · ${esc(item.target_ref)} · ${esc(item.created_at)}
- ${esc(JSON.stringify({ before, after }, null, 2))}
-
- `;
- }).join("")
+ ? `
+
Recent change summary
+
+ ${changeSummary.map((item) => `${esc(item.ts || "N/A")} · ${esc(item.label)}`).join("")}
+
+ ${visibleChanges.map((item) => {
+ const before = Array.isArray(item.before_json) ? item.before_json : [];
+ const after = Array.isArray(item.after_json) ? item.after_json : [];
+ return `
+
+ ${esc(item.domain)} · ${esc(item.action)} · ${esc(item.target_ref)} · ${esc(item.created_at)}
+ ${esc(JSON.stringify({ before, after }, null, 2))}
+
+ `;
+ }).join("")}
+ `
: "
No timeline entries.
";
}
}
@@ -2335,8 +2929,9 @@ def render_index_html() -> str:
const runsEl = document.getElementById("collectionRuns");
const snapshotsEl = document.getElementById("collectionSnapshots");
const errorsEl = document.getElementById("collectionErrors");
+ const timelineEl = document.getElementById("collectionTimeline");
const detailEl = document.getElementById("collectionDetail");
- if (!chip || !summary || !metrics || !runsEl || !snapshotsEl || !errorsEl || !detailEl) return;
+ if (!chip || !summary || !metrics || !runsEl || !snapshotsEl || !errorsEl || !timelineEl || !detailEl) return;
const filterText = String(document.getElementById("collectionFilter")?.value || state.filterCollection || "").trim().toLowerCase();
state.filterCollection = filterText;
chip.textContent = `collector: ${counts.collection_runs ?? 0} runs / ${counts.collection_snapshots ?? 0} snapshots / ${counts.collection_source_errors ?? 0} errors`;
@@ -2346,6 +2941,7 @@ def render_index_html() -> str:
latestRun.run_id ? `latest_run=${latestRun.run_id}` : "",
latestReport.status ? `latest_status=${latestReport.status}` : "",
latestReport.generated_at ? `generated_at=${latestReport.generated_at}` : "",
+ state.version?.source?.latest_updated_at ? `final_updated_at=${state.version.source.latest_updated_at}` : "",
filterText ? `filter=${filterText}` : "",
].filter(Boolean).join(" | ");
const sourceCounts = latestReport.source_counts || {};
@@ -2378,7 +2974,7 @@ def render_index_html() -> str:
const key = currentCollectionSelectionKey(kind, item, index);
const selected = selection.kind === kind && selection.key === key;
return `
-