OTC Trading

I once had a single OTC transaction sit unreconciled overnight — $5 million USD equivalent, USDT to IDR. The crypto leg was confirmed on-chain within minutes. Clean hash, right wallet, right amount. The fiat leg — the IDR that was supposed to hit our bank account the same day — didn’t arrive.

That IDR was earmarked as a capital injection. A regulatory requirement from OJK, Indonesia’s financial services authority. We had a deadline.

It wasn’t failed. It wasn’t cancelled. It was just — not there yet. Somewhere between the market maker, the correspondent bank, and the settlement window, the fiat leg was still in transit. And I had no system to tell me exactly where, or when to expect it.

That night I learned something that doesn’t show up in any treasury textbook: in OTC crypto settlement, confirmed does not mean settled. The blockchain will tell you the crypto moved. It will not tell you whether the IDR landed. Those are two different systems, two different timelines — and in the gap between them lives every reconciliation problem worth solving.


// The problem

OTC crypto desks look simple from the outside. A client wants to buy USDT. You source it from a market maker, mark it up, deliver it to the client’s wallet, collect the IDR. Margin captured. Move on.

The reality is four moving parts that don’t talk to each other.

Banks operate on hard cut-off windows that have nothing to do with crypto market hours. In Indonesia, large IDR transfers — anything above IDR 250 million — are processed through BI-FAST or RTGS only between 8am and 2pm UTC+8. A transfer initiated at 2:10pm does not arrive today. It arrives tomorrow morning. Miss the window by ten minutes on a $1 million transaction and your settlement is a day late regardless of what the blockchain says.

SWIFT adds another layer. USD transfers have their own cut-off — typically 2pm to 3pm depending on the correspondent bank. Other currencies have different windows entirely. A SGD transfer and a USD transfer initiated at the same time on the same day can arrive two days apart. The bank doesn’t flag this. It just happens.

Crypto wallets operate on blockchain time. TRC20 USDT confirms in minutes. ERC20 takes longer depending on gas and network congestion. BTC can take anywhere from ten minutes to an hour. On-chain confirmation is objective and timestamped — but it only tells you one leg of the story.

Market makers have their own internal systems. The rate they quoted you at 2pm is the rate you traded at, but their settlement instruction arrives separately — sometimes with a reference number that doesn’t match anything in your internal records.

Exchange accounts — used for price discovery and hedging — add another layer of positions to reconcile against.

Now run all four simultaneously across USDT, USDC, BTC, and PAXG. Across multiple clients. On the same day. With a month-end close approaching.

The specific failure modes I’ve seen:

Late recognition. The crypto leg confirms at 11pm. The fiat leg clears the next morning because the transfer was initiated after the 2pm cut-off. If your system books PnL at trade time rather than settlement time, you’ve recognized revenue in the wrong period — a problem that compounds badly at month-end when trades cross the cut-off window.

Timestamp mismatches. The bank statement says the credit posted at 09:14. The market maker’s confirmation says 09:11. Your internal record says 09:16. All three are referring to the same transaction. None of them agree.

FX drift. Between the moment you quote a client and the moment both legs settle, the rate has moved. On a stablecoin trade this is negligible. On a $500K BTC transaction with an 8-hour settlement window, it is not.

Silent failures. The most dangerous kind. The transaction doesn’t fail — it just doesn’t complete. It sits in a pending state that nobody is monitoring because the system shows no error. Days later, someone notices the position doesn’t balance.

At low volume, you catch these manually. You build intuition for which banks are slow on Fridays, which market makers send late confirmations, which pairs have the widest settlement windows. You compensate with experience and late nights.

At scale, experience is not a system.


// Inspection

Before building anything, I pulled a full year of transaction data and looked at what was actually happening.

5,968 transactions across FY 2024. Four currency pairs. Eight clients. Three market makers. Two banks handling the bulk of IDR settlement, one nostro account for USD, five crypto wallets across TRC20, ERC20, and BTC chains.

The first thing the data showed was the settlement rate: 92.8% of transactions reached full settlement. That sounds healthy until you look at what the remaining 7.2% represents — 431 transactions sitting in PENDING, RECONCILING, or FAILED status. At an average transaction size of IDR 3 billion, that is over IDR 1.3 trillion in transactions that didn’t complete cleanly.

The pair breakdown told a clearer story.

