Skip to content

Ledger generation: transactions, running balances & statements

This section demonstrates the detail → derive pattern on the archetypal hard case, a bank ledger: transactions generated on an account x day x sequence scaffold, calendar-driven behavior (salary on the 25th, card-heavy weekends), signs and per-account running balances computed exactly via sequence-aware calculated features, and a monthly statement derived from the ledger — closing balances and totals reconciling by construction.

Example Code

"""
Example: bank-ledger generation — transactions with exact running balances,
and monthly statements derived from them.

The full FakerSynthesizer pipeline on the archetypal hard case:

  - accounts reference table: opening balances and segments carried into the
    rows via scaffold `include` (joined, never invented)
  - scaffold: account x day x intra-day sequence — every account active every
    day, volumes fixed by construction
  - conditions with CALENDAR PREDICATES: salary credits cluster on the 25th,
    card activity rises at weekends; amounts differ by category and segment
  - sequence + per-partition calculated features: the running balance is
    opening_balance + cumsum(signed_amount) PER ACCOUNT, in order — exact to
    the penny on every row
  - derived monthly statement: one row per account, with `last`-aggregated
    closing balance — reconciling with the ledger by construction.

Deterministic, reproducible under random_state, zero LLM calls.
"""
import pandas as pd

from ydata.metadata import Metadata
from ydata.metadata.builder import MetadataConfigurationBuilder
from ydata.synthesizers import FakerSynthesizer

TRANSACTIONS_PER_ACCOUNT_PER_DAY = 4
MONTH_START, MONTH_END = "2026-03-01", "2026-03-31"

# ---------------------------------------------------------------------------
# Accounts reference (in practice: read from your master)
# ---------------------------------------------------------------------------
accounts = pd.DataFrame({
    "account_id": ["ACC-1001", "ACC-1002", "ACC-1003", "ACC-1004"],
    "segment": ["retail", "retail", "business", "business"],
    "opening_balance": [2_500.00, 830.50, 40_000.00, 12_750.25],
})

CREDIT_CATEGORIES = {"salary", "incoming_transfer"}

columns = {
    "category": {"datatype": "categorical", "vartype": "string",
                 "categories": {"card_payment": 45, "groceries": 20, "utilities": 10,
                                "outgoing_transfer": 10, "incoming_transfer": 10, "salary": 5}},
    "amount": {"datatype": "numerical", "vartype": "float", "min": 1.0, "max": 100_000.0},
}
metadata = Metadata(configuration_builder=MetadataConfigurationBuilder(columns))

synth = FakerSynthesizer(locale="en")
synth.fit(
    metadata,
    table_name="ledger",
    scaffold={
        "dimensions": [
            {"column": "account_id", "from_table": "accounts",
             "from_column": "account_id", "include": ["segment", "opening_balance"]},
            {"column": "booking_date",
             "date_range": {"start": MONTH_START, "end": MONTH_END, "freq": "D"}},
            {"column": "entry_seq",
             "values": list(range(1, TRANSACTIONS_PER_ACCOUNT_PER_DAY + 1))},
        ]
    },
    reference_data={"accounts": accounts},
    sequence={"order_by": ["booking_date", "entry_seq"], "partition_by": ["account_id"]},
    conditions=[
        {   # payday: salary credits cluster on the 25th; weekends are card-heavy
            "column": "category",
            "rules": [
                {"when": {"booking_date": {"day_in_month": [25]}},
                 "values": {"salary": 0.5, "card_payment": 0.3, "groceries": 0.2}},
                {"when": {"booking_date": {"weekday_in": [5, 6]}},
                 "values": {"card_payment": 0.6, "groceries": 0.3, "incoming_transfer": 0.1}},
            ],
            # weekdays fall back to the declared category distribution
        },
        {   # amount magnitude per category and segment
            "column": "amount",
            "rules": [
                {"when": {"category": "salary"},
                 "distribution": {"distribution": "normal", "loc": 3200.0, "scale": 400.0, "min": 1200.0}},
                {"when": {"category": ["incoming_transfer", "outgoing_transfer"],
                          "segment": "business"},
                 "distribution": {"distribution": "lognormal", "mean": 8.0, "sigma": 0.8}},
                {"when": {"category": ["incoming_transfer", "outgoing_transfer"]},
                 "distribution": {"distribution": "lognormal", "mean": 5.5, "sigma": 0.9}},
                {"when": {"category": "utilities"},
                 "distribution": {"distribution": "normal", "loc": 120.0, "scale": 30.0, "min": 20.0}},
            ],
            "default": {"distribution": {"distribution": "lognormal", "mean": 3.4, "sigma": 0.8}},
        },
    ],
    calculated_features=[
        {   # sign follows the category — credits in, debits out; a rule, exact
            "calculated_features": "signed_amount",
            "function": lambda amount, category: (
                amount * category.isin(CREDIT_CATEGORIES).map({True: 1, False: -1})
            ).round(2),
            "calculated_from": ["amount", "category"]},
        {   # the running balance: per account, in order, exact to the penny
            "calculated_features": "balance",
            "function": lambda opening, signed: (opening + signed.cumsum()).round(2),
            "calculated_from": ["opening_balance", "signed_amount"],
            "per_partition": True},
    ],
    derived_tables=[
        {"name": "monthly_statement", "type": "aggregate",
         "group_by": ["account_id", "segment", "opening_balance"],
         "aggregations": {
             "n_transactions": {"column": "signed_amount", "agg": "count"},
             "total_movement": {"column": "signed_amount", "agg": "sum"},
             "closing_balance": {"column": "balance", "agg": "last"},   # order-aware
         }},
    ],
)

data = synth.sample(random_state=42)
ledger = data["ledger"].to_pandas()
statement = data["monthly_statement"].to_pandas()

# ---------------------------------------------------------------------------
# The checks an accountant would run
# ---------------------------------------------------------------------------
# running balance exact on every row, per account, in order
for _, account_rows in ledger.groupby("account_id"):
    expected = (account_rows["opening_balance"].iloc[0]
                + account_rows["signed_amount"].cumsum()).round(2)
    assert (account_rows["balance"] == expected).all()

# the statement reconciles with the ledger by construction
merged = statement.set_index("account_id")
assert (merged["closing_balance"]
        == (merged["opening_balance"] + merged["total_movement"]).round(2)).all()
assert merged["n_transactions"].sum() == len(ledger)

# calendar behavior: salary concentrates on the 25th
dates = pd.to_datetime(ledger["booking_date"])
salary_share_payday = (ledger[dates.dt.day == 25]["category"] == "salary").mean()
salary_share_rest = (ledger[dates.dt.day != 25]["category"] == "salary").mean()
assert salary_share_payday > salary_share_rest * 3

print(ledger.head(8).to_string())
print(f"\n{len(ledger)} ledger entries | {len(statement)} monthly statements")
print(statement.to_string(index=False))
print("\nAll balance, reconciliation and calendar checks passed.")