Files
Ten31-Portal/backend/tests/test_history_nav.py
T
Jonathan Kirkwood ebafcf19d9 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.
2026-08-11 12:32:57 -05:00

153 lines
6.4 KiB
Python

"""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