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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user