0.2.43: historical NAV backfill without touching current holdings

The batch history import now also records each quarter's NAV in the
fund's valuation history: the old file's HLD rows are matched by issuer
and security name against the book as it exists today, matched rows
write that quarter's valuations, unmatched rows are counted and
reported, and nothing outside the round is created or modified. A
manually signed quarter is never overwritten.

The single-file wizard automatically takes the same history-only path
when the file is older than the fund's newest round. Previously that
import would regress position cost basis to the old file's values and
resurrect since-exited positions, corrupting the fund's Invested total.
This commit is contained in:
Jonathan Kirkwood
2026-08-11 12:32:57 -05:00
parent ae967494bd
commit ebafcf19d9
11 changed files with 376 additions and 10 deletions
@@ -25,7 +25,9 @@ from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
)
from ten31portal.routers.import_router import _open_workbook, _enav_as_of
from ten31portal.routers.import_router import (
_open_workbook, _enav_as_of, _parse_schedule_xlsx, upsert_history_round,
)
from ten31portal.schemas import (
BatchCapitalFileResult, BatchCapitalImportResult,
CapitalImportCommit, CapitalImportPreview, ImportInvestorPreview, ImportValueRow,
@@ -433,7 +435,8 @@ def batch_import(
fname = upload.filename or "(unnamed)"
res = BatchCapitalFileResult(filename=fname)
try:
wb = _open_workbook(storage.read_capped(upload), password)
file_bytes = storage.read_capped(upload)
wb = _open_workbook(file_bytes, password)
if "ALLOC SI" not in wb.sheetnames:
raise HTTPException(status_code=422, detail="No ALLOC SI tab found in this workbook.")
as_of, roster = _parse_alloc_si(wb)
@@ -488,11 +491,31 @@ def batch_import(
res.matched += 1
res.statements_written += 1
# NAV history leg: record this quarter's valuation round from the file's HLD
# sheet, matched against today's book only (holdings are never modified). An
# HLD problem must not lose the member statements above, so it runs in a
# savepoint and reports per-file.
try:
with session.begin_nested():
_, _, _, positions_prev, _ = _parse_schedule_xlsx(file_bytes, password)
if positions_prev:
hist = upsert_history_round(entity_id, as_of, positions_prev, admin, session)
res.nav_status = hist["status"]
res.nav_matched = hist["matched"]
res.nav_unmatched = hist["unmatched"]
res.nav_cents = hist["nav_cents"]
else:
res.nav_status = "no-hld"
except Exception: # noqa: BLE001 — the savepoint rolled back; members still land
res.nav_status = "error"
record_audit(session, admin.id, "import_batch", "capital_account", entity_id, {
"file": fname,
"as_of_date": str(as_of),
"matched": res.matched,
"skipped": len(res.skipped),
"nav_status": res.nav_status,
"nav_cents": res.nav_cents,
})
session.commit()
total_statements += res.statements_written
@@ -331,6 +331,101 @@ def dedupe_entity(entity_id: int, session: Session) -> dict[str, int]:
return {"removed_holdings": removed_holdings, "removed_positions": removed_positions}
def upsert_history_round(
entity_id: int, as_of: date, positions_preview: list[dict], user: User, session: Session,
) -> dict[str, Any]:
"""Record a past quarter's NAV as a valuation round WITHOUT touching current holdings.
Backfilling an old eNAV through the normal import would regress each position's cost
basis to the old file's values and resurrect positions the fund has since exited. This
path instead matches the file's rows by issuer + security name against the book as it
exists today and only writes that quarter's valuations: matched rows contribute to the
quarter's NAV, unmatched rows are counted and reported, nothing is created or modified
outside the round. A manually signed round for the quarter is left untouched.
"""
existing = session.exec(
select(ValuationRound).where(
ValuationRound.entity_id == entity_id,
ValuationRound.quarter_end == as_of,
)
).first()
if existing is not None and not existing.is_seed:
return {"status": "kept-signed", "matched": 0, "unmatched": 0, "nav_cents": 0}
# Match first, so a file with no recognizable rows never leaves an empty $0 round.
matches: list[tuple[Position, int]] = []
unmatched = 0
for pp in positions_preview:
holding = session.exec(
select(Holding).where(
Holding.entity_id == entity_id,
Holding.company_name == pp["company_name"],
)
).first()
pos = None
if holding is not None:
pos = session.exec(
select(Position).where(
Position.holding_id == holding.id,
Position.security_name == pp["security_name"],
)
).first()
if pos is None:
unmatched += 1
else:
matches.append((pos, pp["value_cents"] or 0))
if not matches:
return {"status": "no-match", "matched": 0, "unmatched": unmatched, "nav_cents": 0}
round = existing
if round is None:
round = ValuationRound(
entity_id=entity_id,
quarter_end=as_of,
status=RoundStatus.approved,
is_seed=True,
approved_by=user.id,
approved_at=datetime.utcnow(),
)
session.add(round)
session.flush()
nav_cents = 0
seen_position_ids: set[int] = set()
for pos, value_cents in matches:
val = session.exec(
select(Valuation).where(
Valuation.round_id == round.id,
Valuation.position_id == pos.id,
)
).first()
if val is None:
session.add(Valuation(round_id=round.id, position_id=pos.id, value_cents=value_cents))
else:
val.value_cents = value_cents
session.add(val)
nav_cents += value_cents
seen_position_ids.add(pos.id)
# Re-import of the same quarter: drop valuations for rows no longer in the file.
if existing is not None:
stale_q = select(Valuation).where(
Valuation.round_id == round.id,
col(Valuation.position_id).not_in(seen_position_ids),
)
for stale_val in session.exec(stale_q).all():
session.delete(stale_val)
return {
"status": "updated" if existing is not None else "added",
"matched": len(matches),
"unmatched": unmatched,
"nav_cents": nav_cents,
"round_id": round.id,
}
@router.post("/schedule")
def import_schedule(
file: UploadFile = File(...),
@@ -450,6 +545,40 @@ def import_schedule(
# import's upsert lands on a single clean copy and "Invested" stops double-counting.
dedupe_entity(resolved_entity_id, session)
# A file OLDER than the fund's newest round is a history backfill: record the quarter's
# NAV against today's book without touching holdings — a full import here would regress
# cost basis to the old file and resurrect since-exited positions. (Replace mode wiped
# the rounds above, so an explicit replace still takes the full path.)
newest_round = session.exec(
select(ValuationRound)
.where(ValuationRound.entity_id == resolved_entity_id)
.order_by(col(ValuationRound.quarter_end).desc())
).first()
if newest_round is not None and newest_round.quarter_end > as_of:
hist = upsert_history_round(resolved_entity_id, as_of, positions_preview, user, session)
record_audit(session, user.id, "import_history", "entity", resolved_entity_id, {
"seed_quarter": str(as_of), **{k: v for k, v in hist.items() if k != "round_id"},
})
session.commit()
return {
"committed": True,
"history_only": True,
**hist,
"source_entity_name": source_entity_name,
"entity": {
"resolution": entity_resolution,
"entity_id": resolved_entity_id,
"entity_name": entity.name,
"entity_type": entity.type.value,
},
"holdings_count": 0,
"positions_count": len(positions_preview),
"positions_created": 0,
"positions_updated": 0,
"seed_quarter": str(as_of),
"errors": errors,
}
# An existing round at this quarter: a NAV re-import should UPDATE it in place (refresh
# to the latest file) instead of stacking a second round and doubling the totals. Only
# import-created seed rounds are refreshable; a manually-signed valuation round is left
+5
View File
@@ -393,6 +393,11 @@ class BatchCapitalFileResult(BaseModel):
updated: int = 0 # matched a statement already at this as-of date
skipped: list[str] = [] # roster names with no existing member (not created)
error: str | None = None # file-level failure (bad password, no ALLOC SI, etc.)
# NAV history leg: the quarter's valuation round written from the file's HLD sheet.
nav_status: str | None = None # added | updated | kept-signed | no-match | no-hld | error
nav_matched: int = 0 # HLD rows matched to positions in today's book
nav_unmatched: int = 0 # HLD rows with no current position (sold/renamed since)
nav_cents: int = 0 # the quarter's NAV as recorded (matched rows only)
class BatchCapitalImportResult(BaseModel):
+152
View File
@@ -0,0 +1,152 @@
"""NAV history backfill (0.2.43): old eNAV files add past quarters to valuation history
WITHOUT touching current holdings — no cost-basis regression, no resurrected positions."""
import io
from datetime import date, datetime
import openpyxl
from sqlmodel import select
from ten31portal.models import (
Entity, EntityType, Holding, Position, RoundStatus, Valuation, ValuationRound,
)
def _enav_file(report: datetime, hld_rows: list[tuple], alloc_rows: list[dict] | None = None) -> bytes:
"""Minimal eNAV workbook: an HLD sheet (and optionally an ALLOC SI roster).
hld_rows: (security_name, quantity, cost, value) tuples.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "HLD"
ws["A1"] = report # _enav_as_of scans the top-left cells for the report date
ws.append([None])
ws.append(["SECURITY NAME", "QUANTITY", "COST BASIS - BOOK", "MARKET VALUE (BOOK)"])
for name, qty, cost, value in hld_rows:
ws.append([name, qty, cost, value])
if alloc_rows is not None:
alloc = wb.create_sheet("ALLOC SI")
alloc.append(["INVESTOR ID", "INVESTOR TYPE", "INVESTOR NAME",
"COMMITTED CAPITAL", "CONTRIBUTIONS", "(DISTRIBUTIONS)", "ENDING BALANCE"])
for r in alloc_rows:
alloc.append([r.get("id"), "LP", r["name"],
r.get("commit", 0), r.get("contrib", 0), r.get("distrib", 0), r["ending"]])
out = io.BytesIO()
wb.save(out)
return out.getvalue()
def _fund_with_current_book(session):
"""A fund holding one position (cost $100) with its NAV already signed for Q1 2026."""
fund = Entity(name="LTPF I", type=EntityType.fund)
session.add(fund)
session.commit()
session.refresh(fund)
holding = Holding(entity_id=fund.id, company_name="Acme")
session.add(holding)
session.commit()
pos = Position(holding_id=holding.id, security_name="Acme - Series A",
investment_date=date(2023, 1, 1), cost_cents=100_00)
session.add(pos)
session.commit()
session.refresh(pos)
rnd = ValuationRound(entity_id=fund.id, quarter_end=date(2026, 3, 31),
status=RoundStatus.approved, is_seed=True)
session.add(rnd)
session.commit()
session.refresh(rnd)
session.add(Valuation(round_id=rnd.id, position_id=pos.id, value_cents=900_00))
session.commit()
return fund, pos
def test_old_file_becomes_history_round_without_touching_book(auth_client, session):
fund, pos = _fund_with_current_book(session)
old = _enav_file(datetime(2025, 9, 30), [
("Acme - Series A", 10, 123.0, 555.0), # matches today's book
("Ghost - SAFE", 5, 999.0, 999.0), # sold since; must NOT be created
])
resp = auth_client.post(
f"/api/import/schedule?entity_id={fund.id}&commit=true",
files={"file": ("old.xlsx", io.BytesIO(old), "application/octet-stream")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["history_only"] is True
assert body["status"] == "added"
assert body["matched"] == 1 and body["unmatched"] == 1
session.expire_all()
# The quarter landed in valuation history with the matched row's value only.
hist_round = session.exec(select(ValuationRound).where(
ValuationRound.entity_id == fund.id, ValuationRound.quarter_end == date(2025, 9, 30)
)).first()
assert hist_round is not None
vals = session.exec(select(Valuation).where(Valuation.round_id == hist_round.id)).all()
assert [(v.position_id, v.value_cents) for v in vals] == [(pos.id, 555_00)]
# Today's book is untouched: cost basis kept, no ghost position resurrected.
assert session.get(Position, pos.id).cost_cents == 100_00
assert session.exec(select(Holding).where(Holding.company_name == "Ghost")).first() is None
def test_signed_round_is_kept(auth_client, session):
fund, pos = _fund_with_current_book(session)
signed = ValuationRound(entity_id=fund.id, quarter_end=date(2025, 9, 30),
status=RoundStatus.approved, is_seed=False)
session.add(signed)
session.commit()
old = _enav_file(datetime(2025, 9, 30), [("Acme - Series A", 10, 123.0, 555.0)])
resp = auth_client.post(
f"/api/import/schedule?entity_id={fund.id}&commit=true",
files={"file": ("old.xlsx", io.BytesIO(old), "application/octet-stream")},
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "kept-signed"
session.expire_all()
assert session.exec(select(Valuation).join(
ValuationRound, Valuation.round_id == ValuationRound.id
).where(ValuationRound.quarter_end == date(2025, 9, 30))).all() == []
def test_batch_backfill_also_records_nav(auth_client, session):
from tests.conftest import make_user
from ten31portal.models import CapitalAccountStatement, EntityAccess, UserRole
fund, pos = _fund_with_current_book(session)
lp = make_user(session, username="lp", name="Alice Trust", role=UserRole.investor)
session.add(EntityAccess(user_id=lp.id, entity_id=fund.id))
session.commit()
old = _enav_file(
datetime(2025, 9, 30),
[("Acme - Series A", 10, 123.0, 555.0), ("Ghost - SAFE", 5, 999.0, 999.0)],
alloc_rows=[{"id": "INV-1", "name": "Alice Trust", "commit": 1000, "contrib": 400, "ending": 420}],
)
for expected_status in ("added", "updated"): # second pass proves idempotency
resp = auth_client.post(
"/api/import/capital-accounts/batch",
data={"entity_id": str(fund.id)},
files=[("files", ("old.xlsx", io.BytesIO(old), "application/octet-stream"))],
)
assert resp.status_code == 200, resp.text
f = resp.json()["files"][0]
assert f["error"] is None and f["skipped"] == []
assert f["statements_written"] == 1
assert f["nav_status"] == expected_status
assert f["nav_matched"] == 1 and f["nav_unmatched"] == 1
assert f["nav_cents"] == 555_00
session.expire_all()
stmts = session.exec(select(CapitalAccountStatement).where(
CapitalAccountStatement.entity_id == fund.id
)).all()
assert len(stmts) == 1 # upserted, not duplicated
rounds = session.exec(select(ValuationRound).where(
ValuationRound.entity_id == fund.id, ValuationRound.quarter_end == date(2025, 9, 30)
)).all()
assert len(rounds) == 1
assert session.get(Position, pos.id).cost_cents == 100_00