USDT and USDC together account for 85% of volume. Tight spreads, fast settlement, low operational risk per transaction. These are the workhorses — high frequency, predictable, manageable. The spread on USDT averages 20 basis points. On USDC, 22. Both settle the fiat leg within four hours on a normal day, well inside the banking cut-off window if the trade is initiated before noon.

BTC is a different animal. 10% of volume, 81.6 basis points average spread. The wider spread isn’t arbitrary — it reflects the settlement risk. A BTC trade initiated at 1pm with a 2-hour on-chain confirmation window and a 2-8 hour fiat settlement window is already at risk of missing the 2pm banking cut-off. When that happens, the fiat leg rolls to the next day. The reconciliation engine flags it. Someone has to investigate.

PAXG is the outlier that the data makes impossible to ignore. 5% of volume, 103 basis points average spread, fiat settlement lag of up to 24 hours. Gold-backed tokens carry both the crypto settlement complexity and the FX conversion risk — PAXG tracks XAU/USD, which then converts to IDR, which means two rates need to be right at the moment both legs confirm. The spread exists precisely because of that window. In the data, PAXG shows the highest rate of RECONCILING status of any pair.

The PnL recognition gap was the most operationally significant finding.

Across the full year, net PnL on a trade date basis and net PnL on a settlement date basis differ by meaningful amounts at every month-end. December is the starkest example — several high-value trades initiated on Dec 30 and 31 had fiat legs that cleared in January, after the banking cut-off. On a trade date basis they appear as December revenue. On a settlement date basis they are January. The reconciliation engine tracks both timestamps and recognizes PnL only on settlement — but without that discipline enforced systematically, month-end close becomes a negotiation between what the system says and what the bank statement says.

The aging data on open items was the clearest argument for automation.

Of the 177 transactions in RECONCILING status, most had been sitting open for more than 24 hours. Some for more than a week. A transaction that has been unreconciled for 320 hours is not going to resolve itself — it needs someone to look at it, find the mismatch, and either match it manually or escalate it. Without a system surfacing these by age, they stay invisible until month-end when someone is trying to close the books and suddenly discovers an IDR 7 billion hole in the ledger.

That is the moment reconciliation becomes a crisis instead of a process.


// Findings

A year of data, structured properly for the first time, produces answers to questions you didn’t know you needed to ask.

Spread is not uniform — and it shouldn’t be.

The blended spread across all pairs is 30.3 basis points. But that number flattens something important. USDT trades at 20 bps. PAXG trades at 103 bps. Both numbers are correct for their context — the spread on any pair reflects the operational cost of settling it, not just the commercial margin above it.

This is a point that gets missed in treasury operations that treat spread as a purely commercial decision. If you compress the PAXG spread to match stablecoin levels because a client pushes back on pricing, you are subsidizing the settlement risk out of your own margin. The data makes that cost visible. A PAXG trade that takes 20 hours to fully settle and requires manual intervention on the fiat leg is not the same operational event as a USDT trade that clears in 90 minutes. The spread should reflect that difference — and it does, when you have the data to defend it.

Volume concentration creates invisible risk.

USDT and USDC together generate IDR 36.7 billion of the IDR 41.4 billion total net PnL — 88.7% of revenue from two pairs that behave similarly and share the same settlement infrastructure. That concentration is efficient until TRC20 or ERC20 has a network congestion event, or until Bank Indonesia announces a change to RTGS processing hours, or until one of the three market makers has a liquidity issue on a Friday afternoon.

The data doesn’t show those events because FY 2024 was a relatively clean year operationally. What it shows is the exposure — if stablecoin settlement breaks for 48 hours, 85% of volume stops moving.

Client concentration is worth monitoring.

The top three clients by net PnL contribution account for IDR 13.3 billion of the total, or 32.1%. That is a healthy level of concentration for a desk this size, but it is concentration nonetheless. Losing one of those three relationships doesn’t just affect revenue — it affects the volume that justifies the market maker relationships and the spread levels negotiated against them. Treasury operations and business development are more connected than they usually appear on an org chart.

The 2pm problem is real and measurable.

Across the full year, transactions initiated after 1pm local time show a materially higher rate of next-day fiat settlement than those initiated before noon. This is not surprising — it is exactly what the banking cut-off windows predict. What the data adds is precision. You can see exactly which pairs are most affected (BTC and PAXG, given their longer on-chain confirmation windows), which days of the week generate the most late initiations (Fridays, predictably), and what the downstream effect is on PnL recognition timing.

