TT Lab
Get started
Learn Learning paths Courses

The Language of Banking

Yesterday's Transactions Were Counted Differently by Every System

Continue in TT Lab

In one line

An instant and a calendar date are different things. A bank's value date is a value computed by placing an instant on the calendar of some time zone and then applying the cutoff time and the business-day calendar.

Why this was needed

Suppose that to the question "how many transactions were there yesterday," three systems gave different numbers. One cut at UTC midnight, one cut at KST midnight, and one counted from the previous day's cutoff time to today's cutoff time. All three are correct by their own criteria. So this divergence does not look like an outage and gets buried as "the tallies are a bit different." Buried, it blows up in settlement reconciliation.

The cause is almost always the same. The data has only the instant of receipt, but people and regulations speak in dates. An instant is one no matter where on Earth you measure it, but which day that instant falls on gets an answer only once you decide on a time zone. If you do this conversion ad hoc all over the code, you get as many conversion rules as there are pieces of code.

One more bank-specific circumstance attaches to this. The day money actually moves, that is, the value date, can differ from the day of receipt. A request that comes in after the cutoff time rolls to the next business day, and if the next day is a Saturday or a market holiday, it is pushed again. So the value date is not a stored fact but the product of rules.

How it works

The design splits into three layers.

  1. Store as an instant. Leave the receipt time in UTC, in the shape of RFC 3339, 2026-07-02T07:30:00Z. RFC 3339 calls a time without an offset a "local time" and stipulates that it alone cannot pin down an instant. The moment you put an offset-less string in the DB, that column is no longer an instant.
  2. Display and bucketing in local time. Python's zoneinfo reads the operating system's IANA time zone database and makes ZoneInfo("Asia/Seoul"). If you move with astimezone() and then take .date(), you get which day that instant is on the Seoul calendar. This is exactly where the result splits from code that adds a fixed 9 hours.
  3. The value date by rule. If the hour and minute in local time are at or past the cutoff time, add a day, and from that date find a business day and push forward. The business-day judgment comes from the weekend and holiday calendar.

Why a fixed offset is dangerous shows up right away in Korean data. Asia/Seoul is UTC+9 fixed now, but it was not always so. Measured directly with zoneinfo in the lab image, it looks like this.

1987-05-15 12:00 Asia/Seoul  ->  UTC+10 (KDT)   # 실습 이미지에서 실측
1987-12-15 12:00 Asia/Seoul  ->  UTC+9  (KST)   # 실습 이미지에서 실측
2026-07-15 12:00 Asia/Seoul  ->  UTC+9  (KST)   # 실습 이미지에서 실측
전환 순간: 1987-05-10 02:00 에 +10 으로, 1987-10-11 03:00 에 +9 로 (1988년도 같은 모양)

The fact that Korea had daylight saving time in the summers of 1987 and 1988 is in tzdb as it is, so zoneinfo returns UTC+10 for that stretch. Code that adds a fixed 9 hours reads the migrated data of this stretch wrong by an hour at a time. For a transaction near midnight, the date is off by a whole day. tzdb's design document says that time zone definitions keep changing through political decisions and that past records are also corrected when new facts come to light. So rules must live not in code but in data, and that data is updated by the IANA time zone database.

There is the same pitfall when you leave date arithmetic to the DB. SQLite's date and time functions offer a localtime modifier, but that is the server's local time, not the time zone the business decided. If you do not want to build a system where a single TZ setting on a container changes the value date, specify the time zone explicitly as a configuration value and have the application do the conversion.

What it looks like in the field

The most common incident is when "the UTC date was used as it is as the business date." It is tested only in time ranges where the UTC and KST dates look the same all day in the development environment, and it shows up in production as transactions after 9 p.m. (= 12:00 UTC) pile up. Transactions before KST 09:00, that is, around UTC midnight, get attached to the previous day.

The second is when the cutoff time is compared in UTC. A comparison like received_utc[11:16] >= "16:30" may happen to give the right answer for now, but it collapses the moment you handle the cutoff of a counterparty institution in a different time zone with the same code. You must always compare after moving to local time.

The third is the counterparty's date. A message sent on the morning of July 3 in Seoul is still July 2 in London. If you read the value_date in the counterparty institution's file by our calendar and reconcile, a whole day is left as unsettled. This is the place where the difference between aware and naive that the datetime documentation distinguishes turns into money in practice.

What really matters in practice

What you will do in the next lab

You build a receipt ledger of twelve days of transfers yourself, and count how many transactions the "UTC date = value date" rule the core system uses now gets wrong. You build a value date calculation engine that reads the holiday calendar and the cutoff time, attach in turn business-day addition and subtraction, cutoff time judgment, and counterparty time zone conversion, and pick out the daylight saving stretch in the 1987 and 1988 migrated data. At the end, without overwriting the ledger, you recompute the value dates of all records and produce a report of what changes and why.