feat: implement asset snapshot and manual asset read model

This commit is contained in:
Jarca
2026-06-30 22:04:52 +09:00
parent 7fd1b45bd5
commit beddff7661
20 changed files with 1232 additions and 61 deletions
+272 -41
View File
@@ -21,6 +21,9 @@ VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustmen
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"}
VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"}
ASSET_BUCKETS = ["cash", "investment", "deposit", "real_estate", "other"]
LIABILITY_BUCKETS = ["loan"]
ASSET_BUCKET_ORDER = [*ASSET_BUCKETS, *LIABILITY_BUCKETS]
RECONCILIATION_TARGET_STATUSES = {"manual_only", "partial_import"}
BALANCE_RECONCILIATION_TOLERANCE_KRW = 1_000
BALANCE_RECONCILIATION_REFERENCE_RATIO = 0.001
@@ -223,7 +226,7 @@ def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
counts: dict[str, int] = {}
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories"):
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots"):
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
if exists:
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
@@ -374,73 +377,301 @@ def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -
return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows))
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
"""Summarize assets/liabilities from account-level liquidity_bucket and last_balance only."""
with db(db_path) as conn:
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
accounts = [row_to_dict(r) for r in rows]
bucket_order = ["cash", "investment", "deposit", "real_estate", "other", "loan"]
bucket_totals = {bucket: 0 for bucket in bucket_order}
bucket_counts = {bucket: 0 for bucket in bucket_order}
missing_balance_accounts: list[dict[str, Any]] = []
invalid_bucket_accounts: list[dict[str, Any]] = []
source_accounts: list[dict[str, Any]] = []
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",
}
for account in accounts:
def _account_valuation_items(conn: sqlite3.Connection) -> 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)
raw_bucket = str(account.get("liquidity_bucket") or "other")
try:
bucket = normalize_liquidity_bucket(raw_bucket)
except ValueError:
bucket = "other"
invalid_bucket_accounts.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
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", ""),
"liquidity_bucket": bucket,
"valuation_date": account.get("last_synced_at", ""),
"last_synced_at": account.get("last_synced_at", ""),
"source_note": account.get("memo", ""),
"memo": account.get("memo", ""),
}
balance = account.get("last_balance")
if balance is None:
missing_balance_accounts.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": bucket})
excluded.append({**base, "reason": "missing_last_balance"})
continue
amount = int(balance)
bucket_counts[bucket] += 1
bucket_totals[bucket] += amount
source_accounts.append(
included.append({**base, "amount": amount, "last_balance": amount})
return included, excluded, invalid
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> 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)
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", ""),
"liquidity_bucket": bucket,
"valuation_date": asset.get("valuation_date", ""),
"source_note": asset.get("source_note", ""),
"memo": asset.get("memo", ""),
"active": asset.get("active", True),
}
value = asset.get("valuation_amount")
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_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
rows = conn.execute(
"SELECT * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC",
(basis_date,),
).fetchall()
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"
items.append(
{
"id": account.get("id", ""),
"name": account.get("name", ""),
"type": account.get("type", ""),
"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", ""),
"liquidity_bucket": bucket,
"last_balance": amount,
"last_synced_at": account.get("last_synced_at", ""),
"memo": account.get("memo", ""),
"amount": int(snap.get("amount") or 0),
"valuation_date": snap.get("basis_date", ""),
"source_note": snap.get("source_note", ""),
"memo": snap.get("memo", ""),
}
)
return items
asset_buckets = ["cash", "investment", "deposit", "real_estate", "other"]
total_assets = sum(bucket_totals[bucket] for bucket in asset_buckets)
total_liabilities = abs(bucket_totals["loan"])
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"))
amount = int(item.get("amount") or 0)
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 bucket_order:
for bucket in ASSET_BUCKET_ORDER:
amount = bucket_totals[bucket]
denominator = total_liabilities if bucket == "loan" else total_assets
denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets
buckets.append(
{
"liquidity_bucket": bucket,
"amount": amount,
"account_count": bucket_counts[bucket],
"asset_share": round(amount / total_assets, 4) if bucket != "loan" and total_assets else None,
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket == "loan" and total_liabilities else None,
"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"})
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 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 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))
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
with db(db_path) as conn:
snapshots = _snapshot_items(conn, basis)
if snapshots:
summary = _summarize_valuation_items(snapshots, [])
return ok(
basis_date=basis,
source="asset_snapshots",
snapshot_count=len(snapshots),
**summary,
)
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)
return ok(
buckets=buckets,
accounts=source_accounts,
total_assets=total_assets,
total_liabilities=total_liabilities,
net_worth=net_worth,
missing_balance_accounts=missing_balance_accounts,
basis_date=basis,
source="live_authority",
snapshot_count=0,
invalid_bucket_accounts=invalid_bucket_accounts,
source="accounts.last_balance",
**summary,
)
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 db(db_path) as conn:
basis_rows = conn.execute(
"""SELECT DISTINCT basis_date FROM asset_snapshots
WHERE basis_date>=? AND basis_date<=?
ORDER BY basis_date ASC""",
(start_norm, end_norm),
).fetchall()
points = []
for row in basis_rows:
basis = row["basis_date"]
items = _snapshot_items(conn, basis)
summary = _summarize_valuation_items(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),
}
)
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
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 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,
)