Spaces:
Running
Running
Add Python reconciliation engine
Browse files- recon/engine.py +213 -0
recon/engine.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Addepar <-> iCapital reconciliation engine for alternatives and insurance products.
|
| 3 |
+
|
| 4 |
+
iCapital is treated as the book of record for subscription, capital activity and
|
| 5 |
+
NAV/contract value. Addepar is the reporting system that has to match it. The
|
| 6 |
+
engine:
|
| 7 |
+
|
| 8 |
+
1. maps every iCapital holding to its Addepar security and position,
|
| 9 |
+
2. reconciles commitments, capital activity (calls, distributions, premiums)
|
| 10 |
+
and valuations,
|
| 11 |
+
3. auto-resolves the breaks that follow a deterministic rule (unbooked events,
|
| 12 |
+
misclassified distributions, valuation lag, rounding) and writes them to an
|
| 13 |
+
Addepar import file, and
|
| 14 |
+
4. routes only true exceptions to a person, each with a suggested fix.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import csv
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
from dataclasses import asdict, dataclass
|
| 22 |
+
from datetime import date, timedelta
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
ROUNDING_TOLERANCE = 1.00 # dollars
|
| 26 |
+
STALE_AFTER_DAYS = 120 # valuation age that needs a fresh GP/sponsor statement
|
| 27 |
+
INSURANCE = {"PPLI", "PPVA", "Fixed Indexed Annuity", "RILA"}
|
| 28 |
+
|
| 29 |
+
# Effort model used for the automation metrics (minutes).
|
| 30 |
+
MANUAL_REVIEW_PER_POSITION = 12 # rep ties out a position by hand
|
| 31 |
+
MANUAL_FIX_PER_BREAK = 20 # rep researches and books a fix
|
| 32 |
+
EXCEPTION_REVIEW = 15 # analyst works an exception the engine couldn't clear
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class Finding:
|
| 37 |
+
account_id: str
|
| 38 |
+
household: str
|
| 39 |
+
security: str
|
| 40 |
+
asset_class: str
|
| 41 |
+
check: str
|
| 42 |
+
status: str # Matched | Auto-resolved | Exception
|
| 43 |
+
icapital: float | None
|
| 44 |
+
addepar: float | None
|
| 45 |
+
difference: float | None
|
| 46 |
+
detail: str
|
| 47 |
+
action: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _read(path: Path) -> list[dict]:
|
| 51 |
+
with open(path, newline="") as f:
|
| 52 |
+
return list(csv.DictReader(f))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load(data_dir: str | Path) -> dict:
|
| 56 |
+
d = Path(data_dir)
|
| 57 |
+
return {n: _read(d / f"{n}.csv") for n in
|
| 58 |
+
("security_master", "accounts", "icapital_positions", "icapital_activity",
|
| 59 |
+
"addepar_positions", "addepar_transactions")}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def reconcile(data: dict, as_of: date = date(2026, 9, 30)) -> dict:
|
| 63 |
+
sec_by_ic = {s["icapital_id"]: s for s in data["security_master"]}
|
| 64 |
+
household = {a["account_id"]: a["household"] for a in data["accounts"]}
|
| 65 |
+
add_pos = {(p["account_id"], p["addepar_id"]): p for p in data["addepar_positions"]}
|
| 66 |
+
events = defaultdict(list)
|
| 67 |
+
for e in data["icapital_activity"]:
|
| 68 |
+
events[(e["account_id"], e["icapital_id"])].append(e)
|
| 69 |
+
txns = defaultdict(list)
|
| 70 |
+
for t in data["addepar_transactions"]:
|
| 71 |
+
txns[(t["account_id"], t["addepar_id"])].append(t)
|
| 72 |
+
|
| 73 |
+
findings: list[Finding] = []
|
| 74 |
+
imports: list[dict] = []
|
| 75 |
+
|
| 76 |
+
for ip in data["icapital_positions"]:
|
| 77 |
+
acct, ic = ip["account_id"], ip["icapital_id"]
|
| 78 |
+
sec = sec_by_ic.get(ic, {"name": ic, "asset_class": "Unknown", "addepar_id": ""})
|
| 79 |
+
cls, name, ad = sec["asset_class"], sec["name"], sec["addepar_id"]
|
| 80 |
+
nav, called, commit = float(ip["nav"]), float(ip["called_to_date"]), float(ip["commitment"])
|
| 81 |
+
ev = events[(acct, ic)]
|
| 82 |
+
|
| 83 |
+
def add(check, status, icv=None, adv=None, detail="", action=""):
|
| 84 |
+
diff = None if icv is None or adv is None else round(adv - icv, 2)
|
| 85 |
+
findings.append(Finding(acct, household.get(acct, acct), name, cls, check, status,
|
| 86 |
+
icv, adv, diff, detail, action))
|
| 87 |
+
|
| 88 |
+
# --- Mapping and position existence -----------------------------------
|
| 89 |
+
if not ad:
|
| 90 |
+
add("Security mapping", "Exception", nav, None,
|
| 91 |
+
f"{name} exists in iCapital but has no Addepar security.",
|
| 92 |
+
f"Create the Addepar security, map {ic}, then the engine will book "
|
| 93 |
+
f"{len(ev)} activity event(s) and the current NAV on the next run.")
|
| 94 |
+
continue
|
| 95 |
+
ap = add_pos.get((acct, ad))
|
| 96 |
+
if ap is None:
|
| 97 |
+
add("Position setup", "Exception", nav, None,
|
| 98 |
+
f"{acct} holds {name} in iCapital (${commit:,.0f} commitment) but has no Addepar position.",
|
| 99 |
+
"Open the position in Addepar with the iCapital commitment; activity and NAV will "
|
| 100 |
+
"flow automatically on the next run.")
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
count_before = len(findings)
|
| 104 |
+
|
| 105 |
+
# --- Commitment ------------------------------------------------------
|
| 106 |
+
a_commit = float(ap["commitment"])
|
| 107 |
+
if abs(a_commit - commit) > ROUNDING_TOLERANCE:
|
| 108 |
+
add("Commitment", "Exception", commit, a_commit,
|
| 109 |
+
"Commitment in Addepar does not match the subscription on iCapital.",
|
| 110 |
+
"Verify against the signed subscription document, then correct the Addepar commitment "
|
| 111 |
+
"(it drives unfunded commitment and pacing reports).")
|
| 112 |
+
|
| 113 |
+
# --- Capital activity ------------------------------------------------
|
| 114 |
+
by_ref = defaultdict(list)
|
| 115 |
+
for t in txns[(acct, ad)]:
|
| 116 |
+
by_ref[t["source_ref"]].append(t)
|
| 117 |
+
for ref, group in by_ref.items():
|
| 118 |
+
if len(group) > 1:
|
| 119 |
+
amt = float(group[0]["amount"])
|
| 120 |
+
add("Duplicate transaction", "Exception", amt, amt * len(group),
|
| 121 |
+
f"{group[0]['type']} for ${amt:,.2f} ({ref}) is booked {len(group)} times in Addepar.",
|
| 122 |
+
f"Approve removal of {', '.join(t['txn_id'] for t in group[1:])}; deletes need a human sign-off.")
|
| 123 |
+
for e in ev:
|
| 124 |
+
amt = float(e["amount"])
|
| 125 |
+
booked = by_ref.get(e["event_id"])
|
| 126 |
+
if not booked:
|
| 127 |
+
add(f"{e['type']} not booked", "Auto-resolved", amt, 0.0,
|
| 128 |
+
f"{e['type']} of ${amt:,.2f} (notice {e['notice_date']}) is on iCapital but not in Addepar.",
|
| 129 |
+
f"Booked {e['type'].lower()} dated {e['due_date']} to Addepar import.")
|
| 130 |
+
imports.append({"action": "ADD_TRANSACTION", "account_id": acct, "addepar_id": ad,
|
| 131 |
+
"type": e["type"], "amount": amt, "date": e["due_date"], "source_ref": e["event_id"]})
|
| 132 |
+
elif booked[0]["type"] != e["type"]:
|
| 133 |
+
add("Distribution classification", "Auto-resolved", amt, amt,
|
| 134 |
+
f"{e['event_id']} is {e['type']} on iCapital but booked as {booked[0]['type']} in Addepar.",
|
| 135 |
+
"Reclassified in Addepar import; cost basis and income reporting corrected.")
|
| 136 |
+
imports.append({"action": "RECLASSIFY", "account_id": acct, "addepar_id": ad,
|
| 137 |
+
"type": e["type"], "amount": amt, "date": booked[0]["trade_date"],
|
| 138 |
+
"source_ref": e["event_id"]})
|
| 139 |
+
|
| 140 |
+
# --- Valuation -------------------------------------------------------
|
| 141 |
+
nav_date = date.fromisoformat(ip["nav_date"])
|
| 142 |
+
a_val, a_date = float(ap["market_value"]), date.fromisoformat(ap["valuation_date"])
|
| 143 |
+
label = "Contract value" if cls in INSURANCE else "NAV"
|
| 144 |
+
source = "carrier statement" if cls in INSURANCE else "sponsor statement"
|
| 145 |
+
if (as_of - nav_date).days > STALE_AFTER_DAYS:
|
| 146 |
+
add("Stale valuation", "Exception", nav, a_val,
|
| 147 |
+
f"Latest {label} is dated {nav_date.isoformat()} ({(as_of - nav_date).days} days old).",
|
| 148 |
+
f"Request the current {source}; performance for this position is understated or overstated until it posts.")
|
| 149 |
+
elif a_date < nav_date:
|
| 150 |
+
add(f"{label} lag", "Auto-resolved", nav, a_val,
|
| 151 |
+
f"Addepar is on the {a_date.isoformat()} mark; iCapital has {nav_date.isoformat()}.",
|
| 152 |
+
f"Posted {label} of ${nav:,.2f} as of {nav_date.isoformat()} to Addepar import.")
|
| 153 |
+
imports.append({"action": "UPDATE_VALUATION", "account_id": acct, "addepar_id": ad,
|
| 154 |
+
"type": label, "amount": nav, "date": nav_date.isoformat(), "source_ref": ic})
|
| 155 |
+
elif abs(a_val - nav) <= ROUNDING_TOLERANCE and a_val != nav:
|
| 156 |
+
add("Rounding", "Auto-resolved", nav, a_val, "Difference is within the $1.00 rounding tolerance.",
|
| 157 |
+
"Cleared automatically; no booking needed.")
|
| 158 |
+
elif abs(a_val - nav) > ROUNDING_TOLERANCE:
|
| 159 |
+
add(f"{label} break", "Exception", nav, a_val,
|
| 160 |
+
f"Same valuation date ({nav_date.isoformat()}) but values differ by "
|
| 161 |
+
f"{abs(a_val - nav) / nav:.1%}.",
|
| 162 |
+
f"Tie out to the {source}; if iCapital is correct, approve the Addepar update.")
|
| 163 |
+
|
| 164 |
+
if len(findings) == count_before:
|
| 165 |
+
add("All checks", "Matched", nav, a_val, "Commitment, activity and valuation agree.", "")
|
| 166 |
+
|
| 167 |
+
return {"findings": findings, "imports": imports, "metrics": metrics(findings, data)}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def metrics(findings: list[Finding], data: dict) -> dict:
|
| 171 |
+
positions = len(data["icapital_positions"])
|
| 172 |
+
auto = sum(f.status == "Auto-resolved" for f in findings)
|
| 173 |
+
exc = sum(f.status == "Exception" for f in findings)
|
| 174 |
+
by_pos = defaultdict(set)
|
| 175 |
+
for f in findings:
|
| 176 |
+
by_pos[(f.account_id, f.security)].add(f.status)
|
| 177 |
+
clean = sum(s == {"Matched"} for s in by_pos.values())
|
| 178 |
+
manual_min = positions * MANUAL_REVIEW_PER_POSITION + (auto + exc) * MANUAL_FIX_PER_BREAK
|
| 179 |
+
auto_min = exc * EXCEPTION_REVIEW
|
| 180 |
+
return {
|
| 181 |
+
"positions": positions,
|
| 182 |
+
"clean_positions": clean,
|
| 183 |
+
"breaks": auto + exc,
|
| 184 |
+
"auto_resolved": auto,
|
| 185 |
+
"exceptions": exc,
|
| 186 |
+
"auto_resolution_rate": round(auto / (auto + exc), 3) if auto + exc else 1.0,
|
| 187 |
+
"manual_hours": round(manual_min / 60, 1),
|
| 188 |
+
"automated_hours": round(auto_min / 60, 1),
|
| 189 |
+
"hours_saved": round((manual_min - auto_min) / 60, 1),
|
| 190 |
+
"positions_touched_by_people": len({(f.account_id, f.security) for f in findings if f.status == "Exception"}),
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def write_outputs(result: dict, out_dir: str | Path) -> None:
|
| 195 |
+
out = Path(out_dir)
|
| 196 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 197 |
+
rows = [asdict(f) for f in result["findings"]]
|
| 198 |
+
with open(out / "recon_findings.csv", "w", newline="") as f:
|
| 199 |
+
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
| 200 |
+
w.writeheader()
|
| 201 |
+
w.writerows(rows)
|
| 202 |
+
with open(out / "addepar_import.csv", "w", newline="") as f:
|
| 203 |
+
w = csv.DictWriter(f, fieldnames=["action", "account_id", "addepar_id", "type", "amount", "date", "source_ref"])
|
| 204 |
+
w.writeheader()
|
| 205 |
+
w.writerows(result["imports"])
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == "__main__":
|
| 209 |
+
here = Path(__file__).resolve().parent.parent
|
| 210 |
+
res = reconcile(load(here / "data"))
|
| 211 |
+
write_outputs(res, here / "output")
|
| 212 |
+
for k, v in res["metrics"].items():
|
| 213 |
+
print(f"{k:28s} {v}")
|