Torres1980 commited on
Commit
1a85018
·
verified ·
1 Parent(s): 27a3aef

Add Python reconciliation engine

Browse files
Files changed (1) hide show
  1. 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}")