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.
153 lines
6.4 KiB
Python
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
|