""" Vrismcost — the export. The report is the product. Everything upstream exists so that one file can be handed to an accountant and survive being questioned. Four rules shape the format: No comment lines in the data. A leading "# generated on …" offsets every column the moment the file is opened in a spreadsheet, which is where these files are always opened. Metadata goes in its own file. Both hashes on every row. A leg is a claim about two moments — when the asset was acquired and when it left. One hash proves half of that, so the schedule carries the acquiring and the disposing transaction side by side. Unresolved rows are exported, not dropped. A schedule that quietly omits what the tool could not read looks complete and is not. Those rows carry UNPRICED where a figure would be, and say why. Provenance is a column, never a footnote. Every row is marked chain, derived (valued at an ETH rate read off the chain), user (a figure the owner supplied) or UNRESOLVED, so a reviewer can see at a glance which numbers the blockchain proved and which ones someone typed. Four files are written, because an accountant works in a spreadsheet and separate sheets beat one file with sections: -disposals.csv the capital gains schedule, one row per leg -income.csv distributions, kept out of the lot inventory -open-lots.csv what is still held, carrying into the next year -summary.csv the metadata, including whether this is complete """ from __future__ import annotations import csv from decimal import Decimal as D from pathlib import Path UNPRICED = "UNPRICED" DISPOSAL_COLUMNS = [ "asset", "quantity", "acquired_date", "acquired_tx", "disposed_date", "disposed_tx", "cost_basis", "gas_in_basis", "proceeds", "gas_on_disposal", "gain", "days_held", "method", "source", "note", ] INCOME_COLUMNS = ["date", "asset", "quantity", "value", "source", "tx", "note"] LOT_COLUMNS = ["lot", "asset", "quantity_remaining", "unit_cost", "cost_basis", "acquired_date", "acquired_tx", "adjusted", "source"] SUMMARY_COLUMNS = ["field", "value"] def _plain(x) -> str: """A token quantity. Spreadsheet-safe: no separators, no scientific notation. Full precision is kept, because 13,505,154.227261797 JEW is the real quantity and rounding it would not balance against the chain.""" if x is None or x == "": return "" try: return format(D(str(x)).normalize(), "f") except Exception: return str(x) def _money(x) -> str: """A currency figure, always to the cent. Decimal keeps every digit of a division, so a cost basis arrives as 2815.504805192172192644745473. That is not a defensible number to hand an accountant: money has two decimal places, and a column that does not respect that makes every other figure in the file look unconsidered.""" if x is None or x == "": return "" try: return str(D(str(x)).quantize(D("0.01"))) except Exception: return str(x) def provenance(disposal_source: str, lot_source: str) -> str: """A row is only as proven as the least proven figure on it.""" if disposal_source == "UNRESOLVED": return "UNRESOLVED" sources = {disposal_source, lot_source} if "user" in sources: return "user" if "derived" in sources: return "derived" return "chain" def disposal_rows(report: dict, method: str = "FIFO") -> list[dict]: """One row per matched leg, one per quantity no lot covers, and one per arrival the chain could not price. Each leg carries its own share of the disposal's proceeds and gas, so the gains down the sheet add up to the realised figure in the summary. A disposal nobody has priced still shows the lots it consumed and their basis, with UNPRICED where the proceeds and the gain would be.""" rows = [] disposed = set() for d in report.get("disposals", []): m = d["methods"].get(method) if not m: continue disposed.add(d["tx"]) priced = d.get("proceeds") is not None total_qty = D(d["qty"]) for leg in m["legs"]: qty = D(leg["qty"]) cost = D(str(leg.get("costExact") or leg["cost"])) gas = D(str(leg.get("gasShare") or "0")) proceeds = D(str(leg["proceedsShare"])) if priced and leg.get("proceedsShare") is not None else None notes = [] if d.get("allocation"): notes.append(d["allocation"]) if leg.get("split"): notes.append("lot adjusted by a corporate action") if leg.get("movedWallets"): notes.append("lot survived a wallet move") rows.append({ "asset": d["symbol"], "quantity": _plain(qty), "acquired_date": leg["acquired"], "acquired_tx": leg.get("tx", ""), "disposed_date": d["at"], "disposed_tx": d["tx"], "cost_basis": _money(cost), "gas_in_basis": _money(leg.get("gasInBasis", "0")), "proceeds": _money(proceeds) if proceeds is not None else UNPRICED, "gas_on_disposal": _money(gas), "gain": _money(proceeds - gas - cost) if proceeds is not None else UNPRICED, "days_held": "" if leg.get("heldDays") is None else leg["heldDays"], "method": method, "source": provenance(d.get("source", "chain"), leg.get("lotSource", "chain")), "note": "; ".join(notes)[:180], }) left = D(m.get("unmatched") or "0") if left > 0 and total_qty > 0: frac = left / total_qty rows.append({ "asset": d["symbol"], "quantity": _plain(left), "acquired_date": "", "acquired_tx": "", "disposed_date": d["at"], "disposed_tx": d["tx"], "cost_basis": UNPRICED, "gas_in_basis": "", "proceeds": _money(D(d["proceeds"]) * frac) if priced else UNPRICED, "gas_on_disposal": _money(D(d.get("gas") or "0") * frac), "gain": UNPRICED, "days_held": "", "method": method, "source": "UNRESOLVED", "note": "UNMATCHED: no acquisition in the scanned history covers this quantity", }) # Arrivals the chain could not price still appear, marked. A distribution # is income business and is kept on the income sheet, not here. for q in report.get("queue", []): if q.get("kind") == "DISTRIBUTION": continue answer = q.get("answer") sides = [("gained", k, v) for k, v in (q.get("gained") or {}).items()] if q.get("tx") not in disposed: sides += [("given", k, v) for k, v in (q.get("given") or {}).items()] for side, asset, amount in sides: if asset in (report.get("quote"), "ETH") and side == "given": continue arriving = side == "gained" rows.append({ "asset": asset, "quantity": _plain(amount), "acquired_date": q.get("at", "") if arriving else "", "acquired_tx": q.get("tx", "") if arriving else "", "disposed_date": "" if arriving else q.get("at", ""), "disposed_tx": "" if arriving else q.get("tx", ""), "cost_basis": UNPRICED, "gas_in_basis": "", "proceeds": "" if arriving else UNPRICED, "gas_on_disposal": "", "gain": UNPRICED, "days_held": "", "method": method, "source": "user" if answer else "UNRESOLVED", "note": (answer.get("label") if answer else q.get("note", ""))[:180], }) return rows def income_rows(report: dict) -> list[dict]: """Distributions are income on arrival. They never join the lot inventory and they never offset a capital gain, so they get their own sheet.""" # Confirmed by the owner, at the value they gave -- or, for the quote asset, # at the amount the chain states. rows = [{"date": i.get("at", ""), "asset": i.get("asset", ""), "quantity": _plain(i.get("quantity", "")), "value": _money(i.get("value")) or UNPRICED, "source": i.get("source", "user"), "tx": i.get("tx", ""), "note": (i.get("note") or "")[:180]} for i in report.get("income", [])] # Still waiting on an answer. for e in report.get("queue", []): if e.get("kind") != "DISTRIBUTION": continue gained = e.get("gained") or {} asset = next(iter(gained), "") rows.append({ "date": e.get("at", ""), "asset": asset, "quantity": _plain(gained.get(asset, "")), "value": _money((e.get("answer") or {}).get("value")) or UNPRICED, "source": "user" if e.get("answer") else "UNRESOLVED", "tx": e.get("tx", ""), "note": e.get("note", "")[:180], }) return sorted(rows, key=lambda r: r["date"]) def open_lot_rows(report: dict, method: str = "FIFO") -> list[dict]: """What is still held under the method this export is for. These carry their basis and acquisition date into the next tax year rather than being re-opened at whatever they are worth. What is left depends on the method: after one sale from a cheap lot and a dear one, FIFO holds the dear lot and LIFO the cheap one.""" rows = [] for l in (report.get("lotsByMethod") or {}).get(method, report.get("lots", [])): remaining = D(l.get("remaining", l.get("qty", "0"))) if remaining <= 0: continue # The exact remaining cost, never a unit cost already rounded to cents # multiplied back up: that drifted by up to half a unit per lot. exact = l.get("remainingCost") if exact is None: exact = remaining * D(str(l.get("unitCostExact") or l.get("unitCost", "0"))) adjusted = [word for word, present in (("split", l.get("split")), ("moved", l.get("moves"))) if present] rows.append({ "lot": l.get("n", ""), "asset": l.get("symbol", ""), "quantity_remaining": _plain(remaining), "unit_cost": _plain(l.get("unitCost", "")), "cost_basis": _money(exact), "acquired_date": l.get("acquired", ""), "acquired_tx": l.get("tx", ""), "adjusted": "; ".join(adjusted), "source": l.get("source") or ("user" if l.get("allocation") else "chain"), }) return rows def realised_under(report: dict, method: str) -> D: """Realised gain under the method this export is for. The engine's own `realised` figure is FIFO, and says so wherever it is printed. A sheet that names another method has to total that method's gains, or it contradicts the disposal sheet sitting next to it. """ total = D("0") for d in report.get("disposals", []): gain = (d.get("methods", {}).get(method) or {}).get("gain") if gain not in (None, ""): # an unpriced disposal has no gain to add total += D(str(gain)) return total def summary_rows(report: dict, meta: dict) -> list[dict]: cov = report.get("coverage", {}) blocked = cov.get("blocked", 0) excluded = report.get("excluded") or [] pairs = [ # The line a reviewer reads first, so it is the first line. ("status", "COMPLETE" if blocked == 0 else f"INCOMPLETE - {blocked} transaction(s) unresolved"), ("generated_by", "Vrismcost"), ("chain", f"{meta.get('chainName', 'Robinhood Chain')} ({meta.get('chainId', 4663)})"), ("wallets", "; ".join(report.get("wallets", []))), ("quote_asset", report.get("quote", "")), ("method", meta.get("method", "FIFO")), ("scanned_from_block", meta.get("fromBlock", "")), ("scanned_to_block", meta.get("toBlock", "")), ("transactions_read", report.get("transactions", 0)), ("priced_by_chain", cov.get("full", 0)), ("answered_by_owner", cov.get("answered", 0)), ("unresolved", blocked), ("unmatched_disposals", cov.get("unmatched", 0)), ("excluded_by_owner", len(excluded)), ("excluded_transactions", "; ".join(x["tx"] for x in excluded)), ("coverage_percent", cov.get("pct", 0)), ("eth_rate_source", meta.get("ethRateSource", "")), ("eth_rate_windows", meta.get("ethRateWindows", "")), ("realised_gain", _money(str(realised_under(report, meta.get("method", "FIFO"))))), ("open_lots", (report.get("openLotsByMethod") or {}).get( meta.get("method", "FIFO"), report.get("openLots", 0))), ] return [{"field": k, "value": "" if v is None else str(v)} for k, v in pairs] def _write(path: Path, columns: list[str], rows: list[dict]) -> Path: with open(path, "w", newline="", encoding="utf-8") as fh: w = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore") w.writeheader() w.writerows(rows) return path def write_report(prefix: str, report: dict, meta: dict, method: str = "FIFO") -> list[tuple[Path, int]]: """Write the four sheets. Returns each path with its row count.""" base = Path(prefix) base.parent.mkdir(parents=True, exist_ok=True) sets = [ (f"{base}-disposals.csv", DISPOSAL_COLUMNS, disposal_rows(report, method)), (f"{base}-income.csv", INCOME_COLUMNS, income_rows(report)), (f"{base}-open-lots.csv", LOT_COLUMNS, open_lot_rows(report, method)), (f"{base}-summary.csv", SUMMARY_COLUMNS, summary_rows(report, {**meta, "method": method})), ] return [(_write(Path(p), c, r), len(r)) for p, c, r in sets]