from __future__ import annotations import json import re import sqlite3 from datetime import datetime, timedelta, timezone from functools import lru_cache from pathlib import Path from typing import Any import yaml ROOT = Path(__file__).resolve().parents[2] DEFAULT_DB = ROOT / "src" / "quant_engine" / "snapshot_admin.db" DEFAULT_SEED_JSON = ROOT / "GatherTradingData.json" KST = timezone(timedelta(hours=9)) 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(latest_ts) FROM ( SELECT updated_at as latest_ts FROM {SETTINGS_TABLE} UNION ALL SELECT captured_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 / "src" / "quant_engine" / "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 def _as_int(value: Any) -> int | None: if value is None or value == "": return None if isinstance(value, bool): return None if isinstance(value, int): return value if isinstance(value, float): return int(value) if value.is_integer() else None try: text = str(value).strip().replace(",", "") if not text: return None parsed = float(text) return int(parsed) if parsed.is_integer() else None 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_int(row.get("holding_quantity")) available_quantity = _as_int(row.get("available_quantity")) average_cost = _as_number(row.get("average_cost")) total_cost = _as_number(row.get("total_cost")) current_price = _as_number(row.get("current_price")) market_value = _as_number(row.get("market_value")) profit_loss = _as_number(row.get("profit_loss")) return_pct = _as_number(row.get("return_pct")) 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 and name != "예수금/D+2현금": 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 available_quantity is not None and available_quantity < 0: errors.append(f"account_snapshot row {idx}: available_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 total_cost is not None and total_cost < 0: errors.append(f"account_snapshot row {idx}: total_cost must be >= 0") if current_price is not None and current_price < 0: errors.append(f"account_snapshot row {idx}: current_price must be >= 0") if market_value is not None and market_value < 0: errors.append(f"account_snapshot row {idx}: market_value must be >= 0") if profit_loss is not None and profit_loss != profit_loss: errors.append(f"account_snapshot row {idx}: profit_loss invalid") if return_pct is not None and abs(return_pct) > 1000: errors.append(f"account_snapshot row {idx}: return_pct out of range") if stop_price is not None and stop_price < 0: errors.append(f"account_snapshot row {idx}: stop_price 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}")