OTC Treasury Reconciliation System

OTC Treasury Reconciliation System

A working prototype of the reconciliation infrastructure used at licensed Indonesian crypto fintech.
Built with real operational logic: dual-leg settlement matching, conservative PnL recognition, and a live pricing engine.

GitHub · Pricer API · Live Dashboard ·Google Collab


// What this is

At a licensed OTC crypto desk, every transaction has two legs:

  • Crypto leg — wallet transfer (USDT, USDC, BTC, or PAXG)
  • Fiat leg — bank transfer (IDR)

PnL can only be recognized when both legs are confirmed. If you book revenue before the bank transfer clears, you are overstating income. If you wait too long, your month-end numbers do not match your actual cash position.

This system automates that matching, tracks settlement status across four account types (bank accounts, crypto wallets, market maker accounts, exchange accounts), and produces a clean PnL ledger — with a live pricing engine on top.


// Architecture

Architecture flow diagram showing three layers: ingestion, reconciliation engine, and output

Three layers:

Ingestion (modular — not built in this prototype)
Bank statement PDF parsers, on-chain listeners (TRC20/ERC20/BTC), and manual CSV upload all normalize into the same transaction schema. In production, each data source gets its own adapter. For this project, the dataset is synthetic and represents normalized output.

Reconciliation engine (the core deliverable)
Matching logic pairs buy leg and sell leg via transaction ID. A settlement status tracker moves each transaction through four states: PENDING → SETTLED → RECONCILING → FAILED. PnL is recognized only when both legs have a confirmed settlement timestamp — the later of the two is the recognition date.

Output
Monthly PnL report, account ledger, live dashboard, and the pricer API.


// Dataset

FY 2024 synthetic data. 5,968 transactions across four currency pairs, generated with geometric Brownian motion for realistic rate simulation.

MetricValue
Total transactions5,968
Settlement rate92.8%
Total volumeIDR 18.2T (~$1.15B USD)
Net PnL (settled)IDR 41.4B (~$2.6M USD)
Avg blended spread30.3 bps
Regulatory tax rate0.21% (OJK)

Pairs:

PairVolume shareAvg spreadNotes
USDT/IDR50%20 bpsHighest frequency, tightest spread
USDC/IDR35%22 bpsSecond stablecoin
BTC/IDR10%81.6 bpsVolatile, wider spread, higher risk per settlement
PAXG/IDR5%103.3 bpsGold-backed, longest settlement lag

Raw CSVs and the data generation script are in the GitHub repo.


// Dashboard

The Looker Studio dashboard connects directly to the dataset and shows:

  • Monthly net PnL trend with pair breakdown
  • Gross spread → tax → net PnL waterfall
  • Settlement status distribution (SETTLED / PENDING / RECONCILING / FAILED)
  • Top clients by PnL contribution
  • Pair-level spread and volume analysis

Open in Looker Studio → (link available once published)


// Pricer

The pricing engine is a FastAPI app that accepts a live market maker rate and returns a buy/sell quote with spread and OJK tax applied. Parameters are editable without redeployment — no code change needed to update spreads when market conditions change.

How it works operationally:

A market maker sends their indicative rate. The pricer applies:

  1. Pair-specific spread — varies by client tier (tighter for Tier A, wider for Tier C)
  2. OJK regulatory tax — 0.21% on the client-facing IDR amount
  3. Returns a buy quote, a sell quote, gross spread, tax amount, and estimated net PnL

Endpoints:

POST /quote     → receives MM rate, volume, pair, client tier
                  returns buy quote, sell quote, gross spread, tax, net PnL

GET  /params    → returns current spread config for all pairs and tiers
PUT  /params    → updates spread parameters (requires X-API-Key header)
GET  /health    → service status check

Spread configuration by client tier:

PairTier ATier BTier C
USDT/IDR15 bps20 bps25 bps
USDC/IDR18 bps22 bps27 bps
BTC/IDR55 bps75 bps100 bps
PAXG/IDR70 bps95 bps130 bps

Pricing formula:

Buy quote  = MM rate × (1 + spread + 0.0021)
Sell quote = MM rate × (1 − spread − 0.0021)
Net PnL    = gross spread − tax

PnL is recognized when both settlement legs confirm — not at quote time.

View source on GitHub → · Live API → (link available once deployed)


// Settlement logic

The most important business rule in the system — and the one most often implemented incorrectly in treasury operations.

No warehousing means every transaction is fully matched. The desk buys crypto from the market maker and sells it to the client simultaneously. There is no inventory position. This means:

  • If the crypto leg settles but the fiat leg is still pending → PnL = 0
  • If both legs settle on different dates → recognition date is the later timestamp
  • Month-end cut: a trade done Dec 31 whose bank transfer clears Jan 2 is a January PnL item, not December

This creates a gap between trade date PnL and settlement date PnL that matters for monthly close. The reconciliation engine tracks both.

Settlement lags by pair:

PairCrypto legFiat legTotal window
USDT/IDR0.1–1.0 hrs1–4 hrsUp to 5 hrs
USDC/IDR0.1–1.0 hrs1–4 hrsUp to 5 hrs
BTC/IDR0.5–2.0 hrs2–8 hrsUp to 10 hrs
PAXG/IDR1.0–4.0 hrs4–24 hrsUp to 28 hrs

PAXG is the operational outlier. Gold-backed tokens take longer on both legs — the wider spread exists partly to compensate for that extended settlement risk window.


// What’s not built

PDF bank statement parser
In production, the fiat leg confirmation comes from bank statement PDFs — BCA, Mandiri, and BNI each have different formats. A parser layer using pdfplumber with bank-specific adapters would replace the manual CSV upload step. Not built here, but the normalized schema it outputs is identical to what this system already consumes.

On-chain listener
The crypto leg confirmation comes from blockchain data — TRC20 for USDT, ERC20 for USDC and PAXG, native BTC chain. A listener using tronpy, web3.py, or the Blockstream API would monitor wallet addresses and write confirmed transactions into the same schema. The ingestion layer just needs to be built around it.

Real-time alerting
The system currently flags RECONCILING and FAILED transactions in the dashboard. A production version would push alerts when a transaction exceeds 8 hours without both legs confirmed — via Slack, email, or a webhook to the ops team’s ticketing system.


// Stack

Python · Pandas · FastAPI · Pydantic · Chart.js · Looker Studio · GitHub

ComponentDetail
Data generationGeometric Brownian Motion via numpy
DashboardSelf-contained HTML, data baked in as JS objects
BI reportsLooker Studio connected to Google Sheets
PricerFastAPI + Uvicorn (deployment pending)

Synthetic data. Does not represent any real entity or transaction.
Built by Gilang Fajar Wijayanto — Senior Treasury & Finance Operations