1032 lines
38 KiB
Python
1032 lines
38 KiB
Python
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}")
|