That precision matters because it gives the operations team something actionable. The answer is not “settle everything before 2pm” — some clients will always need late-day execution. The answer is a pricing and operational framework that accounts for overnight settlement risk explicitly, rather than absorbing it silently.

Most reconciliation failures are not failures — they are delays.

Of the 230 transactions that didn’t reach SETTLED status, the majority are in RECONCILING rather than FAILED. The distinction matters. A FAILED transaction is a real problem — a transfer that didn’t execute, a wallet address that was wrong, a counterparty that didn’t perform. A RECONCILING transaction is almost always a matching problem: the money moved, the asset moved, but the internal records don’t agree on the reference number, the timestamp, or the amount to the last decimal.

That is a solvable problem. It requires tooling, not heroics. And it requires catching it at 25 hours, not at month-end close when it has been sitting invisible for three weeks.


// Solution

The system I built addresses three specific problems the data surfaced: PnL recognition that depends on both legs confirming, a settlement status engine that makes failures visible before they become crises, and a pricing tool that removes human error from the quoting step.

OTC dashboard 1

OTC dashboard 2

OTC dashboard 3

Live demo is here

Dual-leg settlement matching.

Every transaction in the system has two timestamps that matter — crypto_settlement_timestamp and fiat_settlement_timestamp. PnL is recognized at the later of the two. Not at trade time. Not at crypto confirmation. At the moment both legs are done.

This is the no-warehousing rule made explicit in code. The desk doesn’t hold inventory — every trade is matched. Which means every trade has a buy leg and a sell leg, and revenue exists only when both sides have confirmed. A transaction sitting at crypto-confirmed but fiat-pending contributes zero to the PnL ledger. It stays in PENDING until the bank clears.

The practical effect at month-end: a trade done on December 31 whose IDR lands on January 2 — because it was initiated after the 2pm banking cut-off — is a January PnL item. The system enforces that automatically. No manual adjustment, no negotiation between what the trader booked and what the bank statement shows.

Settlement status tracking.

Every transaction moves through four states:

PENDING — one or both legs not yet confirmed. Normal state for any transaction within its expected settlement window.

SETTLED — both legs confirmed. PnL recognized. No further action required.

RECONCILING — settlement window has passed but matching is incomplete. The money moved — it just doesn’t match cleanly against the internal record. Reference number mismatch, timestamp discrepancy, decimal rounding on the IDR amount. These need a human to resolve, but the system surfaces them automatically rather than letting them age invisibly.

FAILED — transaction did not complete. Escalation required.

The reconciliation dashboard shows every open RECONCILING and FAILED item with an age column — hours since trade timestamp. Color coded: green under 24 hours, amber up to 168 hours, red beyond that. The operational rule is simple: nothing should be red. If it is, someone missed something.

In the FY 2024 dataset, 177 transactions reached RECONCILING status. Most aged beyond 24 hours. Several beyond 300 hours. Those are the ones that would have shown up as surprises at month-end close without a system surfacing them daily.

The pricer.

OTC dashboard 3

The quoting step is where manual error enters the process earliest — and in the Indonesian OTC ecosystem, the quoting step is more chaotic than it sounds.

There is no single market maker. On any given client request, I would message two or three simultaneously, asking for their indicative rate on the pair and volume. Rates come back at different times, in different formats, sometimes via WhatsApp. I would plug them into an Excel formula, pick the best one, apply our spread and the OJK tax, and send the client a quote — all while the client is waiting, sometimes impatiently, and the market is moving.

That entire process happens in minutes. On a normal day it is manageable. On a day when a client needs IDR 5 billion of USDT before 1:30pm because they know the banking cut-off is at 2pm, it is a controlled sprint where any arithmetic error — a wrong spread, a missed decimal, forgetting to include the tax — goes directly to the client and cannot be quietly corrected after the fact.

The pricer replaces the Excel formula with something consistent and auditable. A market maker’s rate goes in. The pair, client tier, and volume go in. A buy quote, sell quote, gross spread, tax amount, and estimated net PnL come out — instantly, every time, with the same formula applied correctly regardless of how much else is happening at that moment.

The formula itself is not complex:

Buy quote  = MM rate × (1 + spread + 0.0021)
Sell quote = MM rate × (1 − spread − 0.0021)

The 0.0021 is the OJK regulatory tax — 0.21% on the client-facing IDR amount, remitted to the regulator. It applies to every transaction regardless of pair or tier. It is not negotiable and it is not optional, which makes it exactly the kind of fixed parameter that should never be left to mental arithmetic under time pressure.

