스냅샷 어드민 웹 UI + WBS-7.10 Tabler 테이블 그리드 조회

settings/account_snapshot SQLite를 직접 편집하는 잠금/승인/변경이력
기반 웹 에디터를 추가하고, 2026-06-21 비판적 리뷰에서 요청된 테이블별
그리드 조회 기능(Tabler CDN)을 /tables 경로로 덧붙인다.

- 잠금(lock)·승인(approval)·undo·변경로그 전체 감사 추적
- KIS Collection 대시보드 통합(별도 SQLite, 워크스페이스 DB와 분리)
- WBS-7.10: 워크스페이스/KIS수집/정성매도전략 3개 SQLite, 11개 테이블을
  Tabler 그리드로 조회 — 테이블명은 고정 화이트리스트와 정확히 일치할
  때만 SQL에 사용(SQL 인젝션 방지, 단위테스트로 검증)
This commit is contained in:
2026-06-21 20:06:55 +09:00
parent da0e1b0f7e
commit f99f9821d2
8 changed files with 4889 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
name: Snapshot Admin Web Validation
on:
workflow_dispatch:
push:
paths:
- "src/quant_engine/snapshot_admin_server_v1.py"
- "src/quant_engine/snapshot_admin_store_v1.py"
- "tools/run_snapshot_admin_server_v1.py"
- "tools/validate_snapshot_admin_workflow_v1.py"
- "tools/validate_snapshot_admin_web_v1.py"
- "spec/15_account_snapshot_contract.yaml"
- "spec/18_settings_contract.yaml"
- "GatherTradingData.json"
jobs:
validate-snapshot-admin:
runs-on: self-hosted
steps:
- name: Checkout Code
run: |
if [ -d .git ]; then
git remote set-url origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
else
git init
git remote add origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
fi
git fetch origin main --depth=1
git reset --hard FETCH_HEAD
- name: Validate Snapshot Admin Workflow
run: python3 tools/validate_snapshot_admin_workflow_v1.py
- name: Validate Snapshot Admin Web UI
run: python3 tools/validate_snapshot_admin_web_v1.py
- name: Notify Run Result
if: always()
run: |
STATUS="${{ job.status }}"
echo "=== Snapshot Admin Web Validation ==="
echo "status: $STATUS"
echo "workflow validation: Temp/snapshot_admin_workflow_v1.json"
echo "web validation: Temp/snapshot_admin_web_validation_v1.json"
File diff suppressed because it is too large Load Diff
+993
View File
@@ -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}")
+249
View File
@@ -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)
+144
View File
@@ -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
+164
View File
@@ -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())
+222
View File
@@ -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())
@@ -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())