Money Supply and Quantitative Easing
This page covers the money-supply aggregates (M1/M2), open market operations (QE/QT), the NPP autonomous monetary-operations policy, and how all of it feeds back into inflation. It is a companion to Monetary System (as shipped), which covers the prime rate, FOMC, forex and lines of credit; those systems are not repeated here except where they interact directly with money supply. The whole feature is gated behind gameConfig.moneySupplyEnabled (isMoneySupplyEnabledFromConfig in ): every entry point below is a no-op when the flag is off.
M1 and M2#
calculateMoneyAggregates in sums per-currency components into two aggregates. Every component is normalized to zero first if it is negative or non-finite (money()).
- M1 = householdLiquid + campaignLiquid + nppLiquid + corporateLiquid + partyLiquid + governmentLiquid + fundLiquid + organizationLiquid.
- M2 = M1 + householdSavings + externalBroadMoney + bankDeposits.
- Bank reserves (
bankReserves) and credit outstanding are tracked and reported for diagnostics only. They are never folded into M1 or M2, to avoid double counting base money against deposits.
Where each component comes from#
snapshotMoneySupply in is the single place that walks every collection and buckets balances by currency:
| Component | Source |
|---|---|
householdLiquid / householdSavings |
characters.currencyBalances.personal / .savings (or legacy cashOnHand/savingsOnHand), plus a demographic estimate for the unplayed population (below) |
campaignLiquid |
characters.currencyBalances.campaign (or legacy character.funds) |
nppLiquid |
npps.funds, npps.currencyBalances.personal, and npps.nppInvestmentCashAnchor (booked in USD) |
corporateLiquid |
corporations.liquidCapital, keyed by the corp's liquidCurrencyCode |
partyLiquid |
politicalParties.treasury |
governmentLiquid |
federalBudget.treasuryBalance, clamped to max(0, treasuryBalance) (see below) |
fundLiquid |
indexFunds.cashAnchor, converted from the internal anchor through the live exchange-rate table |
organizationLiquid |
organizationFunds.balanceLocal (already native) |
bankDeposits / part of creditOutstanding |
chartered private banks: corporations.bankCharter.totalDeposits / .totalLoans |
creditOutstanding (player leg) |
characters.lineOfCredit.balances + .arrears |
externalBroadMoney |
centralBanks.externalBroadMoney, passed through effectiveExternalBroadMoney |
bankReserves |
centralBanks.reserveBalance (diagnostic only) |
sovereignBondsOutstanding / centralBankBondHoldings |
bonds.totalIssued / bonds.centralBankHoldings × BOND_UNIT_FACE_VALUE |
Government liquid is deliberately one-sided. governmentLiquidFromTreasury in takes max(0, treasuryBalance) because federalBudget.treasuryBalance is signed: positive is surplus cash, negative is -debt.principal. Taking the absolute value would count national debt as government deposits; an indebted treasury contributes 0 to M1 instead.
Household money for the unplayed population#
addHouseholdMoneyFromDemography derives a household money stock from population and median income, because the characters collection only ever holds players and an NPP-run world has none. Constants in :
PERSONS_PER_HOUSEHOLD = 3.2HOUSEHOLD_LIQUID_RATIO = 0.15(roughly two months of gross annual household income held as transaction balances)HOUSEHOLD_SAVINGS_RATIO = 0.6(a bit over half a year's income as time/savings deposits)
For each state with population, households = population / 3.2, annualIncome = households × medianIncome, and the two ratios split that into liquid and savings. Income is read per state where available (macroMetrics), falling back to the country's national-scope doc. The code comments state this is order-of-magnitude, not a precisely calibrated national-accounts series: a 1953 US check (Census median household income ≈ $3,900, population ≈ 158M) recovers about 60% of the seeded M2 stock from households alone, with corporates and the unmodeled residual covering the rest.
The unmodeled external residual#
externalBroadMoney on each central bank is the seed-time stand-in for the entire broad-money stock, calibrated as gdp × broadMoneyToGdpRatio(preset, countryId) (seedMoneySupplyBaselines in ). Once household/corporate/government components above are measured, counting the full seed on top would double-count. effectiveExternalBroadMoney in handles this:
baseline = externalBroadMoney - netMoneyCreatedLifetime
residual = max(0, baseline) * UNMODELED_EXTERNAL_SHARE // UNMODELED_EXTERNAL_SHARE = 0.25
effective = max(0, residual + netMoneyCreatedLifetime)
UNMODELED_EXTERNAL_SHARE = 0.25 approximates the mid-century US currency-in-circulation share of M2 (cited to Friedman & Schwartz as a stylized composition reference, not a precision calibration). netMoneyCreatedLifetime, the running total of every QE, QT, treasury advance and liquidity injection this bank has done, passes through at full face value on top of the scaled-down baseline, so central-bank operations move M2 one-for-one regardless of how much of the legacy seed has been scaled away.
Growth rate#
annualizedMoneyGrowthPct in annualizes geometrically: (closing / opening) ** (TURNS_PER_YEAR / turnsElapsed) - 1. It returns null, not 0, whenever opening <= 0, closing <= 0, or turnsElapsed < MIN_MONEY_GROWTH_BASE_TURNS = 12 (a game quarter). Annualizing a two-turn bootstrap rebase raises the ratio to the 24th power and produces a meaningless number; null lets downstream consumers fall back to GDP growth instead of reading a false "money supply is frozen."
Snapshots: when and how they are written#
snapshotMoneySupply(db, turn) is registered as the moneySupplySnapshot turn phase in , running once per turn after every value-affecting phase (immediately after index funds). It also runs on demand from seedForex.ts at world setup (turn 0) and from the monetary-operation API route after every manual action.
For each currency with a central bank, it writes one moneySupplySnapshots document (_id: "{turn}:{currencyCode}", upserted) via writeSnapshot, computing annualizedM2GrowthPct against the prior snapshot at or before turn - 12. Countries without a central bank (Warsaw Pact / non-aligned command economies excluded from forex) still accumulate real money-supply components, household demography, government treasury balance, NPP/party liquid, keyed by their own currency, so a second pass over federalBudget writes a synthetic snapshot (bankId = countryId, netMoneyCreatedLifetime: 0) for any currency not already covered by a bank. Without this pass those currencies would silently get zero snapshot rows forever, the same bug class previously found and fixed in inflationRecalc.ts's "unbanked" handling.
Open market operations: QE and QT#
planOpenMarketOperation in is the pure planning function. Given a requested unit count, it:
- Clamps requested units to what is actually available: QE is bounded by the sovereign bond's
publicFloat(units the central bank can buy off the market), QT bycentralBankHoldings(units it can sell back). - Prices the consideration at
units × BOND_UNIT_FACE_VALUE × max(0.01, marketPrice). - Reports
moneySupplyDelta: positive (money created) for QE, negative (money withdrawn) for QT, this is the consideration amount, signed. - Computes
qeSupportRatio = min(1, max(0, centralBankHoldings / totalUnits)), i.e. what fraction of the bond's total issued units the central bank now holds.
Price support. applyQePriceSupport(rateDerivedPrice, qeSupportRatio) layers a persistent demand-support premium on top of the bond's ordinary rate-derived price: support = min(0.2, qeSupportRatio * 0.5), capping the boost at 20% when the bank holds all of a bond's units, and the final price is clamped to [0.05, 2]. In executeMonetaryOperation () the actual price move applied on execution uses the change in support ratio rather than the full ratio: marketPrice = clamp(bond.marketPrice * (1 + supportDelta * 0.5), 0.05, 2), so buying more support pushes price up incrementally rather than resetting it to the full support level every operation.
Execution: executeMonetaryOperation#
handles all four MonetaryOperationType values ("qe" | "qt" | "treasury_advance" | "liquidity_injection", MonetaryPolicyDecision adds "hold"; both types in ).
QE/QT requires a specific eligible sovereign bond (issuerType: "sovereign", same country, not matured, not defaulted). QT is additionally blocked if the consideration would retire more than the bank's own externalBroadMoney ("QT would retire more external deposits than remain"). The bond's publicFloat, centralBankHoldings, qeSupportRatio and marketPrice are updated, and the bank record gets $inc'd on externalBroadMoney and netMoneyCreatedLifetime by moneySupplyDelta.
Treasury advance adds amount directly to federalBudget.treasuryBalance via an optimistic-concurrency updateOne (retries the whole operation if the budget changed concurrently), recomputes debt/interest/credit-rating fields through deriveFiscalState, and books moneySupplyDelta = amount (a treasury advance is unambiguous money creation, no offsetting bond sale).
Liquidity injection tries advanceToPrivateBanks first: if isPrivateBankingEnabled() and any chartered bank exists in this currency, the amount is distributed pro-rata by bankCharter.totalDeposits (equal split if no bank holds deposits), landing in each bank's liquidCapital and booked as bankCharter.cbMarginDebt (so it is a loan, not free money, it repays through the existing margin-repay path). If no chartered bank exists to take it, the operation falls back to the historical behavior: it buffers the central bank's own reserveBalance with moneySupplyDelta: 0, a genuine no-op for M2 in that case.
Turn-order caps and cooldowns#
From :
MONETARY_OPERATION_COOLDOWN_TURNS = 6DIRECT_ADVANCE_GDP_CAP = 0.01(treasury advance capped at 1% of GDP per action for a manual player request)LIQUIDITY_INJECTION_GDP_CAP = 0.03(liquidity injection capped at 3% of GDP)
Player and manual action: the monetary-operation API#
POST /api/country/[code]/central-bank/monetary-operation () is the human-facing entry point, surfaced in the central bank page's Money Supply tab (). It:
- Rejects with 409 if
moneySupplyEnabledis off. - Requires the bank and country's federal budget to exist (404 otherwise).
- Authorizes: admins bypass everything; otherwise the caller must be the bank's seated chair (
bank.chairCharacterId) andchairControlsLockedmust not be set, else 403. - Enforces
MONETARY_OPERATION_COOLDOWN_TURNSagainstbank.lastMonetaryOperationTurnfor non-admins (409 on cooldown). - For
treasury_advance/liquidity_injection, enforces the GDP caps above for non-admins (400 with the numeric cap on violation). - Calls
executeMonetaryOperation, thensnapshotMoneySupply(db, turn)so the UI sees an up-to-date aggregate immediately rather than waiting for the next turn's phase.
Autonomous (NPP) monetary policy#
chooseNppMonetaryOperation in is the pure decision function an autonomous (NPP-chaired) central bank uses each cycle. Inputs: current inflation, target inflation, GDP growth, annualized M2 growth (only trusted when moneyGrowthReliable, i.e. the snapshot window has reached 12 turns, otherwise excessMoneyGrowth is forced to 0), public float, bond holdings, bank reserves, GDP, and treasury balance. Decision order:
- Treasury advance if
inflationGap ≤ -3pp(deep deflation),gdpGrowth ≤ -3%, andtreasuryBalance ≤ -0.5 × GDP(acute fiscal stress),amount = max(1, floor(gdp × 0.001)). - QT if
inflationGap > 1ppORexcessMoneyGrowth > 6pp, and the bank holds bond units to sell,units = max(1, floor(holdings × 0.1)). - Hold (explicitly, with a rationale noting rate policy must do the work) if the same tightening condition fires but the bank holds no bonds.
- QE if
inflationGap < -0.5ppandgdpGrowth < 1.5%and there is public float to buy,units = max(1, floor(publicFloat × 0.01)). - Liquidity injection if
gdpGrowth < 0,inflationGap ≤ 0, andbankReserves < GDP × 0.005(thin lending reserves),amount = max(1, floor(gdp × 0.0025)). - Hold otherwise (the default, no action needed).
inflationGap = inflation - targetInflation; excessMoneyGrowth = annualizedM2GrowthPct - gdpGrowth when reliable, else 0.
Turn wiring: processNppMonetaryOperations#
Runs as the nppMonetaryOperations phase in , after centralBankChairTurn and fomcMeetings, before centralBankChairExecutiveRemoval. For every bank with chairMode: "npp" and chairControlsLocked not true:
- Skips if
turn - bank.lastMonetaryOperationTurn < MONETARY_OPERATION_COOLDOWN_TURNS(6 turns). - Pulls the country's federal budget, the most recent eligible sovereign bond (has public float or CB holdings, sorted latest maturity first), and the latest money-supply snapshot for the currency.
- Feeds
chooseNppMonetaryOperation, then (if not a hold) callsexecuteMonetaryOperationwithactorName: "{bankId} Monetary Committee". - Always writes
centralBanks.lastMonetaryPolicyEvaluation(the full decision, rationale, and inputs) regardless of whether an operation executed, so every cycle's reasoning is visible even on a hold.
Feedback into inflation#
Money-supply growth is one of thirteen additive pressure terms in calculateInflationWithBreakdown (). inflationRecalc.ts () reads the latest moneySupplySnapshots row per currency (turn < currentTurn, most recent) into moneyGrowthByCurrency, then passes moneySupplyGrowthPct = finiteOr(moneyGrowthByCurrency.get(currencyCode), gdpGrowth) into the inflation calc, a null growth reading (window too short) falls back to GDP growth, i.e. a zero monetary impulse, rather than asserting the money supply is frozen.
Inside calculateInflationWithBreakdown:
moneySupply = clamp(-1.5, 2.5, (moneySupplyGrowthPct - gdpGrowth) * 0.08)
Excess M2 growth over real GDP growth contributes up to +2.5pp of inflation; a money supply contracting faster than GDP contributes down to -1.5pp. This term is one addend among unemployment (Phillips curve), the prime-rate gap (monetary, a separate term from expected-inflation channel, not to be confused with this money-supply term), fiscal deficit, tariffs, wages, commodities, forex, savings flow, housing, and discretionary policy stance, all summed into rawInflation, then smoothed with inertia and pulled toward target by mean reversion. Money supply is a real but bounded driver, not the dominant one: its ±pp cap (-1.5 to +2.5) is comparable in size to several of the other cost-push terms, not larger.
Key Files#
- ,
calculateMoneyAggregates,annualizedMoneyGrowthPct,MIN_MONEY_GROWTH_BASE_TURNS - , component helpers, household demography derivation,
effectiveExternalBroadMoney,UNMODELED_EXTERNAL_SHARE - ,
snapshotMoneySupply,MONEY_SUPPLY_SNAPSHOTS_COLLECTION - ,
seedMoneySupplyBaselines, seedsexternalBroadMoneyfrombroadMoneyToGdpRatio - ,
planOpenMarketOperation,applyQePriceSupport - ,
executeMonetaryOperation,advanceToPrivateBanks, cooldown/cap constants - ,
chooseNppMonetaryOperation,processNppMonetaryOperations - ,
isMoneySupplyEnabledFromConfig - ,
MoneySupplySnapshot,MonetaryOperationRecord,MonetaryOperationType,MonetaryPolicyEvaluation - ,
moneySupplySnapshotturn phase registration - ,
nppMonetaryOperationsturn phase registration - , player/chair-facing manual operation endpoint
- , UI for manual QE/QT/advance/injection actions
- , reads
annualizedM2GrowthPctper currency into the inflation calc - ,
calculateInflationWithBreakdown, themoneySupplypressure term