Spread varies by pair and client tier. USDT Tier A is 15 basis points. PAXG Tier C is 130. Those parameters live in a config file that updates without touching the application code — when a client relationship is repriced or market conditions shift, one file changes and every subsequent quote reflects it.

The pricer is a FastAPI application. It does not execute trades. It does not connect to market maker APIs or pull live rates. It is a calculation engine — the kind of tool that should have existed in a spreadsheet but is more reliable, more auditable, and harder to accidentally break when someone adds a column to the wrong sheet. A live rate feed from each market maker is the natural next step, but that is an engineering problem that requires proper API integration, rate limit handling, and failure modes — not something to bolt onto a prototype.

The trader still messages the market makers. Still chooses the best rate. Still manages the client relationship and the timing. The judgment stays where it belongs. The arithmetic does not follow it onto the same WhatsApp thread.

What this looks like operationally.

On a normal day, the workflow is: market maker sends rate, trader runs the pricer, client confirms, transaction is logged, both legs settle within their expected windows, system moves the transaction to SETTLED, PnL recognized. No manual reconciliation required.

On a harder day — late initiation, banking cut-off missed, BTC confirmation slower than expected, PAXG fiat leg rolling overnight — the system flags exactly which transactions need attention, how long they have been open, and what state they are in. The trader knows where to look. The escalation decision is informed, not reactive.

That is the difference between experience as a system and experience as a crutch.


// Limitations

This system is a working prototype. It solves the problems it was built to solve — PnL recognition discipline, settlement visibility, consistent pricing. But there are things it does not do, and being honest about them is more useful than pretending they are on the roadmap.

The data enters manually.

Every transaction in this system starts as a CSV. In production, that CSV would come from somewhere — a bank statement, a blockchain listener, a market maker confirmation email. Right now, the ingestion layer does not exist. Someone still has to pull the bank statement, format it correctly, and upload it. That person is almost certainly the same trader who is also messaging market makers on WhatsApp and managing the client call.

The two most useful things to build next are a PDF parser for bank statements and an on-chain listener for the crypto leg. Neither is technically complex — pdfplumber handles most Indonesian bank statement formats reasonably well, and tronpy or web3.py can monitor wallet addresses and write confirmed transactions directly into the schema this system already uses. The normalized output format is already defined. The ingestion layer just needs to be built around it.

The reason it is not built here is scope, not difficulty. This project is about demonstrating the reconciliation logic and the operational thinking behind it. Parsers are an engineering problem with known solutions. The settlement matching logic is a finance operations problem that requires knowing what you are actually reconciling and why.

It does not scale to real production volume without a database.

The current system stores everything in CSVs and pre-aggregates data into JavaScript objects baked into the dashboard. This works perfectly for 5,968 transactions and a portfolio demonstration. It does not work for a desk processing that volume daily rather than annually.

A production version needs a database — PostgreSQL is the obvious choice, simple schema, well-understood in fintech engineering teams. The reconciliation engine logic stays identical. The storage layer changes. That migration is straightforward but it is not done here.

The pricer does not pull live rates.

The current pricer accepts a rate that the trader inputs manually after checking with market makers. It does not connect to any market maker API, exchange feed, or price oracle. That is a deliberate choice for this prototype — adding a live rate feed introduces latency, API key management, failure modes, and rate limit handling that are real engineering concerns and outside the scope of what this project is demonstrating. The right person to build that integration is an engineer with access to the market maker’s API documentation, not a trader with a FastAPI template.

It assumes the trader catches what the system misses.

The RECONCILING status flags a problem. It does not diagnose it. A transaction that has been sitting in RECONCILING for 320 hours could be a timestamp mismatch, a wrong reference number, a partial transfer, or a genuine counterparty failure. The system surfaces it. A human has to figure out which one it is.

In a small desk, that human is the trader. In a larger operation, it is a dedicated reconciliation team. Either way, the system is a tool for directing attention — not a replacement for the judgment that acts on it. The $5 million transaction that opened this article was not a system failure. There was no system. The system would have flagged it within the hour and put it at the top of the open items list. What happened next would still have required a phone call to the market maker and a conversation with the CFO.

That part does not get automated.


The full dataset, reconciliation engine, and pricer source code are available on GitHub. Technical documentation, architecture diagram, and live dashboard are on the project page.