Files
jarca-ledger-mcp/jarca_ledger_mcp/application/assets_service.py
T

1083 lines
62 KiB
Python

from __future__ import annotations
import json
import hashlib
from .account_valuation_service import _public_candidate
from .shared import (
ASSET_BUCKETS,
ASSET_BUCKET_ORDER,
Any,
LIABILITY_BUCKETS,
current_kst_date,
date,
db,
read_db,
read_operation,
fail,
new_id,
normalize_date,
normalize_liquidity_bucket,
now_iso,
ok,
row_to_dict,
sqlite3,
)
def _valuation_policy() -> dict[str, Any]:
return {
"authority_sources": ["accounts.last_balance", "manual_assets"],
"snapshot_role": "historical_record_layer",
"formula": "asset_buckets - loan",
"asset_buckets": ASSET_BUCKETS,
"liability_buckets": LIABILITY_BUCKETS,
"missing_valuation_policy": "exclude_without_transaction_inference",
}
def _canonical_provenance(*, valuation_date: str = "", basis_date: str = "", carried: bool = False,
verification_status: str = "", evidence_kind: str = "", warnings: list[dict[str, Any]] | None = None,
exclusions: list[dict[str, Any]] | None = None, snapshot_run_id: str = "", close_status: str = "",
selection_policy: str = "", approval: dict[str, Any] | None = None,
correction: dict[str, Any] | None = None, age_days: int | None = None) -> dict[str, Any]:
valuation_date = str(valuation_date or "")[:10]
basis_date = str(basis_date or "")[:10]
calculated_age_days = None
if valuation_date and basis_date:
try:
calculated_age_days = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days
except ValueError:
pass
if age_days is None:
age_days = calculated_age_days
return {"valuation_date": valuation_date, "age_days": age_days, "carried": bool(carried), "carried_forward": bool(carried),
"verification_status": verification_status, "evidence_kind": evidence_kind,
"warnings": warnings or [], "exclusions": exclusions or [], "snapshot_run_id": snapshot_run_id,
"monthly_source_snapshot_id": snapshot_run_id,
"close_status": close_status, "selection_policy": selection_policy,
"approval": approval or {}, "correction": correction or {}}
def _public_evidence(item: dict[str, Any], strict: bool = False) -> dict[str, Any]:
"""Return evidence metadata using only the canonical public shape."""
allowed = {
"id", "source_id", "source_type", "authority_source", "valuation_source", "name", "type", "amount", "currency",
"liquidity_bucket", "valuation_date", "basis_date", "age_days", "carried", "carried_forward", "verification_status", "evidence_kind",
"valuation_id", "valuation_kind", "active", "last_synced_at", "last_balance", "valuation_amount", "reason",
"unverified_candidates", "candidate", "candidates", "state_hash", "blocking", "snapshot_run_id", "is_correction",
"correction_of_run_id", "correction_reason",
}
if isinstance(item, dict):
return {key: _public_evidence(value, strict) for key, value in item.items() if key in allowed or (not strict and isinstance(value, (dict, list)))}
if isinstance(item, list):
return [_public_evidence(value, strict) for value in item]
return item
def _evidence_provenance(evidence: list[dict[str, Any]], basis_date: str) -> dict[str, Any]:
values = [item for item in evidence if item]
dates = {str(item.get("valuation_date") or "")[:10] for item in values if item.get("valuation_date")}
ages = {item.get("age_days") for item in values if item.get("age_days") is not None}
carried = {bool(item.get("carried_forward")) for item in values}
statuses = {str(item.get("verification_status") or "") for item in values}
kinds = {str(item.get("evidence_kind") or "") for item in values}
return {
"valuation_date": next(iter(dates), "") if len(dates) == 1 else "mixed" if dates else "",
"age_days": next(iter(ages), None) if len(ages) == 1 else None,
"carried_forward": next(iter(carried), False) if len(carried) == 1 else bool(any(carried)),
"verification_status": next(iter(statuses), "") if len(statuses) == 1 else "mixed" if statuses else "",
"evidence_kind": next(iter(kinds), "") if len(kinds) == 1 else "mixed" if kinds else "",
}
def _evidence_state_hash(item: dict[str, Any]) -> str:
"""Bind idempotency to evidence state without exposing sensitive text."""
fields = (
"source_type", "source_id", "name", "amount", "currency",
"liquidity_bucket", "authority_source", "valuation_id",
"valuation_date", "verification_status", "source_note", "memo",
"active", "carried_forward",
)
payload = {key: item.get(key) for key in fields}
return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
).hexdigest()
def _valuation_row_state_hash(row: sqlite3.Row) -> str:
"""Bind redacted candidate evidence to its complete immutable row."""
return hashlib.sha256(
json.dumps(row_to_dict(row), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
).hexdigest()
def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
included: list[dict[str, Any]] = []
excluded: list[dict[str, Any]] = []
invalid: list[dict[str, Any]] = []
for row in rows:
account = row_to_dict(row)
account_type = str(account.get("type") or "other").lower()
raw_bucket = str(account.get("liquidity_bucket") or "other")
try:
bucket = normalize_liquidity_bucket(raw_bucket)
except ValueError:
bucket = "other"
invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
if account_type == "loan":
bucket = "loan"
base = {
"id": account.get("id", ""),
"source_id": account.get("id", ""),
"source_type": "account",
"authority_source": "accounts.last_balance",
"name": account.get("name", ""),
"type": account.get("type", ""),
"currency": (account.get("currency") or "KRW").upper(),
"liquidity_bucket": bucket,
"valuation_date": account.get("last_balance_basis_date", ""),
"last_synced_at": account.get("last_synced_at", ""),
"source_note": account.get("memo", ""),
"memo": account.get("memo", ""),
"evidence_kind": "account_balance",
}
if base["currency"] != "KRW":
excluded.append({**base, "reason": "non_krw_without_fx"})
continue
balance = account.get("last_balance")
if basis_date:
valuation_rows = conn.execute(
"SELECT * FROM account_valuations WHERE account_id=? AND UPPER(currency)=? ORDER BY basis_date DESC, recorded_at DESC, id DESC",
(account["id"], base["currency"]),
).fetchall()
leaves = [v for v in valuation_rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (v["id"],)).fetchone() is None]
if account_type == "investment":
# Older imports recorded investment balances as cash_balance;
# keep that as a compatibility fallback after the canonical
# total_value/balance purposes.
purposes = ("total_value", "balance", "cash_balance")
elif account_type == "loan":
purposes = ("loan_balance", "loan", "balance")
else:
purposes = ("cash_balance", "balance")
selected = None
selected_purpose = None
candidate_rows = []
for purpose in purposes:
scoped = [v for v in leaves if v["valuation_kind"] == purpose]
if selection_policy == "exact":
verified = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] == basis_date]
else:
eligible = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] <= basis_date]
latest = max((v["basis_date"] for v in eligible), default="")
verified = [v for v in eligible if v["basis_date"] == latest]
if len(verified) > 1:
invalid.append({**base, "reason": "ambiguous_valuation", "valuation_kind": purpose})
verified = []
break
if verified:
selected, selected_purpose = verified[0], purpose
break
candidate_rows.extend(v for v in scoped if v["verification_status"] == "unverified" and v["basis_date"] <= basis_date and (v["basis_date"] == basis_date if selection_policy == "exact" else True))
if selected is None:
excluded.append({**base, "reason": "missing_verified_exact_valuation" if selection_policy == "exact" else "missing_verified_valuation", "unverified_candidates": [_public_candidate(v) for v in candidate_rows]})
continue
valuation = selected
candidate_rows.extend(
v for v in leaves
if v["valuation_kind"] == selected_purpose
and v["verification_status"] == "unverified"
and v["basis_date"] <= basis_date
and (v["basis_date"] == basis_date if selection_policy == "exact" else True)
)
if account_type == "loan" and not valuation["representative_liability_definition"]:
excluded.append({
**base,
"reason": "missing_representative_liability_definition",
"valuation_id": valuation["id"],
"valuation_kind": selected_purpose,
"valuation_date": valuation["basis_date"],
"verification_status": valuation["verification_status"],
})
continue
balance = valuation["amount"]
base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date, "evidence_kind": "account_valuation", "age_days": (date.fromisoformat(basis_date) - date.fromisoformat(str(valuation["basis_date"])[:10])).days})
if candidate_rows:
base["unverified_candidates"] = [_public_candidate(v) for v in candidate_rows if v["basis_date"] <= basis_date]
if balance is None:
excluded.append({**base, "reason": "missing_last_balance"})
continue
amount = int(balance)
included.append({**base, "amount": amount, "last_balance": amount})
return included, excluded, invalid
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
where = "WHERE active=1" if active_only else ""
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
included: list[dict[str, Any]] = []
excluded: list[dict[str, Any]] = []
for row in rows:
asset = row_to_dict(row)
if (asset.get("currency") or "KRW").upper() != "KRW":
excluded.append({"id": asset.get("id", ""), "source_id": asset.get("id", ""), "source_type": "manual_asset", "reason": "non_krw_without_fx", "currency": asset.get("currency")})
continue
try:
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
except ValueError:
bucket = "other"
base = {
"id": asset.get("id", ""),
"source_id": asset.get("id", ""),
"source_type": "manual_asset",
"authority_source": "manual_assets",
"name": asset.get("name", ""),
"currency": str(asset.get("currency") or "KRW").upper(),
"liquidity_bucket": bucket,
"valuation_date": asset.get("valuation_date", ""),
"source_note": asset.get("source_note", ""),
"memo": asset.get("memo", ""),
"active": asset.get("active", True),
"verification_status": "verified",
"evidence_kind": "manual_confirmed",
}
value = asset.get("valuation_amount")
if basis_date:
valuation_date = str(asset.get("valuation_date") or "")[:10]
if not valuation_date or valuation_date > basis_date or (selection_policy == "exact" and valuation_date != basis_date):
excluded.append({**base, "reason": "missing_exact_manual_asset_valuation" if selection_policy == "exact" else "future_or_unknown_manual_asset_valuation"})
continue
base["carried_forward"] = valuation_date != basis_date
base["age_days"] = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days
if value is None:
excluded.append({**base, "reason": "missing_valuation_amount"})
continue
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
return included, excluded
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
"""Load one immutable close run and only the rows persisted by that run."""
run = conn.execute(
"SELECT rowid AS _run_rowid, * FROM asset_snapshot_runs WHERE id=?", (run_id,)
).fetchone()
if not run:
return [], {}
basis_date = run["basis_date"]
rows = conn.execute(
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? AND snapshot_run_id=? ORDER BY created_at ASC, name ASC",
(basis_date, run["id"]),
).fetchall()
total_rows = conn.execute(
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
).fetchone()[0]
previous_rows = conn.execute(
"""SELECT COUNT(*)
FROM asset_snapshots AS snapshot
LEFT JOIN asset_snapshot_runs AS snapshot_run
ON snapshot_run.id=snapshot.snapshot_run_id
WHERE snapshot.basis_date=?
AND (
snapshot.snapshot_run_id IS NULL
OR snapshot.snapshot_run_id=''
OR snapshot_run.rowid < ?
)""",
(basis_date, run["_run_rowid"]),
).fetchone()[0]
metadata = {
"snapshot_run_id": run["id"],
"is_correction": bool(run["correction_of_run_id"]),
"correction_of_run_id": run["correction_of_run_id"] or "",
"correction_reason": run["correction_reason"] or "",
"close_status": run["close_status"],
"selection_policy": run["selection_policy"],
"approved_by": run["approved_by"],
"approved_at": run["approved_at"],
"approval_reason": run["approval_reason"],
"approval_reference": run["approval_reference"],
"applied_evidence": json.loads(run["applied_evidence"] or "[]"),
"excluded_evidence": json.loads(run["excluded_evidence"] or "[]"),
"carried_evidence": json.loads(run["carried_evidence"] or "[]"),
"closed_at": run["created_at"] or "",
"previous_snapshot_row_count": previous_rows,
"total_snapshot_rows_for_basis_date": total_rows,
}
return rows, metadata
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall()
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone()
if not run:
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} if rows else {}
return _snapshot_rows_for_run(conn, run["id"])
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
rows, metadata = _snapshot_rows_for_latest_run(conn, basis_date)
evidence_by_key = {}
for evidence in metadata.get("applied_evidence", []):
key = (evidence.get("source_type", ""), evidence.get("source_id", ""), evidence.get("valuation_id", ""))
evidence_by_key[key] = evidence
items: list[dict[str, Any]] = []
for row in rows:
snap = row_to_dict(row)
try:
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
except ValueError:
bucket = "other"
evidence = evidence_by_key.get(
(snap.get("source_type", ""), snap.get("source_id", ""), snap.get("valuation_id", "")),
evidence_by_key.get((snap.get("source_type", ""), snap.get("source_id", ""), ""), {}),
)
valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "")
carried_forward = bool(evidence.get("carried_forward", False))
public_item = _public_evidence({
"id": snap.get("id", ""),
"source_id": snap.get("source_id", ""),
"source_type": snap.get("source_type", ""),
"authority_source": "asset_snapshots",
"valuation_source": snap.get("valuation_source", ""),
"name": snap.get("name", ""),
"currency": snap.get("currency", ""),
"valuation_id": snap.get("valuation_id", ""),
"liquidity_bucket": bucket,
"amount": int(snap["amount"]) if snap.get("amount") is not None else None,
"valuation_date": valuation_date,
"age_days": evidence.get("age_days"),
"carried_forward": carried_forward,
"verification_status": evidence.get("verification_status") or snap.get("verification_status", ""),
"evidence_kind": evidence.get("evidence_kind") or snap.get("evidence_kind", ""),
"source_note": snap.get("source_note", ""),
"memo": snap.get("memo", ""),
"snapshot_run_id": snap.get("snapshot_run_id", ""),
"is_correction": bool(snap.get("is_correction", False)),
"correction_of_run_id": snap.get("correction_of_run_id", ""),
"correction_reason": snap.get("correction_reason", ""),
})
if not snap.get("valuation_id"):
public_item.pop("valuation_id", None)
items.append(public_item)
return items
def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"""Expose immutable run evidence without source notes or memos."""
warnings = []
exclusions = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])]
candidate_warnings: dict[tuple[str, str], dict[str, Any]] = {}
for item in exclusions:
if item.get("candidate"):
key = (item.get("source_type", ""), item.get("source_id", ""))
warning = candidate_warnings.setdefault(key, {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []})
warning["candidates"].append(item["candidate"])
else:
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
warnings.extend(candidate_warnings.values())
return {
"warnings": warnings,
"excluded_items": exclusions,
"carried_items": [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])],
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"close_status": metadata.get("close_status", ""),
"selection_policy": metadata.get("selection_policy", ""),
"approval": {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")},
"correction": {k: metadata.get(k, "") for k in ("is_correction", "correction_of_run_id", "correction_reason")},
}
def _preview_fingerprint(preview: dict[str, Any], basis: str, selection_policy: str) -> str:
"""Opaque authority-state token shared by preview and close."""
included = preview.get("_included_items", preview.get("included_items", []))
payload = {
"basis_date": basis,
"selection_policy": selection_policy,
"included": [{**{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(item)} for item in included],
"excluded": [{**{k: item.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(item)} for item in preview.get("excluded_items", [])],
"warnings": [warning for warning in preview.get("warnings", []) if warning.get("code") != "existing_snapshot_rows"],
"candidate_state_hashes": preview.get("_candidate_state_hashes", []),
}
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
def _provenance_aliases(provenance: dict[str, Any], snapshot_run_id: str = "") -> dict[str, Any]:
return {key: provenance.get(key) for key in ("valuation_date", "age_days", "verification_status", "evidence_kind")} | {
"monthly_source_snapshot_id": provenance.get("monthly_source_snapshot_id") or snapshot_run_id,
}
def _ok_with_provenance_aliases(**payload: Any) -> dict[str, Any]:
result = ok(**payload)
provenance = result.get("provenance")
if isinstance(provenance, dict):
result.update(_provenance_aliases(provenance, result.get("snapshot_run_id", "")))
return result
def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
warnings = _closed_run_metadata(metadata)["warnings"]
row_by_key = {(row["source_type"], row["source_id"]): row_to_dict(row) for row in rows}
for evidence in metadata.get("applied_evidence", []):
if not evidence.get("carried_forward"):
continue
row = row_by_key.get((evidence.get("source_type", ""), evidence.get("source_id", "")), {})
valuation_date = str(evidence.get("valuation_date") or "")[:10]
if valuation_date and valuation_date < basis_date:
warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date})
previous_rows = int(metadata.get("previous_snapshot_row_count") or 0)
if previous_rows:
warnings.append({
"code": "existing_snapshot_rows",
"basis_date": basis_date,
"snapshot_row_count": previous_rows,
"policy": "append_only_correction_required",
})
return warnings
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
for item in included_items:
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
raw_amount = item.get("amount")
if raw_amount is None:
continue
amount = int(raw_amount)
bucket_counts[bucket] += 1
bucket_totals[bucket] += amount
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS)
net_worth = total_assets - total_liabilities
buckets = []
for bucket in ASSET_BUCKET_ORDER:
amount = bucket_totals[bucket]
denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets
buckets.append(
{
"liquidity_bucket": bucket,
"amount": amount,
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
"manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
"item_count": bucket_counts[bucket],
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
"share": round(abs(amount) / denominator, 4) if denominator else None,
}
)
return {
"buckets": buckets,
"bucket_totals": bucket_totals,
"total_assets": total_assets,
"total_liabilities": total_liabilities,
"net_worth": net_worth,
"included_items": included_items,
"excluded_items": excluded_items or [],
"valuation_policy": _valuation_policy(),
}
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
if not name.strip():
return fail("validation_error", "manual asset name is required")
try:
bucket = normalize_liquidity_bucket(liquidity_bucket)
amount = int(valuation_amount) if valuation_amount is not None else None
vdate = normalize_date(valuation_date, default_today=True)
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
asset_id = new_id("asset")
with db(db_path) as conn:
conn.execute(
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
)
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
@read_operation
def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "WHERE active=1" if active_only else ""
with read_db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
allowed_updates: list[str] = []
params: list[Any] = []
try:
if name is not None:
if not name.strip():
return fail("validation_error", "manual asset name is required")
allowed_updates.append("name=?")
params.append(name.strip())
if valuation_amount is not None:
allowed_updates.append("valuation_amount=?")
params.append(int(valuation_amount))
if valuation_date is not None:
allowed_updates.append("valuation_date=?")
params.append(normalize_date(valuation_date, default_today=True))
if liquidity_bucket is not None:
allowed_updates.append("liquidity_bucket=?")
params.append(normalize_liquidity_bucket(liquidity_bucket))
if source_note is not None:
allowed_updates.append("source_note=?")
params.append(source_note)
if memo is not None:
allowed_updates.append("memo=?")
params.append(memo)
if currency is not None:
allowed_updates.append("currency=?")
params.append(currency or "KRW")
if active is not None:
allowed_updates.append("active=?")
params.append(int(bool(active)))
except Exception as e:
return fail("validation_error", str(e))
if not allowed_updates:
return fail("validation_error", "no fields to update")
allowed_updates.append("updated_at=?")
params.extend([now_iso(), asset_id])
with db(db_path) as conn:
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "manual asset not found")
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
if hard_delete:
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
else:
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
if cur.rowcount == 0:
return fail("not_found", "manual asset not found")
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
if row is not None:
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
return ok(**payload)
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_policy: str = "exact") -> dict[str, Any]:
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy)
manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy)
included = account_items + manual_items
excluded = account_excluded + manual_excluded
summary = _summarize_valuation_items(included, excluded)
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
warnings: list[dict[str, Any]] = []
for item in excluded:
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
if item.get("unverified_candidates"):
warnings.append({"code": "unverified_candidate", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "candidates": [{k: candidate.get(k) for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} for candidate in item["unverified_candidates"]]})
for item in included:
if item.get("unverified_candidates"):
warnings.append({"code": "unverified_candidate", "blocking": False, "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "candidates": [{k: candidate.get(k) for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} for candidate in item["unverified_candidates"]]})
item_date = str(item.get("valuation_date") or "")[:10]
if item_date and item_date < basis:
warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis})
for item in invalid_bucket_accounts:
warnings.append({"code": "invalid_liquidity_bucket", **item})
if existing_rows:
warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"})
candidate_ids = sorted({
candidate.get("id", "")
for warning in warnings
if warning.get("code") == "unverified_candidate"
for candidate in warning.get("candidates", [])
if candidate.get("id")
})
candidate_state_hashes = []
for candidate_id in candidate_ids:
candidate_row = conn.execute(
"SELECT * FROM account_valuations WHERE id=?", (candidate_id,),
).fetchone()
if candidate_row is not None:
candidate_state_hashes.append({
"id": candidate_id,
"state_hash": _valuation_row_state_hash(candidate_row),
})
public_excluded = [_public_evidence(item) for item in excluded]
return {
"basis_date": basis,
"source": "live_authority",
"provenance": _canonical_provenance(basis_date=basis, warnings=warnings, exclusions=public_excluded, selection_policy=selection_policy),
"is_closed_snapshot": False,
"snapshot_count": 0,
"existing_snapshot_row_count": int(existing_rows),
"would_be_correction": bool(existing_rows),
"warnings": warnings,
"_candidate_state_hashes": candidate_state_hashes,
"_included_items": included,
**{**summary, "included_items": [_public_evidence(item) for item in included], "excluded_items": public_excluded},
}
@read_operation
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]:
if selection_policy not in {"exact", "as_of"}:
return fail("validation_error", "selection_policy must be exact or as_of")
try:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
except Exception as e:
return fail("validation_error", str(e))
with read_db(db_path) as conn:
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
preview["selection_policy"] = selection_policy
approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
other_blocking = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith("stale_") and not carried_items]
if other_blocking:
preview["close_status"] = "incomplete_draft"
elif approval_eligible or carried_items:
preview["close_status"] = "incomplete_draft"
else:
preview["close_status"] = "complete_exact"
# Fingerprint the private authority collection before redacting it from
# the public preview. Close performs the same calculation.
preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy)
preview.pop("_included_items", None)
preview.pop("_candidate_state_hashes", None)
return ok(**preview)
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "", preview_fingerprint: str = "") -> dict[str, Any]:
if selection_policy not in {"exact", "as_of"}:
return fail("validation_error", "selection_policy must be exact or as_of")
try:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
run_id = new_id("snaprun")
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy):
return fail("authority_drift", "asset authority changed since preview", basis_date=basis)
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id"))
if legacy_snapshot_rows and not correction_reason.strip():
return fail(
"legacy_snapshot_correction_required",
"legacy snapshot rows require an explicit correction reason before close",
basis_date=basis,
existing_snapshot_row_count=preview["existing_snapshot_row_count"],
)
missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
conditional = bool(missing_exclusions or carried_items)
authority_fingerprint = _preview_fingerprint(preview, basis, selection_policy)
request_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference}
request_fingerprint = hashlib.sha256(json.dumps(request_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
fingerprint = json.dumps({"authority": authority_fingerprint, "request": request_fingerprint}, sort_keys=True, separators=(",", ":"))
if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions")
if carried_items and (not allow_as_of_carry_forward or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
return fail("conditional_approval_required", "conditional close requires explicit approval metadata and flags")
if idempotency_key:
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
if existing_run:
try:
stored_fingerprint = json.loads(existing_run["request_fingerprint"])
except (TypeError, json.JSONDecodeError):
stored_fingerprint = {"authority": existing_run["request_fingerprint"], "request": ""}
latest_basis_run = conn.execute(
"SELECT correction_of_run_id FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1",
(basis,),
).fetchone()
authority_changed_after_correction = bool(
latest_basis_run
and latest_basis_run["correction_of_run_id"] == existing_run["id"]
)
if stored_fingerprint.get("request") == request_fingerprint and (
stored_fingerprint.get("authority") == authority_fingerprint
or authority_changed_after_correction
):
replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"])
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])]
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
replay_public = _closed_run_metadata(replay_meta)
replay_public["warnings"] = replay_warnings
replay_summary = _summarize_valuation_items(replay_items, replay_public["excluded_items"])
replay_provenance = _evidence_provenance(replay_meta.get("applied_evidence", []), basis)
approval = replay_public["approval"]
correction = replay_public["correction"]
return _ok_with_provenance_aliases(
basis_date=basis, source="snapshot", is_closed_snapshot=True,
snapshot_run_id=existing_run["id"],
correction_of_run_id=correction["correction_of_run_id"],
correction_reason=correction["correction_reason"],
is_correction=correction["is_correction"],
closed_at=existing_run["created_at"],
close_status=existing_run["close_status"],
selection_policy=existing_run["selection_policy"],
inserted_count=len(replay_rows),
inserted_snapshot_ids=[
next((row["id"] for row in replay_rows if row["source_type"] == item.get("source_type") and row["source_id"] == item.get("source_id")), "")
for item in replay_meta.get("applied_evidence", [])
],
warnings=replay_warnings,
carried_items=replay_public["carried_items"],
approval=approval, correction=correction,
provenance=_canonical_provenance(
valuation_date=(replay_items[0].get("valuation_date", "") if replay_items else ""),
age_days=(replay_items[0].get("age_days") if replay_items else None),
carried=replay_provenance["carried_forward"],
verification_status=replay_provenance["verification_status"],
evidence_kind=replay_provenance["evidence_kind"],
basis_date=basis, snapshot_run_id=existing_run["id"],
close_status=existing_run["close_status"],
selection_policy=existing_run["selection_policy"],
warnings=replay_warnings,
exclusions=replay_public["excluded_items"],
approval=approval, correction=correction,
),
**replay_summary,
)
return fail("idempotency_conflict", "snapshot close idempotency payload differs")
included = preview.pop("_included_items", preview["included_items"])
# A conditional close may legitimately contain zero snapshot rows.
# The run record, not row count, is the authoritative close marker.
is_correction = bool(latest_meta.get("snapshot_run_id") or legacy_snapshot_rows)
if is_correction and not correction_reason.strip():
return fail("validation_error", "correction_reason is required when closing a basis_date that already has a snapshot run", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"])
correction_of_run_id = latest_meta.get("snapshot_run_id", "")
snapshot_ids: list[str] = []
for item in included:
snapshot_id = new_id("snap")
row_note = source_note or item.get("source_note", "") or "month-end close"
row_memo = memo or item.get("memo", "")
conn.execute(
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
snapshot_id,
basis,
item.get("source_type", ""),
item.get("source_id", item.get("id", "")),
item.get("name", ""),
int(item["amount"]) if item.get("amount") is not None else None,
item.get("currency", "KRW"),
normalize_liquidity_bucket(item.get("liquidity_bucket")),
item.get("authority_source", item.get("source_type", "")),
row_note,
row_memo,
run_id,
correction_of_run_id,
correction_reason.strip(),
int(is_correction),
ts,
item.get("valuation_id", ""),
selection_policy,
item.get("verification_status", "unverified"),
"carried_forward" if item.get("carried_forward") else "applied",
"",
ts,
),
)
snapshot_ids.append(snapshot_id)
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") if item.get(k) is not None} for item in included]
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
excluded_evidence.extend(
{
"source_type": warning.get("source_type", ""),
"source_id": warning.get("source_id", ""),
"reason": "unverified_candidate",
"candidate": candidate,
"state_hash": next(
state["state_hash"]
for state in preview["_candidate_state_hashes"]
if state["id"] == candidate.get("id")
),
}
for warning in preview["warnings"]
if warning.get("code") == "unverified_candidate"
for candidate in warning.get("candidates", [])
)
carried_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind")} for item in included if item.get("carried_forward")]
summary = _summarize_valuation_items([_public_evidence(item) for item in included], excluded_evidence)
try:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (run_id, basis, "conditional" if conditional else "complete_exact", selection_policy, idempotency_key, approved_by, ts if approved_by else "", approval_reason, approval_reference, correction_of_run_id, correction_reason.strip(), json.dumps(applied_evidence, ensure_ascii=False, sort_keys=True), json.dumps(excluded_evidence, ensure_ascii=False, sort_keys=True), json.dumps(carried_evidence, ensure_ascii=False, sort_keys=True), fingerprint, ts))
except sqlite3.IntegrityError:
conn.rollback()
return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis)
approval = {
"approved_by": approved_by,
"approved_at": ts if approved_by else "",
"approval_reason": approval_reason,
"approval_reference": approval_reference,
}
correction = {
"is_correction": is_correction,
"correction_of_run_id": correction_of_run_id,
"correction_reason": correction_reason.strip(),
}
verification_statuses = {str(item.get("verification_status") or "") for item in included}
evidence_kinds = {str(item.get("evidence_kind") or "") for item in included}
return _ok_with_provenance_aliases(
basis_date=basis,
source="snapshot",
is_closed_snapshot=True,
snapshot_run_id=run_id,
correction_of_run_id=correction_of_run_id,
correction_reason=correction_reason.strip(),
is_correction=is_correction,
closed_at=ts,
close_status="conditional" if conditional else "complete_exact",
selection_policy=selection_policy,
inserted_count=len(snapshot_ids),
inserted_snapshot_ids=snapshot_ids,
warnings=preview["warnings"],
carried_items=carried_evidence,
approval=approval,
correction=correction,
provenance=_canonical_provenance(
valuation_date=(preview["included_items"][0].get("valuation_date", "") if preview["included_items"] else ""),
basis_date=basis,
carried=bool(carried_evidence),
verification_status=(verification_statuses.pop() if len(verification_statuses) == 1 else "mixed" if verification_statuses else ""),
evidence_kind=(evidence_kinds.pop() if len(evidence_kinds) == 1 else "mixed" if evidence_kinds else ""),
snapshot_run_id=run_id,
close_status="conditional" if conditional else "complete_exact",
selection_policy=selection_policy,
warnings=preview["warnings"],
exclusions=excluded_evidence,
approval=approval,
correction=correction,
),
**summary,
)
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
if source_type not in {"account", "manual_asset"}:
return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
if not name.strip():
return fail("validation_error", "snapshot item name is required")
try:
basis = normalize_date(basis_date, default_today=True)
bucket = normalize_liquidity_bucket(liquidity_bucket)
amount_int = int(amount) if amount is not None else None
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
snapshot_id = new_id("snap")
with db(db_path) as conn:
conn.execute(
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
)
row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
return ok(asset_snapshot=row_to_dict(row))
@read_operation
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]:
if selection_policy not in {"exact", "as_of"}:
return fail("validation_error", "selection_policy must be exact or as_of")
try:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
except Exception:
return fail("validation_error", f"invalid date: {basis_date}")
with read_db(db_path) as conn:
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
has_close_run = bool(close_metadata.get("snapshot_run_id"))
snapshots = _snapshot_items(conn, basis) if rows else []
legacy_snapshots = snapshots if rows and not has_close_run else []
if has_close_run:
run_public = _closed_run_metadata(close_metadata)
run_public["warnings"] = _closed_warnings(close_metadata, basis, rows)
summary = _summarize_valuation_items(snapshots, run_public["excluded_items"])
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
return _ok_with_provenance_aliases(
basis_date=basis,
source="asset_snapshots",
is_closed_snapshot=True,
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
snapshot_count=len(snapshots),
close_metadata=run_public,
selection_policy=close_metadata.get("selection_policy", selection_policy),
close_status=close_metadata.get("close_status", "complete_exact"),
warnings=run_public["warnings"],
carried_items=run_public["carried_items"],
approval=run_public["approval"],
correction=run_public["correction"],
provenance=_canonical_provenance(
valuation_date=evidence_provenance["valuation_date"],
age_days=evidence_provenance["age_days"],
carried=evidence_provenance["carried_forward"],
verification_status=evidence_provenance["verification_status"],
evidence_kind=evidence_provenance["evidence_kind"],
basis_date=basis, snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
close_status=close_metadata.get("close_status", "complete_exact"),
selection_policy=close_metadata.get("selection_policy", selection_policy),
warnings=run_public["warnings"], exclusions=run_public["excluded_items"],
approval=run_public["approval"], correction=run_public["correction"],
),
**summary,
)
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
included = preview.get("_included_items", [])
if not included and not preview["excluded_items"] and legacy_snapshots:
public_legacy = [_public_evidence(item) for item in legacy_snapshots]
null_items = [item for item in public_legacy if item.get("amount") is None]
null_excluded = [{**item, "reason": "missing_amount"} for item in null_items]
summary = _summarize_valuation_items(public_legacy, null_excluded)
return _ok_with_provenance_aliases(
basis_date=basis, source="legacy_asset_snapshots", selection_policy=selection_policy,
close_status="incomplete_draft" if null_items else close_metadata.get("close_status", "legacy_read_only"), is_closed_snapshot=False,
provenance=_canonical_provenance(basis_date=basis, snapshot_run_id="", close_status="legacy_read_only", selection_policy=selection_policy),
snapshot_count=len(legacy_snapshots), legacy_snapshot_row_count=len(legacy_snapshots),
close_metadata={"legacy": True, "snapshot_run_id": ""}, **summary,
)
public_included = [_public_evidence(item) for item in included]
public_excluded = preview["excluded_items"]
carried_items = [item for item in public_included if item.get("carried_forward")]
warnings = preview["warnings"]
approval = {}
correction = {}
evidence_provenance = _evidence_provenance(included, basis)
summary = _summarize_valuation_items(public_included, public_excluded)
return _ok_with_provenance_aliases(
basis_date=basis,
source="live_authority",
selection_policy=selection_policy,
close_status=preview["close_status"] if "close_status" in preview else ("incomplete_draft" if (public_excluded or carried_items) else "complete_exact"),
provenance=_canonical_provenance(
valuation_date=evidence_provenance["valuation_date"], age_days=evidence_provenance["age_days"],
carried=evidence_provenance["carried_forward"], verification_status=evidence_provenance["verification_status"],
evidence_kind=evidence_provenance["evidence_kind"], basis_date=basis,
close_status=preview["close_status"] if "close_status" in preview else "",
selection_policy=selection_policy, warnings=warnings, exclusions=public_excluded,
approval=approval, correction=correction,
),
is_closed_snapshot=False,
snapshot_count=0,
legacy_snapshot_row_count=len(legacy_snapshots),
invalid_bucket_accounts=[warning for warning in warnings if warning.get("code") == "invalid_liquidity_bucket"],
warnings=warnings,
carried_items=carried_items,
approval=approval,
correction=correction,
**{**summary, "included_items": public_included, "excluded_items": public_excluded},
)
@read_operation
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
try:
months_int = max(1, min(int(months), 120))
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
if start_date:
start_norm = normalize_date(start_date)
else:
end_dt = date.fromisoformat(end_norm)
start_month = end_dt.month - months_int + 1
year = end_dt.year + (start_month - 1) // 12
month = (start_month - 1) % 12 + 1
start_norm = date(year, month, 1).isoformat()
except Exception as e:
return fail("validation_error", str(e))
with read_db(db_path) as conn:
basis_rows = conn.execute(
"""SELECT basis_date FROM asset_snapshots
WHERE basis_date>=? AND basis_date<=?
UNION
SELECT basis_date FROM asset_snapshot_runs
WHERE basis_date>=? AND basis_date<=?
ORDER BY basis_date ASC""",
(start_norm, end_norm, start_norm, end_norm),
).fetchall()
points = []
for row in basis_rows:
basis = row["basis_date"]
items = _snapshot_items(conn, basis)
_, metadata = _snapshot_rows_for_latest_run(conn, basis)
run_public = _closed_run_metadata(metadata)
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis)
summary = _summarize_valuation_items(items, run_public["excluded_items"])
points.append(
{
"basis_date": basis,
"total_assets": summary["total_assets"],
"total_liabilities": summary["total_liabilities"],
"net_worth": summary["net_worth"],
"source": "asset_snapshots",
"item_count": len(items),
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"close_status": metadata.get("close_status", ""),
"selection_policy": metadata.get("selection_policy", ""),
"warnings": run_public["warnings"],
"excluded_items": run_public["excluded_items"],
"carried_items": run_public["carried_items"],
"approval": run_public["approval"],
"correction": run_public["correction"],
"provenance": _canonical_provenance(
valuation_date=run_provenance["valuation_date"],
age_days=run_provenance["age_days"],
carried=run_provenance["carried_forward"],
verification_status=run_provenance["verification_status"],
evidence_kind=run_provenance["evidence_kind"],
basis_date=basis,
snapshot_run_id=metadata.get("snapshot_run_id", ""),
close_status=metadata.get("close_status", ""),
selection_policy=metadata.get("selection_policy", ""),
warnings=run_public["warnings"],
exclusions=run_public["excluded_items"],
approval=run_public["approval"],
correction=run_public["correction"],
),
}
)
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
@read_operation
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
"""Summarize assets/liabilities from shared valuation authority sources.
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
source remain equivalent to the original accounts.last_balance behavior.
"""
with read_db(db_path) as conn:
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
included = account_items + manual_items
excluded = account_excluded + manual_excluded
summary = _summarize_valuation_items(included, excluded)
source_accounts = [item for item in included if item.get("source_type") == "account"]
source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
return ok(
buckets=summary["buckets"],
accounts=source_accounts,
manual_assets=manual_items,
total_assets=summary["total_assets"],
total_liabilities=summary["total_liabilities"],
net_worth=summary["net_worth"],
missing_balance_accounts=[
{"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")}
for item in account_excluded
],
missing_valuation_items=excluded,
invalid_bucket_accounts=invalid_bucket_accounts,
manual_asset_count=len(manual_items),
included_items=included,
excluded_items=excluded,
valuation_policy=summary["valuation_policy"],
source=source,
)