Balance Sheet Variance Analysis — Fake, Inc. FY2026¶
A month-over-month analysis of every balance sheet account, built to catch the kind of unexplained swing a controller would want to understand before an auditor asks about it — not after.
This notebook takes the general ledger (Allocated Transactions) and Chart of Accounts from the Fake, Inc. Excel model, builds a monthly trial balance for every account, computes month-over-month change, and flags anything that moves more than expected.
Why this matters: a balance that jumps 40% in one month isn't necessarily wrong — but it's exactly the kind of thing that should have an answer ready before someone asks. This is the Python-native version of the disciplined reconciliation habit that shows up throughout the Excel workbook.
1. Load the data¶
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
pio.renderers.default = "notebook"
pd.set_option('display.float_format', lambda x: f'{x:,.2f}')
transactions = pd.read_csv('allocated_transactions.csv')
coa = pd.read_csv('chart_of_accounts.csv')
transactions['Date'] = pd.to_datetime(transactions['Date'])
transactions['Month'] = transactions['Date'].dt.to_period('M')
print(f"{len(transactions):,} transaction lines across {transactions['Month'].nunique()} months")
transactions.head()
1,933 transaction lines across 13 months
| Date | JE Number | Cost Center | Account Number | Account Name | Account Type | Description | Debit | Credit | Total | Source | Month | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2026-05-30 | JE-1345 | 2,010.00 | 1,110.00 | Grants Receivable | Asset | Signed Latin American restricted grant | 70,000.00 | NaN | 70,000.00 | GL | 2026-05 |
| 1 | 2026-05-30 | JE-1345 | 2,010.00 | 3,100.00 | Net Assets - Temporarily Restricted | Equity | Signed Latin American restricted grant | NaN | 70,000.00 | -70,000.00 | GL | 2026-05 |
| 2 | 2025-07-01 | JE-1321 | 2,010.00 | 3,100.00 | Net Assets - Temporarily Restricted | Equity | Reclassify restricted grant revenue to 3100 - ... | NaN | 36,609.78 | -36,609.78 | GL | 2025-07 |
| 3 | 2025-07-01 | JE-1321 | 2,020.00 | 3,100.00 | Net Assets - Temporarily Restricted | Equity | Reclassify restricted grant revenue to 3100 - ... | NaN | 27,465.59 | -27,465.59 | GL | 2025-07 |
| 4 | 2025-07-01 | JE-1321 | 2,030.00 | 3,100.00 | Net Assets - Temporarily Restricted | Equity | Reclassify restricted grant revenue to 3100 - ... | NaN | 27,465.59 | -27,465.59 | GL | 2025-07 |
2. Build a monthly trial balance¶
For every account, every month, we need a running ending balance — not just the annual total. Each account's balance is its prior month's ending balance, plus that month's activity, signed according to whether the account is normally a debit or credit balance.
# Merge in each account's normal balance side from the Chart of Accounts
coa_lookup = coa.set_index('Account Number')[['Account Name', 'Account Type', 'Normal Balance']]
txn = transactions.merge(coa_lookup, left_on='Account Number', right_index=True, how='left',
suffixes=('', '_coa'))
# Fill missing Debit/Credit with 0 BEFORE the signed calculation -- using
# `or 0` against a NaN value does not work as expected in pandas (NaN is
# truthy), so we explicitly fillna first.
txn['Debit'] = txn['Debit'].fillna(0)
txn['Credit'] = txn['Credit'].fillna(0)
# Signed monthly activity: Debit-normal accounts increase with Debit, decrease with Credit; reverse for Credit-normal
txn['Signed Activity'] = txn.apply(
lambda r: r['Debit'] - r['Credit'] if r['Normal Balance'] == 'Debit'
else r['Credit'] - r['Debit'],
axis=1
)
# Only balance sheet accounts (Asset, Liability, Equity) for this analysis
bs_accounts = coa[coa['Account Type'].isin(['Asset', 'Liability', 'Equity'])]['Account Number'].tolist()
bs_txn = txn[txn['Account Number'].isin(bs_accounts)]
monthly_activity = bs_txn.groupby(['Account Number', 'Month'])['Signed Activity'].sum().reset_index()
monthly_activity.head(10)
| Account Number | Month | Signed Activity | |
|---|---|---|---|
| 0 | 1,000.00 | 2025-06 | 185,000.00 |
| 1 | 1,000.00 | 2025-07 | 206,111.20 |
| 2 | 1,000.00 | 2025-08 | 226,349.97 |
| 3 | 1,000.00 | 2025-09 | 196,242.38 |
| 4 | 1,000.00 | 2025-10 | 181,172.45 |
| 5 | 1,000.00 | 2025-11 | 234,430.67 |
| 6 | 1,000.00 | 2025-12 | 234,769.98 |
| 7 | 1,000.00 | 2026-01 | 116,048.00 |
| 8 | 1,000.00 | 2026-02 | 318,325.35 |
| 9 | 1,000.00 | 2026-03 | 227,885.67 |
# Pivot to one row per account, one column per month, then take a running
# cumulative sum to get ENDING balance each month (not just that month's activity)
pivot = monthly_activity.pivot(index='Account Number', columns='Month', values='Signed Activity').fillna(0)
pivot = pivot.sort_index(axis=1)
running_balance = pivot.cumsum(axis=1)
running_balance = running_balance.merge(coa_lookup[['Account Name']], left_index=True, right_index=True)
running_balance.round(2)
| 2025-06 | 2025-07 | 2025-08 | 2025-09 | 2025-10 | 2025-11 | 2025-12 | 2026-01 | 2026-02 | 2026-03 | 2026-04 | 2026-05 | 2026-06 | Account Name | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Account Number | ||||||||||||||
| 1,000.00 | 185,000.00 | 391,111.20 | 617,461.17 | 813,703.55 | 994,876.00 | 1,229,306.67 | 1,464,076.65 | 1,580,124.65 | 1,898,450.00 | 2,126,335.67 | 2,305,246.13 | 2,492,699.52 | 2,698,103.45 | Cash - Operating |
| 1,010.00 | 42,000.00 | -107,196.88 | -258,842.16 | -414,598.36 | -571,650.70 | -723,375.75 | -877,719.80 | -1,030,459.60 | -1,187,083.23 | -1,340,544.79 | -1,495,261.85 | -1,639,860.09 | -1,791,166.35 | Cash - Payroll |
| 1,020.00 | 250,000.00 | 250,977.64 | 251,751.58 | 252,581.90 | 253,350.96 | 254,213.13 | 255,182.11 | 256,073.91 | 256,839.09 | 257,751.75 | 258,591.75 | 259,623.43 | 260,638.36 | Cash - Money Market/Reserve |
| 1,100.00 | 38,500.00 | 149,351.02 | 149,351.02 | 149,351.02 | 228,902.22 | 228,902.22 | 228,902.22 | 318,875.24 | 318,875.24 | 318,875.24 | 415,893.56 | 415,893.56 | 415,893.56 | Accounts Receivable - Trade |
| 1,110.00 | 96,000.00 | 82,449.09 | 80,523.58 | 89,691.22 | 92,922.89 | 72,485.31 | 83,860.20 | 83,860.20 | 63,309.01 | 38,832.55 | 41,687.54 | 124,888.04 | 123,166.75 | Grants Receivable |
| 1,200.00 | 0.00 | 5,399.99 | 2,699.98 | -0.03 | 5,399.96 | 2,699.95 | -0.06 | 5,399.93 | 2,699.92 | -0.08 | 5,399.92 | 2,699.92 | -0.08 | Prepaid Expenses |
| 1,300.00 | 145,000.00 | 145,000.00 | 145,000.00 | 163,500.00 | 163,500.00 | 163,500.00 | 163,500.00 | 163,500.00 | 163,500.00 | 163,500.00 | 163,500.00 | 163,500.00 | 163,500.00 | Property and Equipment |
| 1,310.00 | 62,000.00 | 64,450.00 | 66,900.00 | 69,350.00 | 71,800.00 | 74,250.00 | 76,700.00 | 79,150.00 | 81,600.00 | 84,050.00 | 86,500.00 | 88,950.00 | 91,400.00 | Accumulated Depreciation |
| 1,400.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | 8,000.00 | Security Deposits |
| 2,000.00 | 23,763.80 | 8,497.15 | 13,194.33 | 26,140.95 | 37,242.58 | 44,015.34 | 53,618.32 | 64,809.74 | 70,490.20 | 81,192.52 | 94,072.07 | 100,407.53 | 112,376.71 | Accounts Payable |
| 2,010.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | 31,000.00 | Accrued Payroll |
| 2,020.00 | 18,500.00 | 36,455.40 | 58,090.82 | 80,376.42 | 103,877.15 | 132,984.59 | 161,583.30 | 195,584.92 | 234,109.16 | 270,891.44 | 309,105.83 | 346,780.16 | 388,112.32 | Accrued Vacation/PTO |
| 2,100.00 | 41,000.00 | 41,000.00 | 41,000.00 | 41,000.00 | 41,000.00 | 41,000.00 | 41,000.00 | 41,000.00 | 41,000.00 | 41,000.00 | 41,000.00 | 16,000.00 | 16,000.00 | Deferred Revenue |
| 2,200.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | 22,000.00 | Refundable Grant Advances |
| 2,300.00 | 15,000.00 | 15,000.00 | 15,000.00 | 15,000.00 | 15,000.00 | 15,000.00 | 15,000.00 | 15,000.00 | 55,000.00 | 55,000.00 | 55,000.00 | 55,000.00 | 55,000.00 | Notes Payable - Current |
| 2,400.00 | 85,000.00 | 83,820.00 | 82,640.00 | 81,460.00 | 80,280.00 | 79,100.00 | 77,920.00 | 76,740.00 | 75,560.00 | 74,380.00 | 73,200.00 | 72,020.00 | 70,840.00 | Notes Payable - Long Term |
| 3,000.00 | 310,000.00 | 265,179.30 | 310,000.00 | 310,000.00 | 290,187.70 | 310,000.00 | 310,000.00 | 310,000.00 | 310,000.00 | 310,000.00 | 297,362.14 | 310,000.00 | 310,000.00 | Net Assets - Unrestricted |
| 3,100.00 | 138,300.00 | 183,120.70 | 138,300.00 | 138,300.00 | 158,112.30 | 138,300.00 | 138,300.00 | 138,300.00 | 138,300.00 | 138,300.00 | 150,937.86 | 208,300.00 | 208,300.00 | Net Assets - Temporarily Restricted |
3. Compute month-over-month variance¶
For every account, every month: dollar change versus the prior month.
month_cols = [c for c in running_balance.columns if c != 'Account Name']
dollar_change = running_balance[month_cols].diff(axis=1)
dollar_change.insert(0, 'Account Name', running_balance['Account Name'])
dollar_change.round(2)
| Account Name | 2025-06 | 2025-07 | 2025-08 | 2025-09 | 2025-10 | 2025-11 | 2025-12 | 2026-01 | 2026-02 | 2026-03 | 2026-04 | 2026-05 | 2026-06 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Account Number | ||||||||||||||
| 1,000.00 | Cash - Operating | NaN | 206,111.20 | 226,349.97 | 196,242.38 | 181,172.45 | 234,430.67 | 234,769.98 | 116,048.00 | 318,325.35 | 227,885.67 | 178,910.46 | 187,453.39 | 205,403.93 |
| 1,010.00 | Cash - Payroll | NaN | -149,196.88 | -151,645.28 | -155,756.20 | -157,052.34 | -151,725.05 | -154,344.05 | -152,739.80 | -156,623.63 | -153,461.56 | -154,717.06 | -144,598.24 | -151,306.26 |
| 1,020.00 | Cash - Money Market/Reserve | NaN | 977.64 | 773.94 | 830.32 | 769.06 | 862.17 | 968.98 | 891.80 | 765.18 | 912.66 | 840.00 | 1,031.68 | 1,014.93 |
| 1,100.00 | Accounts Receivable - Trade | NaN | 110,851.02 | 0.00 | 0.00 | 79,551.20 | 0.00 | 0.00 | 89,973.02 | 0.00 | 0.00 | 97,018.32 | 0.00 | 0.00 |
| 1,110.00 | Grants Receivable | NaN | -13,550.91 | -1,925.51 | 9,167.64 | 3,231.67 | -20,437.58 | 11,374.89 | 0.00 | -20,551.19 | -24,476.46 | 2,854.99 | 83,200.50 | -1,721.29 |
| 1,200.00 | Prepaid Expenses | NaN | 5,399.99 | -2,700.01 | -2,700.01 | 5,399.99 | -2,700.01 | -2,700.01 | 5,399.99 | -2,700.01 | -2,700.00 | 5,400.00 | -2,700.00 | -2,700.00 |
| 1,300.00 | Property and Equipment | NaN | 0.00 | 0.00 | 18,500.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 1,310.00 | Accumulated Depreciation | NaN | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 | 2,450.00 |
| 1,400.00 | Security Deposits | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 2,000.00 | Accounts Payable | NaN | -15,266.65 | 4,697.18 | 12,946.62 | 11,101.63 | 6,772.76 | 9,602.98 | 11,191.42 | 5,680.46 | 10,702.32 | 12,879.55 | 6,335.46 | 11,969.18 |
| 2,010.00 | Accrued Payroll | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 2,020.00 | Accrued Vacation/PTO | NaN | 17,955.40 | 21,635.42 | 22,285.60 | 23,500.74 | 29,107.44 | 28,598.71 | 34,001.63 | 38,524.23 | 36,782.28 | 38,214.39 | 37,674.33 | 41,332.15 |
| 2,100.00 | Deferred Revenue | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | -25,000.00 | 0.00 |
| 2,200.00 | Refundable Grant Advances | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 2,300.00 | Notes Payable - Current | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 40,000.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 2,400.00 | Notes Payable - Long Term | NaN | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 | -1,180.00 |
| 3,000.00 | Net Assets - Unrestricted | NaN | -44,820.70 | 44,820.70 | 0.00 | -19,812.30 | 19,812.30 | 0.00 | 0.00 | 0.00 | 0.00 | -12,637.86 | 12,637.86 | 0.00 |
| 3,100.00 | Net Assets - Temporarily Restricted | NaN | 44,820.70 | -44,820.70 | 0.00 | 19,812.30 | -19,812.30 | 0.00 | 0.00 | 0.00 | 0.00 | 12,637.86 | 57,362.14 | 0.00 |
4. Flag unusual swings¶
A fixed dollar threshold doesn't work equally well across every account — a $5,000 swing means something very different for Petty Cash than it does for Accounts Payable. Instead, flag any month where an account's change exceeds 1.5 standard deviations from that account's own average month-over-month change across the year. This catches genuinely unusual months relative to each account's normal behavior, not just large accounts in general.
Some accounts (like a note payable with a perfectly flat monthly payment) will have zero variance and are correctly excluded — there's nothing to flag when a balance moves the exact same amount every month.
flags = []
for acct in dollar_change.index:
changes = dollar_change.loc[acct, month_cols].dropna()
if len(changes) < 3:
continue
mean, std = changes.mean(), changes.std()
if std == 0 or pd.isna(std):
continue
for month, val in changes.items():
z = (val - mean) / std
if abs(z) > 1.5:
flags.append({
'Account Number': acct,
'Account Name': running_balance.loc[acct, 'Account Name'],
'Month': str(month),
'Change ($)': round(val, 2),
'Z-score': round(z, 2),
})
flags_df = pd.DataFrame(flags)
if len(flags_df):
flags_df = flags_df.sort_values('Z-score', key=lambda s: s.abs(), ascending=False).reset_index(drop=True)
print(f"{len(flags_df)} flagged account-months out of {len(dollar_change) * len(month_cols)} total")
flags_df
15 flagged account-months out of 234 total
| Account Number | Account Name | Month | Change ($) | Z-score | |
|---|---|---|---|---|---|
| 0 | 1,300.00 | Property and Equipment | 2025-09 | 18,500.00 | 3.18 |
| 1 | 2,100.00 | Deferred Revenue | 2026-05 | -25,000.00 | -3.18 |
| 2 | 2,300.00 | Notes Payable - Current | 2026-02 | 40,000.00 | 3.18 |
| 3 | 2,000.00 | Accounts Payable | 2025-07 | -15,266.65 | -2.94 |
| 4 | 1,110.00 | Grants Receivable | 2026-05 | 83,200.50 | 2.87 |
| 5 | 1,010.00 | Cash - Payroll | 2026-05 | -144,598.24 | 2.35 |
| 6 | 1,000.00 | Cash - Operating | 2026-02 | 318,325.35 | 2.29 |
| 7 | 3,000.00 | Net Assets - Unrestricted | 2025-08 | 44,820.70 | 2.08 |
| 8 | 3,000.00 | Net Assets - Unrestricted | 2025-07 | -44,820.70 | -2.08 |
| 9 | 1,000.00 | Cash - Operating | 2026-01 | 116,048.00 | -1.96 |
| 10 | 3,100.00 | Net Assets - Temporarily Restricted | 2026-05 | 57,362.14 | 1.93 |
| 11 | 3,100.00 | Net Assets - Temporarily Restricted | 2025-08 | -44,820.70 | -1.90 |
| 12 | 1,100.00 | Accounts Receivable - Trade | 2025-07 | 110,851.02 | 1.69 |
| 13 | 2,020.00 | Accrued Vacation/PTO | 2025-07 | 17,955.40 | -1.61 |
| 14 | 1,020.00 | Cash - Money Market/Reserve | 2026-05 | 1,031.68 | 1.52 |
5. Visualize a flagged account¶
Grants Receivable is a strong example of what this analysis is built to catch: a genuine, explainable event rather than noise. The account jumps by $83,200.50 in May 2026 — a real grant recognized and billed to a funder that had not yet been collected as cash. That kind of swing is exactly what a controller should be able to explain in one sentence during a close review, and this analysis surfaces it automatically instead of waiting for someone to notice it by eye.
def plot_account(acct_number, running_balance, flags_df, month_cols):
name = running_balance.loc[acct_number, 'Account Name']
y_vals = running_balance.loc[acct_number, month_cols].values
x_vals = [str(m) for m in month_cols]
fig = go.Figure()
fig.add_trace(go.Scatter(
x=x_vals, y=y_vals, mode='lines+markers', name='Ending Balance',
line=dict(color='#1F4E78', width=3),
hovertemplate='%{x}<br>Ending Balance: $%{y:,.0f}<extra></extra>',
))
acct_flags = flags_df[flags_df['Account Number'] == acct_number] if len(flags_df) else pd.DataFrame()
if len(acct_flags):
flag_y = [running_balance.loc[acct_number, pd.Period(m)] for m in acct_flags['Month']]
fig.add_trace(go.Scatter(
x=acct_flags['Month'], y=flag_y, mode='markers', name='Flagged month',
marker=dict(color='#C0392B', size=13, symbol='x'),
hovertemplate='%{x}<br>Flagged change<extra></extra>',
))
fig.update_layout(
title=dict(text=f'{acct_number:.0f} — {name}: Monthly Ending Balance', font=dict(size=18)),
xaxis_title='Month', yaxis_title='Ending Balance ($)', yaxis_tickformat='$,.0f',
template='plotly_white', height=480, margin=dict(l=60, r=30, t=60, b=50),
)
return fig
fig = plot_account(1110.0, running_balance, flags_df, month_cols)
fig.write_html('balance_sheet_variance_chart.html', include_plotlyjs='cdn', full_html=True)
fig.show()
What this catches that a static spreadsheet doesn't¶
A single annual Trial Balance shows you the beginning and ending position — it can't show you when something moved or whether that movement was normal for that account. This notebook rebuilds the full monthly path for every balance sheet account and statistically flags the months worth asking about, which is the actual question a controller needs answered before close: not "does the balance sheet balance" (it always should), but "does every material movement have an explanation I could give an auditor right now."