From abed763a4f35cac8dff589c04dccc66f10a11ed5 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sun, 22 Feb 2026 23:39:47 -0800 Subject: [PATCH] Initial commit: Four-Quadrant Balance Sheet Matrix (FQBM) framework Co-authored-by: Cursor --- .gitignore | 39 +++ README.md | 52 ++++ data/.gitkeep | 1 + docs/GAPS_AND_MISSING.md | 152 ++++++++++++ docs/IPSAS_COMPLIANCE.md | 144 +++++++++++ docs/REFERENCES.md | 17 ++ docs/framework_summary.md | 37 +++ fqbm_workbook.xlsx | Bin 0 -> 11985 bytes pyproject.toml | 28 +++ requirements.txt | 15 ++ scripts/streamlit_dashboard.py | 60 +++++ src/fqbm/__init__.py | 9 + src/fqbm/empirical/__init__.py | 19 ++ src/fqbm/empirical/regressions.py | 84 +++++++ src/fqbm/ipsas/__init__.py | 32 +++ src/fqbm/ipsas/presentation.py | 330 +++++++++++++++++++++++++ src/fqbm/matrix.py | 59 +++++ src/fqbm/scenarios/__init__.py | 9 + src/fqbm/scenarios/presets.py | 162 ++++++++++++ src/fqbm/sheets/__init__.py | 21 ++ src/fqbm/sheets/capital_stress.py | 62 +++++ src/fqbm/sheets/cbdc.py | 30 +++ src/fqbm/sheets/ccp.py | 73 ++++++ src/fqbm/sheets/central_bank.py | 38 +++ src/fqbm/sheets/commercial_bank.py | 42 ++++ src/fqbm/sheets/commodity.py | 36 +++ src/fqbm/sheets/dashboard.py | 55 +++++ src/fqbm/sheets/fx_parity.py | 52 ++++ src/fqbm/sheets/monte_carlo.py | 119 +++++++++ src/fqbm/sheets/shadow_banking.py | 102 ++++++++ src/fqbm/sheets/sovereign_debt.py | 46 ++++ src/fqbm/state.py | 91 +++++++ src/fqbm/system/__init__.py | 5 + src/fqbm/system/differential_model.py | 89 +++++++ src/fqbm/workbook/__init__.py | 5 + src/fqbm/workbook/runner.py | 170 +++++++++++++ tests/__init__.py | 1 + tests/test_capital_stress.py | 24 ++ tests/test_central_bank.py | 23 ++ tests/test_commercial_bank.py | 31 +++ tests/test_commodity.py | 32 +++ tests/test_differential_model.py | 30 +++ tests/test_fx_parity.py | 42 ++++ tests/test_ipsas.py | 111 +++++++++ tests/test_matrix.py | 32 +++ tests/test_regressions.py | 33 +++ tests/test_scenarios.py | 29 +++ tests/test_sovereign_debt.py | 31 +++ tests/test_state_and_ipsas_remnants.py | 61 +++++ tests/test_stubs.py | 93 +++++++ tests/test_workbook.py | 65 +++++ 51 files changed, 2923 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/.gitkeep create mode 100644 docs/GAPS_AND_MISSING.md create mode 100644 docs/IPSAS_COMPLIANCE.md create mode 100644 docs/REFERENCES.md create mode 100644 docs/framework_summary.md create mode 100644 fqbm_workbook.xlsx create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 scripts/streamlit_dashboard.py create mode 100644 src/fqbm/__init__.py create mode 100644 src/fqbm/empirical/__init__.py create mode 100644 src/fqbm/empirical/regressions.py create mode 100644 src/fqbm/ipsas/__init__.py create mode 100644 src/fqbm/ipsas/presentation.py create mode 100644 src/fqbm/matrix.py create mode 100644 src/fqbm/scenarios/__init__.py create mode 100644 src/fqbm/scenarios/presets.py create mode 100644 src/fqbm/sheets/__init__.py create mode 100644 src/fqbm/sheets/capital_stress.py create mode 100644 src/fqbm/sheets/cbdc.py create mode 100644 src/fqbm/sheets/ccp.py create mode 100644 src/fqbm/sheets/central_bank.py create mode 100644 src/fqbm/sheets/commercial_bank.py create mode 100644 src/fqbm/sheets/commodity.py create mode 100644 src/fqbm/sheets/dashboard.py create mode 100644 src/fqbm/sheets/fx_parity.py create mode 100644 src/fqbm/sheets/monte_carlo.py create mode 100644 src/fqbm/sheets/shadow_banking.py create mode 100644 src/fqbm/sheets/sovereign_debt.py create mode 100644 src/fqbm/state.py create mode 100644 src/fqbm/system/__init__.py create mode 100644 src/fqbm/system/differential_model.py create mode 100644 src/fqbm/workbook/__init__.py create mode 100644 src/fqbm/workbook/runner.py create mode 100644 tests/__init__.py create mode 100644 tests/test_capital_stress.py create mode 100644 tests/test_central_bank.py create mode 100644 tests/test_commercial_bank.py create mode 100644 tests/test_commodity.py create mode 100644 tests/test_differential_model.py create mode 100644 tests/test_fx_parity.py create mode 100644 tests/test_ipsas.py create mode 100644 tests/test_matrix.py create mode 100644 tests/test_regressions.py create mode 100644 tests/test_scenarios.py create mode 100644 tests/test_sovereign_debt.py create mode 100644 tests/test_state_and_ipsas_remnants.py create mode 100644 tests/test_stubs.py create mode 100644 tests/test_workbook.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cda0f31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Bytecode +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual env +.venv/ +venv/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Build / dist +build/ +dist/ +*.egg-info/ +*.egg + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Data (optional) +data/*.csv +data/*.xlsx +!data/.gitkeep + +# Jupyter +.ipynb_checkpoints/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..14de558 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Four-Quadrant Balance Sheet Matrix (FQBM) + +A unified institutional framework for monetary, fiscal, financial, and open-economy dynamics. This codebase implements the full FQBM from the white paper: simulation workbook (eight sheets), Monte Carlo stress engine, differential system model, and empirical regression suite. + +## Install + +```bash +cd FOUR-QUADRANT_BALANCE_SHEET_MATRIX +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e . +``` + +Optional: `pip install pytest` for tests. + +## Run + +- **Workbook (all sheets)**: + `python -m fqbm.workbook.runner` + Or from code: `from fqbm.workbook.runner import run_workbook` then `run_workbook(initial_state=..., mc_runs=100, export_path="out.xlsx")`. +- **Monte Carlo**: `from fqbm.sheets.monte_carlo import run_n_simulations` +- **Differential model**: `from fqbm.system.differential_model import solve_trajectory, check_stability` +- **Regressions**: `from fqbm.empirical.regressions import run_inflation_pass_through, run_sovereign_spread, run_capital_flow_sensitivity` + +## Test + +```bash +pytest tests/ -v +``` + +## Optional: Streamlit dashboard + +```bash +pip install streamlit +streamlit run scripts/streamlit_dashboard.py +``` + +## Optional: Historical scenarios (Part XI) + +Use preset scenarios in code: `run_workbook(scenario="asia_1997")` or `"gfc_2008"`, `"pandemic_2020"`, `"rate_shock_2022"`. See `fqbm.scenarios`. + +## References + +See [docs/REFERENCES.md](docs/REFERENCES.md) for Chicago Author–Date references (Adrian & Shin, BIS, Dornbusch, Fed H.4.1, IMF, McLeay et al., Obstfeld & Rogoff, etc.). + +## IPSAS compliance + +The framework includes an **IPSAS-aligned presentation layer** (IPSAS 1 statement of financial position, IPSAS 24 budget vs actual structure). Use `fqbm.ipsas.presentation.statement_of_financial_position(state, entity="central_bank"|"commercial_bank"|"consolidated")` and `budget_vs_actual_structure()`. Excel export adds IPSAS-style sheets when available. See [docs/IPSAS_COMPLIANCE.md](docs/IPSAS_COMPLIANCE.md) for the compliance assessment. **[docs/GAPS_AND_MISSING.md](docs/GAPS_AND_MISSING.md)** lists IPSAS standards and FQBM gaps. Additional helpers: **notes_to_financial_statements_structure**, **statement_of_financial_position_comparative**, **maturity_risk_disclosure_structure**, **cash_flow_from_state_changes**, **fx_translate**. Part VII: **repo_multiplier**, **margin_spiral_simulation**. Part VIII: **variation_margin_flow**, **ccp_clearing_simulation**. + +## Framework + +See [docs/framework_summary.md](docs/framework_summary.md) and the white paper for the theoretical foundation (Parts I–XVI). diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/GAPS_AND_MISSING.md b/docs/GAPS_AND_MISSING.md new file mode 100644 index 0000000..913cb2b --- /dev/null +++ b/docs/GAPS_AND_MISSING.md @@ -0,0 +1,152 @@ +# Complete IPSAS and FQBM Matrix — Gaps and Missing + +This document lists **all remaining IPSAS standards** and **all FQBM/Matrix gaps and missing elements** as of the current codebase. It extends [IPSAS_COMPLIANCE.md](IPSAS_COMPLIANCE.md). + +--- + +## Part A — IPSAS: Full standards list and status + +| # | Standard | Status | Gap / note | +|---|----------|--------|-------------| +| **CF** | Conceptual Framework | Partial | A = L + E aligned; no revenue/expense/definition of surplus. | +| **1** | Presentation of Financial Statements | Supported | Structure + current/non-current in `fqbm.ipsas.presentation`. | +| **2** | Cash Flow Statements | Structure only | Template in `cash_flow_statement_structure()`; no activity classification from FQBM. | +| **3** | Accounting Policies, Changes in Estimates and Errors | **Missing** | No policy note, prior-period adjustment, or error correction. | +| **4** | Effects of Changes in Foreign Exchange Rates | Partial | S and pass-through only; no translation of FC assets/liabilities, no FX gain/loss disclosure. | +| **5** | Borrowing Costs | **Missing** | No borrowing-cost capitalisation or expense classification. | +| **6** | Consolidated and Separate Financial Statements | Partial | Sectoral (CB, bank) and consolidated view in presentation; no control definition or full consolidation mechanics. | +| **7** | Investments in Associates | **Missing** | Not applicable to core FQBM; no equity method. | +| **8** | Interests in Joint Ventures | **Missing** | Not applicable; no joint arrangement. | +| **9** | Revenue from Exchange Transactions | **Missing** | No revenue recognition or measurement. | +| **10** | Financial Reporting in Hyperinflationary Economies | **Missing** | No hyperinflation restatement (FQBM has inflation π but not restatement). | +| **11** | Construction Contracts | **Missing** | Not applicable to monetary/financial core. | +| **12** | Inventories | **Missing** | Not applicable. | +| **13** | Leases | **Missing** | Not applicable to core; no right-of-use asset/lease liability. | +| **14** | Events After the Reporting Date | **Missing** | No adjustment vs disclosure classification. | +| **15** | Financial Instruments: Disclosure and Presentation | Partial | Presentation only; no disclosure (maturity, risk). | +| **16** | Investment Property | **Missing** | Not applicable. | +| **17** | Property, Plant, and Equipment | **Missing** | Not applicable; no PP&E or depreciation. | +| **18** | Segment Reporting | **Missing** | No segment definition or segment P&L. | +| **19** | Provisions, Contingent Liabilities and Contingent Assets | **Missing** | No provisions or contingencies. | +| **20** | Related Party Disclosures | **Missing** | No related party identification or disclosure. | +| **21** | Impairment of Non-Cash-Generating Assets | **Missing** | Not applicable to financial core. | +| **22** | Disclosure of Financial Information About the General Government Sector | Partial | Sectoral structure supported; GGS boundary and entity list external. | +| **23** | Revenue from Non-Exchange Transactions | **Missing** | No tax/transfer revenue. | +| **24** | Presentation of Budget Information in Financial Statements | Structure only | Template in `budget_vs_actual_structure()`; budget data external. | +| **25** | Employee Benefits | **Missing** | Not applicable to core. | +| **26** | Impairment of Cash-Generating Assets | **Missing** | No impairment model. | +| **27** | Agriculture | **Missing** | Not applicable. | +| **28** | Financial Instruments: Presentation | Partial | Asset/liability split in statement of financial position. | +| **29** | Financial Instruments: Recognition and Measurement | **Missing** | No measurement basis (amortised cost, FVOCI, FVPL) or ECL. | +| **30** | Financial Instruments: Disclosures | **Missing** | No risk or maturity disclosure. | +| **31** | Intangible Assets | **Missing** | Not applicable. | +| **32** | Service Concession Arrangements: Grantor | **Missing** | Not applicable. | +| **33** | First-Time Adoption of Accrual Basis IPSAS | **Missing** | Not applicable (no full IPSAS adoption). | +| **34** | Separate Financial Statements | Partial | Entity-level (CB, bank) supported. | +| **35** | Consolidated Financial Statements | Partial | Consolidated layout; no full consolidation rules. | +| **36** | Investments in Associates and Joint Ventures | **Missing** | Not applicable. | +| **37** | Joint Arrangements | **Missing** | Not applicable. | +| **38** | Disclosure of Interests in Other Entities | **Missing** | No disclosure. | +| **39** | Employee Benefits | **Missing** | Not applicable. | +| **40** | Public Sector Combinations | **Missing** | Not applicable. | +| **41** | Financial Instruments | **Missing** | No classification, measurement, or ECL. | +| **42** | Social Benefits | **Missing** | Not applicable. | +| **43** | Leases | **Missing** | Not applicable. | +| **44** | Non-Current Assets Held for Sale and Discontinued Operations | **Missing** | Not applicable. | +| **45** | Property, Plant, and Equipment | **Missing** | Not applicable. | +| **46** | Measurement | **Missing** | No measurement basis (historical, FV, etc.). | +| **47** | Revenue | **Missing** | No revenue model. | +| **48** | Transfer Expenses | **Missing** | No transfer expense. | +| **49** | Retirement Benefit Plans | **Missing** | Not applicable. | +| **50** | Exploration for and Evaluation of Mineral Resources | **Missing** | Not applicable. | +| **Cash basis** | Financial Reporting Under the Cash Basis | **Missing** | No cash-basis statement. | +| **RPG 1** | Long-Term Sustainability of an Entity's Finances | Partial | Debt sustainability (r−g) in sovereign module; no full RPG 1 disclosure. | +| **RPG 2** | Financial Statement Discussion and Analysis | **Missing** | No narrative. | +| **RPG 3** | Reporting Service Performance Information | **Missing** | Not applicable. | +| **SRS 1** | Climate-related Disclosures | **Missing** | Not applicable. | + +--- + +## Part B — FQBM / Four-Quadrant Matrix: Gaps and missing + +### B.1 State vector and identities + +| Item | Status | Note | +|------|--------|------| +| **L_cb (central bank loans)** | **Added** | `FQBMState.L_cb` (default 0); central_bank_step updates L_cb; matrix uses state.L_cb. | +| **Open-economy split A_dom / A_ext, L_dom / L_ext** | **Added** | `open_economy_view(A_dom, A_ext, L_dom, L_ext, E)` in `fqbm.state` returns identity check and totals. | +| **Reporting period / reporting date** | **Added** | `FQBMState.reporting_date` (optional); `statement_of_financial_position(..., reporting_date=)` uses state.reporting_date when not passed. | + +### B.2 Four-quadrant matrix (Part I) + +| Item | Status | Note | +|------|--------|------| +| **Explicit four-quadrant layout** | **Added** | `fqbm.matrix.four_quadrant_matrix(state)` and `four_quadrant_summary(state)` return the 4-column matrix and identity check. | +| **Quadrant balance checks** | **Added** | Summary includes `identity_A_eq_L_plus_E`. | +| **Cross-sector consistency** | Partial | CB and bank identities implemented; no automatic check that matrix totals equal sectoral sums. | + +### B.3 White paper parts not implemented + +| Part | Topic | Status | Gap | +|------|--------|--------|-----| +| **VII** | Shadow banking and leverage | **Integrated** | `leverage_ratio()` in dashboard ratios; `margin_spiral_risk()` available; repo multiplier not modeled. | +| **VIII** | Derivatives clearing and CCP | **Integrated** | `run_workbook(ccp_params=CCPParams(...))` returns `result["ccp"]` with identity and waterfall flag. | +| **IX** | CBDC and digital reserve architecture | **Integrated** | `run_workbook(cbdc_params=CBDCParams(...))` applies deposit/reserve shift; `result["cbdc"]` has cbdc_liability. | +| **XI** | Historical case expansion | **Partial** | Presets (1997, 2008, 2020, 2022) in scenarios; no narrative or coded “case” outputs. | + +### B.4 IPSAS presentation structures added (templates only where no FQBM data) + +| Item | Status | Location | +|------|--------|----------| +| Statement of financial position (IPSAS 1) | Supported | `statement_of_financial_position()` | +| Budget vs actual (IPSAS 24) | Structure | `budget_vs_actual_structure()` | +| Cash flow statement (IPSAS 2) | Structure | `cash_flow_statement_structure()` | +| Statement of financial performance | Structure | `statement_of_financial_performance_structure()` | +| Four-quadrant matrix | Supported | `fqbm.matrix.four_quadrant_matrix()` | + +### B.5 Other missing + +| Item | Status | +|------|--------| +| **Statement of changes in net assets/equity** | **Added** | `statement_of_changes_in_net_assets_structure()`. | +| **Notes to the financial statements** | **Added** | `notes_to_financial_statements_structure()`. | +| **Comparative period** | **Added** | `statement_of_financial_position_comparative(state_prior, state_current, entity)`. | +| **Functional currency / presentation currency** | **Missing**. | +| **Financial instrument maturity breakdown** | **Added** | `maturity_risk_disclosure_structure()` (maturity buckets, interest rate +100bp, credit exposure, ECL). | +| **Interest rate sensitivity** | **Partial** | Column in maturity_risk_disclosure_structure. | +| **Credit risk / ECL disclosure** | **Partial** | Columns in maturity_risk_disclosure_structure. | +| **FX translation of FC positions** | **Added** | `fx_translate(fc_amount, S_prev, S_curr)`. | +| **Cash flow from balance sheet changes** | **Added** | `cash_flow_from_state_changes(state_prev, state_curr)`. | + +--- + +## Part C — Implementation checklist (done vs to do) + +### Done + +- IPSAS 1 statement of financial position (CB, bank, consolidated); uses state.L_cb and state.reporting_date. +- IPSAS 24 budget vs actual template. +- IPSAS 2 cash flow statement template; **cash_flow_from_state_changes(state_prev, state_curr)** infers amounts from Δstate. +- Statement of financial performance template. +- **Statement of changes in net assets** template. +- **FX translation**: **fx_translate(fc_amount, S_prev, S_curr)**. +- Four-quadrant matrix and summary; **L_cb** from state. +- **L_cb in state**: `FQBMState.L_cb` and `reporting_date`; central_bank_step updates L_cb; differential model 12-element vector. +- **Open economy**: **open_economy_view(A_dom, A_ext, L_dom, L_ext, E)**. +- **Part VII**: Leverage ratio in dashboard; **run_workbook** exposes shadow metrics. +- **Part VIII**: **run_workbook(ccp_params=CCPParams(...))** returns result["ccp"]. +- **Part IX**: **run_workbook(cbdc_params=CBDCParams(...))** applies deposit/reserve shift; result["cbdc"]. + +### Optional work (completed) + +1. **Part VII**: **repo_multiplier(initial_collateral, haircut, rounds)** and **margin_spiral_simulation(...)** in `fqbm.sheets.shadow_banking`. +2. **Part VIII**: **variation_margin_flow(mark_to_market_change)** and **ccp_clearing_simulation(vm_calls_per_period, liquidity_buffer_start)** in `fqbm.sheets.ccp`. +3. **IPSAS**: **notes_to_financial_statements_structure()**; **statement_of_financial_position_comparative(state_prior, state_current, entity)**; **maturity_risk_disclosure_structure()** in `fqbm.ipsas.presentation`. + +--- + +## References + +- [IPSAS_COMPLIANCE.md](IPSAS_COMPLIANCE.md) — Scope and how to use the IPSAS layer. +- IPSASB (2025). *Handbook of International Public Sector Accounting Pronouncements*. IFAC. +- White paper: Four-Quadrant Balance Sheet Matrix, Parts I–XVI. diff --git a/docs/IPSAS_COMPLIANCE.md b/docs/IPSAS_COMPLIANCE.md new file mode 100644 index 0000000..14f14d9 --- /dev/null +++ b/docs/IPSAS_COMPLIANCE.md @@ -0,0 +1,144 @@ +# IPSAS Compliance Assessment — FQBM Framework + +This document assesses the Four-Quadrant Balance Sheet Matrix (FQBM) against **International Public Sector Accounting Standards (IPSAS)** issued by the IPSASB. The FQBM is a macroeconomic and institutional simulation framework; IPSAS applies to **entity-level general purpose financial reporting** by public sector entities. The assessment clarifies scope, alignment, and gaps. + +--- + +## 1. Scope and applicability + +| Aspect | FQBM | IPSAS | +|--------|------|--------| +| **Primary purpose** | Monetary/fiscal simulation, stress testing, open-economy dynamics | Entity financial statements (audit-ready, general purpose) | +| **Unit of account** | Sectoral balance sheets (CB, banks, sovereign), state vector **X** | Single public sector entity or consolidated government | +| **Basis** | Identities (A = L + E), differentials, stochastic shocks | Accrual (or cash) basis, recognition, measurement, presentation | +| **Output** | Time paths, ratios, stress tables, Monte Carlo distributions | Statement of financial position, statement of financial performance, cash flow statement, budget comparison | + +**Conclusion**: FQBM supports **analysis that can feed into** IPSAS-consistent reporting (e.g. central bank or government balance sheet layout, budget vs actual structure). It is not a substitute for full IPSAS-compliant financial statements. Compliance is assessed for **presentation and disclosure structures** that can be derived from or aligned with FQBM outputs. + +--- + +## 2. IPSAS standards relevant to FQBM + +| Standard | Relevance | FQBM alignment / gap | +|----------|-----------|------------------------| +| **IPSAS 1** — Presentation of Financial Statements | Statement of financial position (balance sheet) structure, current/non-current classification, minimum line items | **Partial**: We provide an IPSAS 1-style layout (current/non-current) for central bank and commercial bank from state **X**. Minimum line items satisfied where FQBM has data (financial assets, cash/reserves, liabilities, net assets). | +| **IPSAS 2** — Cash Flow Statements | Operating, investing, financing cash flows | **Gap**: FQBM does not model cash flows by activity. Cash flows can be inferred from balance sheet changes (e.g. ΔR, ΔC) but not classified by IPSAS 2 categories. | +| **IPSAS 4** — Effects of Changes in Foreign Exchange Rates | FX translation, functional currency, presentation of FX differences | **Partial**: FQBM has exchange rate **S** and FX pass-through; no explicit translation of foreign-currency assets/liabilities or disclosure of FX gains/losses. | +| **IPSAS 15 / 28 / 29 / 41** — Financial Instruments | Classification, measurement, disclosure of financial assets/liabilities | **Partial**: State variables (B, R, Loans, Deposits) map to financial instruments; no classification (amortised cost vs FVOCI etc.) or impairment model. | +| **IPSAS 22** — Disclosure of Financial Information About the General Government Sector | Sectoral disaggregation (e.g. general government) | **Partial**: FQBM distinguishes CB, banks, sovereign; can support GGS-style disclosure structure if sector definitions are aligned. | +| **IPSAS 24** — Presentation of Budget Information in Financial Statements | Budget vs actual comparison, material variances, comparable basis | **Gap**: No budget or appropriation model in FQBM. We provide a **structure** (budget, actual, variance) for users to populate from external budget data. | +| **Conceptual Framework** | Definitions of assets, liabilities, net assets, revenue, expense | **Partial**: A = L + E and four-quadrant identity align with “assets” and “liabilities”; no revenue/expense or surplus/deficit from operations. | + +--- + +## 3. Current/non-current classification (IPSAS 1) + +IPSAS 1 requires **current** and **non-current** classification for assets and liabilities unless a liquidity presentation is more appropriate. + +**Central bank (from FQBM state)**: + +| IPSAS 1-style line item | FQBM variable | Suggested classification | Notes | +|-------------------------|---------------|---------------------------|--------| +| Financial assets (at amortised cost / FV) | B (government bonds), L_cb (loans) | Non-current (hold-to-maturity / policy portfolio) or split by maturity | L_cb not in state; can be added. | +| Cash and cash equivalents / Reserves | R | Current | | +| Currency in circulation | C | Liability (current) | | +| Liabilities (reserve accounts) | R (bank reserves) | Current | | +| Net assets / Equity | E_cb | — | | + +**Commercial bank**: + +| IPSAS 1-style line item | FQBM variable | Suggested classification | +|-------------------------|----------------|---------------------------| +| Loans and receivables | Loans | Current / non-current by behavioural maturity if available | +| Cash and balances at central bank | R (if attributed to bank) | Current | +| Deposits from customers | Deposits | Current | +| Net assets / Equity | E_b | — | + +The module `fqbm.ipsas.presentation` produces statement-of-financial-position layouts with these classifications where applicable. + +--- + +## 4. Budget vs actual (IPSAS 24) + +IPSAS 24 requires comparison of **approved budget** (original/final) with **actual** amounts on a comparable basis, with explanations of material variances. + +**FQBM**: No budget or appropriation data. We provide: + +- A **template** for budget vs actual: line item, original budget, final budget, actual, variance, material (e.g. >10%). +- **Actual** amounts can be filled from FQBM state (e.g. total assets, total liabilities, key aggregates). **Budget** columns must be supplied by the reporting entity. + +See `fqbm.ipsas.presentation.budget_vs_actual_structure()`. + +--- + +## 5. Financial instruments (IPSAS 15, 28, 29, 41) + +- **Presentation**: Financial assets vs liabilities, and (where applicable) current vs non-current — supported by the IPSAS 1-style layout. +- **Recognition and measurement**: FQBM does not implement measurement bases (amortised cost, FVOCI, FVPL) or impairment (e.g. ECL). **Gap** for full IPSAS 41 compliance. +- **Disclosure**: No maturity breakdown, interest rate sensitivity, or credit risk disclosure. **Gap**; can be extended with user-supplied data. + +--- + +## 6. Foreign exchange (IPSAS 4) + +- FQBM has **S** (exchange rate) and **FX pass-through** (e.g. π_import = β ΔS). Useful for **analysis** of FX effects on inflation and balance sheets. +- **Gap**: No explicit translation of foreign-currency-denominated assets/liabilities, or separate disclosure of FX gains/losses in a performance statement. + +--- + +## 7. Consolidation and general government (IPSAS 6, 22, 35) + +- FQBM **A_dom + A_ext = L_dom + L_ext + E** and sectoral breakdown (CB, banks, sovereign) support **consolidation logic** and sectoral views. +- **IPSAS 22** (general government sector disclosure): FQBM can feed sectoral totals; exact GGS boundary and entity list must be defined by the reporting jurisdiction. + +--- + +## 8. Compliance checklist (summary) + +| Requirement | Status | Notes | +|-------------|--------|--------| +| Statement of financial position (IPSAS 1) structure | **Supported** | Via `fqbm.ipsas.presentation.statement_of_financial_position()` | +| Current/non-current classification | **Supported** | Applied in presentation layer for CB and bank | +| Minimum line items (IPSAS 1) | **Partial** | Satisfied for items present in FQBM; no PP&E, inventories, etc. | +| Cash flow statement (IPSAS 2) | **Not supported** | No activity classification of cash flows | +| Budget vs actual (IPSAS 24) | **Structure only** | Template provided; budget data external | +| Financial instrument measurement (IPSAS 41) | **Not supported** | No measurement basis or ECL | +| FX translation (IPSAS 4) | **Partial** | Exchange rate and pass-through only | +| Disclosure of GGS (IPSAS 22) | **Partial** | Sectoral structure supported | +| Reporting period / reporting date | **Not defined** | FQBM is period-agnostic; user must set | + +--- + +## 9. How to use the IPSAS layer in this codebase + +1. **Run the workbook** (or differential model) to obtain **state** (and optional stress, MC). +2. **Call** `fqbm.ipsas.presentation.statement_of_financial_position(state, entity="central_bank")` (or `"commercial_bank"`, `"consolidated"`) to get an IPSAS 1-style layout. +3. **Call** `fqbm.ipsas.presentation.budget_vs_actual_structure()` to get an empty DataFrame/template for IPSAS 24; fill budget from external source, actual from state where applicable. +4. **Export** presentation outputs to Excel or include in reports alongside FQBM dashboards. + +For **full IPSAS compliance** at entity level, the reporting entity must: + +- Maintain recognition and measurement in line with IPSAS (e.g. IPSAS 41, 46). +- Prepare cash flow statement (IPSAS 2) and statement of financial performance. +- Populate budget information (IPSAS 24) and provide variance analysis. +- Apply IPSAS 4 for FX and other standards as applicable. + +The FQBM IPSAS layer supports **presentation consistency** and **structural alignment** with IPSAS 1 and IPSAS 24, not end-to-end compliance by itself. + +--- + +## 10. References + +- IPSASB (2025). *Handbook of International Public Sector Accounting Pronouncements*. IFAC. +- IPSAS 1, *Presentation of Financial Statements*. +- IPSAS 2, *Cash Flow Statements*. +- IPSAS 4, *The Effects of Changes in Foreign Exchange Rates*. +- IPSAS 24, *Presentation of Budget Information in Financial Statements*. +- IPSAS 22, *Disclosure of Financial Information About the General Government Sector*. +- IPSASB Conceptual Framework, *General Purpose Financial Reporting by Public Sector Entities*. + +--- + +## 11. Complete IPSAS and Matrix gaps + +For a **full list of all remaining IPSAS standards** (1–50, cash basis, RPGs, Conceptual Framework) with status (supported / partial / missing) and **all FQBM/Matrix gaps** (L_cb, open-economy split, Parts VII–IX, reporting period, etc.), see **[GAPS_AND_MISSING.md](GAPS_AND_MISSING.md)**. diff --git a/docs/REFERENCES.md b/docs/REFERENCES.md new file mode 100644 index 0000000..1d9fbb6 --- /dev/null +++ b/docs/REFERENCES.md @@ -0,0 +1,17 @@ +# References (Chicago Author–Date) + +Adrian, Tobias, and Hyun Song Shin. 2010. "Liquidity and Leverage." *Journal of Financial Intermediation*. + +Bank for International Settlements (BIS). 2017. *Basel III: Finalising Post-Crisis Reforms*. + +Borio, Claudio. 2014. "The Financial Cycle and Macroeconomics." *BIS Working Papers*. + +Dornbusch, Rudiger. 1976. "Expectations and Exchange Rate Dynamics." *Journal of Political Economy*. + +Federal Reserve. 2009–2023. *H.4.1 Statistical Release*. + +International Monetary Fund (IMF). 2014. *Government Finance Statistics Manual*. + +McLeay, Michael, Amar Radia, and Ryland Thomas. 2014. "Money Creation in the Modern Economy." *Bank of England Quarterly Bulletin*. + +Obstfeld, Maurice, and Kenneth Rogoff. 1995. *Foundations of International Macroeconomics*. diff --git a/docs/framework_summary.md b/docs/framework_summary.md new file mode 100644 index 0000000..52dc3f5 --- /dev/null +++ b/docs/framework_summary.md @@ -0,0 +1,37 @@ +# FQBM Framework Summary + +## Core identity (Part I) + +- **Accounting**: \( A = L + E \) +- **Open economy**: \( A_{dom} + A_{ext} = L_{dom} + L_{ext} + E \) + +Every financial asset is someone else’s liability (within or across borders). + +## Four-quadrant matrix + +| Assets (Dr) | Assets (Cr) | Liabilities (Dr) | Liabilities (Cr) | + +All monetary operations must balance across this structure. + +## Parts of the white paper + +| Part | Topic | +|------|--------| +| I | Accounting foundation | +| II | Closed-economy monetary dynamics (CB, commercial bank) | +| III | Open-economy extension (BoP, parity) | +| IV | Capital flows and FX dynamics | +| V | Sovereign risk and CDS | +| VI | Commodity shock channel | +| VII | Shadow banking and leverage | +| VIII | Derivatives clearing and CCP | +| IX | CBDC and digital reserve architecture | +| X | Empirical regression appendix | +| XI | Historical case expansion | +| XII | Quantitative stress tables | +| XIII | Monte Carlo simulation framework | +| XIV | Full system differential model | +| XV | Policy implications | +| XVI | Simulation workbook architecture (eight sheets) | + +This codebase implements the simulation workbook (Part XVI), Monte Carlo (XIII), differential model (XIV), and empirical regressions (Part X). diff --git a/fqbm_workbook.xlsx b/fqbm_workbook.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..971eea8ab07ca464d0ae1912164d0a4b595c4325 GIT binary patch literal 11985 zcmZ{K1yq~c(l!poDFs^GH8>Q9wiNdU3vR*Pp+JG+THFd0cY=FyYj6t`DQ=~>7Ycvq z`R_gV^t(4%$-9!g&+I)j&&bLiRRv^}CkO}#7zpT1ntIX$O0r4tPb2UbKKy0oV5;iu z;ON3>?C8khX=kf6ri9hag^jk-@y5L|ErK#1_qA9=R@)edTW|x5UC__NeH00MFRwwe zT#j&B#)ed>V~!0wUY-G*7cjb?;zQ7!h@6eLmE21KQPre)`Baq1J-K$X{=wYXg2QW# zRlCz1(iU!G$*(+Wgw+^(*0lMAtC^_E7RfR3x4jv}BMB9RQrJ-MBY{Iiz^pB(!>t)= z2YL&M>OWM_+iO?)4&V9^A_4-zf2v^W;0%7yp&(94rH2bUclKiG^p3HLg*3n{X+P-XE87mL&5XO z5KM1Hmz0wZ_PuUlNi)dGHs`5~nUSvn!1Beox|)uIA$8p*74H?%ka{#Y50OyoC1Z0~ z-O06W-|hT2aq!r#Fa=M5Jr|ia^}A7rq77x8uvPW$-cD-^2~C@UiAh=iz%Raus4a3A zc~e9fJUAl4ncAc7J{P_F*-_AWS3Wz^mGxs!!G@v9NJny%6}8r^?#wgWj{KL8>hi3{ z&E_uVE8_}Fqk$?@m%ht;8I~V0qjgoLY&@2xV#50?#yxS`k5xi@D#QEB#$ETU7@2-m z+T`yNTfUNB-}8PO* zz%zDujk%{RYC)x$T%|uYwviR(OxL<}q3wI2B4bZCThdueJDVs?FO@yW-m*a;STBde zLnwif8Io$PN{*EOh&wqaxh7L@czIAu05Xk4uF7lw=9- z*b3(FK2E(jO5~*fDIXVWRZCao$~dyaOWGw3A#)40oD}uGL%sgUk4YX!MuB8ORx3>t zWYu~^389ZkujnRVdnIo2>^Z4(J#j#TQ%R7K&g1?GPvyxvf?H5Ie}cHt;E$0ww^3Le zEHE1(+9M4q%<$R*VlHK-N#r;17>gu! zdHB%t(m{_t2WeVomxl6*7gO?Jq8>L>if?**VhSYpx2aYJzD2yBm>1|Sy9(?SddJ~A zR&dySCYCeVC_v|H(KQvR`%T(jxA&%!dw!OWgV|Ked1P=v9g@H@CzRd!W;Y?t%uS-Y z8@rGkS8>4DwW#5C-3S)1WTUgf_lCswdPYI{v+7|-Wy&FJ5ew0HiUyBQ6#O?A`tBQa z1zf-s$tKcQ?Z|d5nTx`LS*dZ&m{V^)IP_fa_IZ-734KLr_D$8Y+n$@4xY_PftI9hR zO{N$KJISRv$O%au>yZrgdTW?!k<_os_r%V&oE{+EXw)=&40y6)X2kvp{XIkt>Ew-u z5w~tXGTVn-AH^uC9mnkyJ;U{h`1OTCaJ@Z*#p9VB?Hi$Pt5iaZGUzvOb(aRkn1=32 zRlBuV9g*_b9jIb38=F9x80)dDX(CCXeD3KpVEZ;hZJwastd(%D|?_ z?hSZIJk6~_CEj3ei^7v-cI-z0-7wJOH6{K1neB{^mEN@OKT|xqj81+zev!f36xlXB z)0eFDyI5NPX`dh>tN78Yj{wnC3W+X>9F3TSXG8TM{sS5z;z;6`*GEW!W`*SbsXwtk zpI|q$5RF!d3%!#9ybn<}m}GeTB%%r%m4?K5{qQis+9PWACm_HsNS()Qh6E}Z-X+HC zEIFsna9TOfh!woBrasb2+RkXC(Bo~N$7IIt={)TL@MF^10_v`YFi0~&UGih&us9ZCA6sdcA96+6M3tPy)SX#mMnJ&{Q zFk>T-P(b&gibpez(mmrdjD2EKQCMBkKhKTr<*$8;ontPz94eJg$~zNTE)$PUDR7LZzwHXj5e1O#n(5qbJo z0r7BfwsElpgI!%Xe_wuAk>DgdhXrcn9!qKG3De*v)|otbOEf$kFcO+W_h+Dy9x&|-PWJf?3UVQwFRqJJugNVr>CmEMx)Or@7S<-aI6~HX=5iD&pB(lZmwBLd1IABr zwxOhj@8`4Sj;a318YvIgkrsF_c8KynS;OfG>-07J>u_}T z4b)2q@AO2BF*Kt`<|Qqc+fCyqErXl8dBt6Bqx%FYqI{ij5Tqd$lN_cvCYPPfB7EbR z9;U+MbwIKO%=~f6<~=}|HNcw4aNwB2A$S^~GO$=X0-VZ=C(W|k_}M^ou}_frjHkhb zD*O#489AY-iilGX#mZp}Ci6yu3WM&rGARY9nD@f&T$SFmulS12*imb5P@q;|s}EhG zEUGW-(G&7KcRc)p}#Jx-*qowq3h<|K`}F;e8Fm{^*cn# z=2wU*S@seHSOK&u70+n*GDfD}8(z`LG1qtGQ|Leygdd$E39r*jgCW2el(nf_N0ztP zNl7c6S)C)(w(t2DoTUwdHbkDx-FqMuwHI!|fG&IfeXMspv(uo1ciBK?2_RHu_KJr0 z&1f?bjo9%a5#Y#y@lL7Jgmm_lKRSZo# zs`}WfyUSWRd9~o1@Yd6}W0&P3Cx|1pc8JVBi}D+!79YELM17USidZ0W|Fk6cUfRjI zM2KhM*~+=>n|QF?NfHZ(tKOFB{zRhC1$}Uc4B=M;7JGxkmusrMR=1jS5vB^J0ZCp1 zu~o?4-^sfQY$r>Ck?c^v5555QE>E_Oc$-CqNg$z#MAA8!$6ozN2yF=?Tqcwi{E)J8 z;1YNm>sMkix-M`{mG^G;Tb6FI>Kze(y_6533_o96N?qS7lb)!rmxEJJ zcuI2@4bL~*G%BAJi0g}-Uv&F}O)ZWBYS&m5BhQ*~eMWIfZhbNCe-)6NwR9H0in4C? z{}wbZvNrv0Crn5np}@8#5tTl^j6u?(E;|jF#wa(ce7Ys6W7Q&`vhkeeRd8uSpv}+T zZtt2Z+~P%mj`kQ}(VU7rD1Xu{iCq|jK-{ghuTd8J$-yiFmR zAN`pNbOfc`wo4rEj}bh+QBNjb6{Py%ZNB@bXV)&CwxJOtAk3Tm&q(I`?b%FS2YrF~ z;QNTx%W%&QK62!Q3ie@9>~UEx-dG$V@tELbhS+?1-g`gTynAn+RQ06g@*q&OUQW|j z17QF8=JakgJ2qRTs7T=cX7l3Uc%U&mJFER}e{Jn}cC3hVHdbQso!C;&=G3pAls%@~ z^VP=40SI2vUhF_<`=SBLHncBgFQc{baGB!%;xhE|@ZjCMme0*1zkaQbtREV->q*=$ zs}wEVNWAmeTfRHTlUTmJxZb@Ajl4W;H@J8Ixp{V$ZFr1$d%gx59mwD;Y8}Y9X%)aL zQjuu?rFGxPcG&K_wR37HP$WS$c6S@{-KVMJp6UMNkP0sEszSN3$S|9EgS6w9)4XzH zV`A{(iNtbWJ4Me)!uGp6h1;29lp|CLJ&CM_n}z%K>#MWtfyVv)jMyTT*^XuLUq1KS z9hvPmNdsDKZGJ6&_Xn$YV*+^Um%myKuhDVCV+1^W6gx!4R+b&OCZ}#PS_f_xB$Pc= z4mq-p_^)C~I{fa(Rm$+hK2oaSz594~j7WI8x2+}KToHQX`gX+P6dKB*MfKD3=h0d0 zz%o@f*WN6Zy$Zy~-O$(l<~r${R@t`>a)td(CXOROHkGBP-=|glW5jp&>!06x91mQ( z@Xj|l*m*3^`#F}>P5anaTcjk|aK1Gpjdzcmj^q?KCwW_;UAGH9WZR|iDIQJHv4)6k z-7Ve(w;Y3(EQ@XG?2b39RC!@pl6VaL$$7;#vh#-WCmb@_ChsdtTBoISpEZ0^*r}iS z@W!>Et?@}E(flvl%kNa;#Wa>VPDvP`D}@i@FdbXt>NxBW}pSS}ZR ze(bv04b?XEjl(P#phL#osgVUYlSfjY{k|wjP8yimS4ZVIUV8dXlr-?z%6)DkwvL&5 zSurqMUokeXDXE)hS5{*O>{LfN5Q=q@f^Uo?P`J_LcrG|8f$X*JuCM4KIS|UxsoF4G zZ#Opo`~hH7V<%~y1J*K%N5(FA&|m8DFrh7XL&xD<;!DcKq_fHbL8^ENBH_oqZuxTy zx0|mdar+lQf$4poX6?b8vlT2`m+%%R9JyB6z<^-pr>);tM+4_sb~DLgKBe4X@x6FM zt=ZNtuF+J(GN+qwf+3acMS4E(XBA6^NIut@?X#BMq*te|M!pHtSgO3}Ke_C>{@!z3 zPU7-otaC(Ho-BIdvOmXlpqyT(0!eGFmDO%=UWypTXJ*uH+>|~fm6RN3+C}(XoKb7H zkN9Q|ujzzX9~7ODeGP3!7=1L}-k2}1>(5XlYHQfbA(t~V$+S>FMLvB(5k{T$5f_tlGwbYJS&HF3y(LNonl(T$^(1eh>y;{LFz|UFbuiA ztxrdDeJiQKQq*Pi+j)p#p_1KkG873i<8aYogjk}~mfmtXr!(j|SH)DxPTh;Qbwcb4 zUxVb1B`cE6!r#X-m1$;vjU#R|eA}_1ubDW3G>na+nW#K0#y7L78tSZtcXqO^o{u}E zd$fuAekJp5IKRl1hJW^C3WJRnsG$ETxu)$Y%SOJqPOD7RuJZxnG4ZQ#)PU;%qi-f$ zWlpE^#*`uXPA)zAJq{%>;?NQRiey+YzOWG2TTPJGJOu}q{~-ZO3uTSr$oUIyhw9Xs zCa(hSrw0*Td1X;{{1G*9r(gj;2K+b*#ApVlHPqu~5>mN}T#*-obIz%G96Bde;OY~_ ztQfEG1>}l#mn*U@=JF(=R;=b%x7x5D;UqL_MwB`JIGW5%o(}HQ3+)1TJ-ZRs)*}vz zdAcP0281`mU%!%EtS=SNW>eDKNwN{bU%87aAEZKerzlSka@ zNUp4NmFF3+0*o?o$i#InmvS0WCVx~%h6B+U(12+21sB(lT3%4WI}OCiyI-J(7N0xi z&;JO)*iARNTpx_M=bu90J&jEws8o*|y%Vt&)Wx7`AHZzv531@MwqET1c=rk+b|-AN ztS~N_!CFi0QyJ8ZiV^=q5zTOt`(?E;Q$C6edX)aiS!a!r@x!K!={zf7^2o9U-9eeG zcE)MY?~aq=reCNpcD5&`JPML92-J9|_Xup0Pr9{21HfI~JY`N;APeE{dZZ8dZmc_ z%@capGDEb8Y-Cy%ZKQ_KfW3V9sQF2k0a#r((R)p$n z3G!O)I~E>I4f3(kycEV>4W{4J@Q_M1T@W>d~ z^`N<4n|lGP0zI+MMf1&V3~{0q4c+T$>hLWeTAE$yw%>|(A9~C=hUkLD`z8)BT*fKP zAujE|x)b|qk>yTbPm0Dr;lsobLiiPzFr`1OWcOuWLlZ)CQk?(Zb0 z6DtOo+XU#U6DL7EYY6cvg<2|D-kZReAjUQ+{o<0l`=JoEQX7VpoA@M6E6V5yo>Yc% zgTb2X#6&}+Sptc;a}g`sR;BC!Ajt88#Uw9KaD|8wY4#wEn~d_xx)MD_Dsrg2 zpPe;@f;xEoaSr>GerbIlm=()atCI;pEjS4-t>NN4g={!Rsnl!x>fNtjlH6qgnD-O@KPcTR*q@5xazqlRfTDGu2t!5FjV=m_ zd&PP4;DMYpq*pAHG zhKGz|LuAk~u$6WQAoZ~H$o6&he2r008nr?TN=8d9a4~p;l?C8Zke7LOfo{XS6?T7y zJ@vT0j9FXK(D~KI26s4Vx1N>9Q@ivhmQkvr5KBHB4h78`I3eS^36xZj!e^u03N0K{ z9s<4#^F}dSgU;4-T&ZG-ZHK6Ep?NDNJ|P(Pe&|hI;rv=neo-ohtyLfI1)pC=T|<*1 z*cfU}J7Ze=YNf7odxP{NpK6RvKE#=Mx7=vB{3)+OKc7Af`N9O8TsgZNwl*1GUsgVf zk=t+Zh|O1?$?`Hv1MLvaC2*{~=8=^nsHO()$K*Qq^5eM%XW%*+b22E`od|Vm+;nwu zMtfUw*vgF3uP;z0SWaSW>a)7H5vZo>K@Cb1+Ww#nvzl)sGJmP@ax)PK#w~BiO>1P^ zKd=$}gg=D+0;pwg^DjgeJq*)@Y^6}w=Vj1xeM6O^88=ZB2+gYbo9=8NHpR2MTwqSfqeI5^_D#m%i@upUciHJyy)!x@YOPRgQJijIyE-G(5Tpo>9K|!Mo$})K@_i+bN@K&w7Uv1)Y`R45FGznISaf7dl zP-zOB^`J5+l4p6g%is7K=}H> zwJigDs)773MP(%dV-YsE{HVz)5b|?`8e2qoFoL^iqezT>Jp*Mar$hr8Y{oFn!Zk#q zoBP%I#ykt?Op`(Hr3lB<%ypQ>`~kWz;AH{iq7i()yclvs!>-J0M*z~KwV~$OT0kK4 zYnkIZR}O02P}sr*l^^hgm21C--!By&MX2vtKwB#+d))gFAFxk|A6n^5@(CXo)W2QX zO=0u=o|Cdd!mE##Kot3=1-Gau&@kiikoVEw(e?rraD zbZwPToRRb>cy5G7po04XYj}#jc*gPw1l5Pt@vS>NBy4bRh*4Z)mgR`L4TZ3VXLMAk z-!vmYwGNw^jW`i*DK+Nx600B~M3xtqayD4d*U1A}XsP~Cq%vQAfnqwk@1496LdUF3 z5DY*arK)B;|15DLQ2@`f@gP3K#<26mE>GlxLz2Sr5wa|3J{wH6rAwPq!d`{EAtb4f>+nWk)GbzZdSjycFu6CaiYB`mDx2Dd2rwTWi(8v6w+pw zQ5%Vc#AzYGK2nyvrLQ0+1rRk4VCb1F2ZHtr9#)Ia66U$zFy>&aciT{pdBN$u_5D_U zw+r021}{Nu>6MsO*<9;w1;l&m*bhaE*cN0irEf#SC6%LN6LY{k|Kcn_WjbCHX6p~C zerRtZvJ6e4iM9rX%k5`QH-B3?2I_U!-L~VEeA)_|U*dkf`iPU;GYOO9{cwbox#qfN zAzLbTdF(X1%LD7vN&w6sc^aPIx`nw3!zK^DOno|179X3@3_f(L0@csKmncPgv+>Jb zbQc$$nCW?~*}ldUn8-bOvdB?jzB`<0~Uh*T5PnJtD~Ss%hf< zynUM6aHykeSy)r03DT!{3}RDZ^!aX}9ftZY+`kQwL}{kU&I6C$QuNf3?H$$^qDWe2 zm{7QJNk9Nqmw~QNp*UI!g~*NgE(jrv=8mH_3WncBo62?cG}$@IynR3f7Bt!`a$bO` zw*(LS>VEBEpb!dN;hy1}NH03|sh1slF{iERmf+?Hs{aiOF0gEW@nLb2LQtH=&M6Jb z>WCZwKUtg{wQ|;(8prVVwrb0d5Om<5LWY*2!c&nZQ8ZjD^UtTA7ChgG6H1D=xO+;D z*iq7Ea!YHsU@OIIYTPkL^AP8*^B?R_w`{nEFO4vsQCu#0W#F2{6=|7BQ5F)QB~tnlWCejeV-X?q(*fcnE@-)TWEla z&r{1m5BDp589eQuZVVjIX|?`MM&=^t`U|LtE^te>wI|S6Hw6Lm zbytWa&W%hRRvrXTMfJP5jCTs3a}raidcGvF^F0t3c&J}VzAW+S}H%* za5}2FQY^R4>Mk9pWFd(MO^1zLKpM0guubX}5@@+3q(o@#QFZ7PicWgEw)K5U{%eZOC0{icNJJOto%7MfG}*lr@Eqy|Y@e1>I+Qp&)g?{-YfmV#m~3}1q}yI;FP;et zKbSZyCHk;ak?Vu6J8Yi9)I|ba71JbJs%-{6%`I{mp1*BAcZfXJB$y&JnYxjNw_TrY z8iwE3@~F0_?9{ic%gb{hn8Y`yr?6^FNKhca>PHuCJoqvD|~GcPyPj>9{t#)sYuMl_VVYQh@q;Z^r(G`_AZ9}O7B#mLy} zSf2GJv*0bR&+|+WqvF-~7Z5z=z29YGHj!wVD?~1)z2>8b&N6XQN%;YpA@!q5yS8zj z&pv9QTfqyIv5UyIozQq3);-OK{`S12H&S*EA4$;QWtxGYl^SSvji)j>&8Pv$D>E;K{^|1A z9QF0Z_P|fhdP-FoJ2O0qRaz$Es|wtv$AHDtc(9-?uRs@yEqz&@BtT?Eif87%rwkG= z=-XFa>&ws+-Qd5Yf_=U1v=!RO7mwONfKT$es)aSy3IX7vaE1=$Wk2_VrDnX9^(!QB zJE}K<+gYv0U^-_U|1xbE1jn>b3~$J$tc|ExC*v?qqkEkINyP?XreegULXwvZ`>?-7 zxSeRjt-_csYi28ZR$CReA*P1A`Ga-4sd*-Y~Nnu`G&ZZ55xS+^{)~}iH{2V>v zBpAxi-)rEjk=sdS%9SXv%wkh-|E@k})$8;7T?E*uB0&R-ZFW%gT7UzA%uW=-a}|j8 z%pCw=>%%X*5s95goT2~m*K3r!;n4dgzpMSNgX59?UBdh(D$mrDqkS1>pPQknU$g5L zuYH={dAHm<-yWat{#@S;pcuc~y}R4J=Da1q9g`Sc`*n}}&;O1mQIDnHz|Rh4FcA=N z{yIB!arLqVyFC11yrj7hu>d~`AX>*OPDo8}qW3DMQMDZEK9Drr)K*4mX{u?X|Mk*)eTIKgf!j)o^q-Ck6 zl}i@B7D6`Ov@B`t=C!my#U0n$$@lZDvrHnqmW|35R`GV46%(`LqS1&Q$t+oIJk82% zj8b+|dc%nyik=z<4UWp15^m2p<^qfFiAaWJQu1S(6UX~QEQyDLWDW_jSU6dUt*~u1 zDO_T7Kc6ep2k*SKtus{4)T(a$!Dr~B=FGRcgjjS9LDq8mxBkGnf+ zWZO}+Wdybke39~_brVO;9(qsC4Z}k0SkXTX7SfLhwI`_zqYQTak_jSki zes7smHu~}h+`F+Z!8g`r5kaDW(D#?JOlLb3ucsV; zwTomgSI^^J>mzoQuCkNmzisy4JF|Yb9{p6^3v~WEI=Za#LQ5Fx<&+6DRNM)n@&ZOz zF3ol+wyh4uXc@HyH>@Jzn@QonKpqE7_X=#i##)sV|Yi(Eo5S8c=*lNf?5WxLLZQRj;T?Hvt{ z7@(&$DEI*{q_ZT$4CWqTK&ff)p{G|gzNcbCqsz@de$G0blKqo>jWT-?op90-!wHB5 zzZyD&ZCyAy9^NzK73`6?v0p$QgAEl+dXc!x=c**bu$scR^U`|bOnc8RqUSoPEeRCv zv$EJBczmDS$z2u-Z2P%-^}cHIS0L2buoz`IU?$b=T*2LSscThPS1pt|F|vUKPntPJ*WPV zD6Pg|uEX{&BCbTuF?Y4S-e zgCi@$<#S~wMqm(5FkM9eLRXfV!Q+7xtrj8tj_KE&eakDRIxb-0I6wX5l3nUK*Oy|i z_v?GRB9d?Q-Yl3Pi&%#ss6jVPob-7-*<`uh=9qXV?n0Bi(e~b;Q+KHp8*!(Pj7)qL zBiU@D4Pd-yDFb=E63ib9&?APVR!PU6KzP<0(SayYPmgf)z{G{J6cm2=>ts^Ze#=~5 zhG|13$0&fgkApY1rxP$M^5S<5rY?22bCl z2P9j(7$O{pT@-s|npBP&RVs5y@VhZLB#VtaE#r-vNu1`UL}!_|*@mgFp(NQsZ- z&fNSyw5jzuvFoP7x?`OJbQyPfTy6X9kI@M^D3*OU+#%I)$B_Qzn7>H)pYHih$dLpg z-1l7gQV^4C1V!yv1c(}>^UNewM%uk5L%eF;`|o8Dli%dO%&EE4_PZxq-w64chjgx zHE=tA>bSvahrWx*~$(X}SvG>>p>@mp`_z*(=W#Zn2kUqDdY(`dMC}{gaYY2^|~Ea7sF({xx-| zE9qeG3buDORQGZOyXZgAazuSdxrYn?Hi>zb)rlnx+1eYiavWL0C|=2QhSRH&;n&;e zxX~!A>>8{Sk?e0REc!*_w;e?AQtPC30h1wv5*PtsWNDKFQSHiUj;C@y*lerd=43Yf z+TAmQ#L>sv(BgGjl2fMQO=;lC;t4;iMI@hpKcCl+fK1Ie`NMiv1}A-6yK_l~n>T~a zye3s@luX#Ngiflo$$lDh0`2xRC>k$o5(5nKjZ-b_U(8!|-U<#Nrc_pHJW65{2+?&u zLo^G_;8{)ANgJ*QGQIT8w;UQRfe42NRq@ z9LJcY9TBHN5_;MLimJVKx2YZ5qDR>_X723L_K?28rFW!W9`?SwoSzn4=#nU~!>(pH z#Nm`eM0$e!zlU|Z^q+IGe_;_2{1JQp6aBBL*+1cbPLuu(UxY9J zUuH}H6!7OX(Z2-*P+ zuTp+bF#IXy&-3xWrBLAhS1G?w%m0+}XQ%XUDTpBY-%|eWoBo9US?B(S!WjQi_5K9@ zSvUR$cEE>)|EMB=g8$5Ie}lDIeuMu=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "fqbm" +version = "0.1.0" +description = "Four-Quadrant Balance Sheet Matrix — institutional framework for monetary, fiscal, financial, and open-economy dynamics" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "numpy>=1.24", + "scipy>=1.10", + "pandas>=2.0", + "statsmodels>=0.14", + "openpyxl>=3.1", + "xlsxwriter>=3.1", +] + +[project.optional-dependencies] +dashboard = ["streamlit>=1.28"] +dev = ["pytest>=7.0"] + +[tool.setuptools.packages.find] +where = ["src"] + +[project.scripts] +fqbm-run = "fqbm.workbook.runner:main" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aba0a29 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# FQBM — Four-Quadrant Balance Sheet Matrix +# Python 3.10+ + +numpy>=1.24 +scipy>=1.10 +pandas>=2.0 +statsmodels>=0.14 + +# Optional: workbook export +openpyxl>=3.1 +xlsxwriter>=3.1 + +# Optional: interactive dashboard +# streamlit>=1.28 +# dash>=2.14 diff --git a/scripts/streamlit_dashboard.py b/scripts/streamlit_dashboard.py new file mode 100644 index 0000000..76a5d8a --- /dev/null +++ b/scripts/streamlit_dashboard.py @@ -0,0 +1,60 @@ +""" +Optional Streamlit dashboard for FQBM (Sheet 8 + workbook). + +Run: streamlit run scripts/streamlit_dashboard.py +Requires: pip install streamlit +""" + +import streamlit as st +from fqbm.state import FQBMState +from fqbm.workbook.runner import run_workbook +from fqbm.scenarios import list_scenarios, get_scenario + +st.set_page_config(page_title="FQBM Dashboard", layout="wide") +st.title("Four-Quadrant Balance Sheet Matrix — Dashboard") + +scenario_names = ["(custom)"] + list_scenarios() +scenario = st.sidebar.selectbox("Scenario (Part XI)", scenario_names) +mc_runs = st.sidebar.slider("Monte Carlo runs", 0, 500, 100) +run_btn = st.sidebar.button("Run workbook") +if run_btn: + with st.spinner("Running workbook..."): + if scenario == "(custom)": + state = FQBMState(R=200, Deposits=1000, Loans=900, E_b=100) + st.session_state["result"] = run_workbook(initial_state=state, mc_runs=mc_runs) + else: + st.session_state["result"] = run_workbook(scenario=scenario, mc_runs=mc_runs) + st.session_state["scenario_used"] = scenario + +result = st.session_state.get("result") +if not result: + st.info("Select a scenario and click **Run workbook** in the sidebar to run the full FQBM workbook.") + st.stop() + +state = result["state"] +dashboard = result["dashboard"] + +st.subheader("State snapshot") +cols = st.columns(4) +names = ["B", "R", "C", "Loans", "Deposits", "E_cb", "E_b", "S", "K", "Spread", "O"] +vals = state.to_vector() +for i, (n, v) in enumerate(zip(names, vals)): + cols[i % 4].metric(n, f"{v:,.1f}") + +st.subheader("Key ratios") +ratios = dashboard.get("ratios", {}) +for k, v in ratios.items(): + st.write(f"**{k}**: {v:.4f}") + +st.subheader("Stress tables") +stress = result["stress"] +st.dataframe(stress["liquidity_stress"], use_container_width=True) +st.dataframe(stress["capital_stress"], use_container_width=True) + +if dashboard.get("mc_summary"): + st.subheader("Monte Carlo summary") + ms = dashboard["mc_summary"] + st.write(f"P(insolvency): {ms.get('p_insolvency', 0):.2%}") + st.write(f"P(reserve breach): {ms.get('p_reserve_breach', 0):.2%}") + st.write(f"Inflation (mean ± std): {ms.get('inflation_mean', 0):.4f} ± {ms.get('inflation_std', 0):.4f}") + st.write(f"P(debt unsustainable): {ms.get('p_debt_unsustainable', 0):.2%}") diff --git a/src/fqbm/__init__.py b/src/fqbm/__init__.py new file mode 100644 index 0000000..9b304ef --- /dev/null +++ b/src/fqbm/__init__.py @@ -0,0 +1,9 @@ +""" +FQBM — Four-Quadrant Balance Sheet Matrix. + +Unified institutional framework for monetary, fiscal, financial, and open-economy +dynamics (Parts I–XVI). Exposes workbook sheets, Monte Carlo engine, differential +model, and empirical regressions. +""" + +__version__ = "0.1.0" diff --git a/src/fqbm/empirical/__init__.py b/src/fqbm/empirical/__init__.py new file mode 100644 index 0000000..3ab2b84 --- /dev/null +++ b/src/fqbm/empirical/__init__.py @@ -0,0 +1,19 @@ +"""Part X: Empirical regression appendix — pass-through, sovereign spread, capital flow sensitivity.""" + +from fqbm.empirical.regressions import ( + run_inflation_pass_through, + run_sovereign_spread, + run_capital_flow_sensitivity, + generate_synthetic_inflation, + generate_synthetic_spread, + generate_synthetic_capital_flow, +) + +__all__ = [ + "run_inflation_pass_through", + "run_sovereign_spread", + "run_capital_flow_sensitivity", + "generate_synthetic_inflation", + "generate_synthetic_spread", + "generate_synthetic_capital_flow", +] diff --git a/src/fqbm/empirical/regressions.py b/src/fqbm/empirical/regressions.py new file mode 100644 index 0000000..4a96131 --- /dev/null +++ b/src/fqbm/empirical/regressions.py @@ -0,0 +1,84 @@ +""" +Part X: Empirical regression appendix. + +Model 1: π_t = α + β ΔS_t + γ OutputGap_t + ε_t (inflation pass-through). +Model 2: Spread_t = α + β (Debt/GDP)_t + γ Reserves/GDP_t + δ FXVol_t + ε_t. +Model 3: ΔK_t = θ (i_d - i_f)_t − ω RiskPremium_t + ε_t (capital flow sensitivity). +""" + +from typing import Optional + +import numpy as np +import pandas as pd +import statsmodels.api as sm + + +def generate_synthetic_inflation(n: int = 100, seed: Optional[int] = None) -> pd.DataFrame: + """Synthetic data for Model 1. Columns: pi, dS, output_gap.""" + rng = np.random.default_rng(seed) + dS = rng.normal(0, 0.05, n) + output_gap = rng.normal(0, 0.02, n) + pi = 0.02 + 0.2 * dS + 0.3 * output_gap + rng.normal(0, 0.005, n) + return pd.DataFrame({"pi": pi, "dS": dS, "output_gap": output_gap}) + + +def run_inflation_pass_through( + data: Optional[pd.DataFrame] = None, +) -> sm.regression.linear_model.RegressionResultsWrapper: + """ + Model 1: π_t = α + β ΔS_t + γ OutputGap_t + ε_t. + Data must have columns: pi, dS, output_gap. + """ + if data is None: + data = generate_synthetic_inflation() + X = sm.add_constant(data[["dS", "output_gap"]]) + y = data["pi"] + return sm.OLS(y, X).fit() + + +def generate_synthetic_spread(n: int = 100, seed: Optional[int] = None) -> pd.DataFrame: + """Synthetic data for Model 2. Columns: spread, debt_gdp, reserves_gdp, fx_vol.""" + rng = np.random.default_rng(seed) + debt_gdp = rng.uniform(0.3, 1.0, n) + reserves_gdp = rng.uniform(0.05, 0.4, n) + fx_vol = rng.uniform(0.05, 0.2, n) + spread = 0.01 + 0.02 * debt_gdp - 0.01 * reserves_gdp + 0.1 * fx_vol + rng.normal(0, 0.002, n) + return pd.DataFrame({ + "spread": spread, "debt_gdp": debt_gdp, "reserves_gdp": reserves_gdp, "fx_vol": fx_vol, + }) + + +def run_sovereign_spread( + data: Optional[pd.DataFrame] = None, +) -> sm.regression.linear_model.RegressionResultsWrapper: + """ + Model 2: Spread_t = α + β (Debt/GDP)_t + γ Reserves/GDP_t + δ FXVol_t + ε_t. + """ + if data is None: + data = generate_synthetic_spread() + X = sm.add_constant(data[["debt_gdp", "reserves_gdp", "fx_vol"]]) + y = data["spread"] + return sm.OLS(y, X).fit() + + +def generate_synthetic_capital_flow(n: int = 100, seed: Optional[int] = None) -> pd.DataFrame: + """Synthetic data for Model 3. Columns: dK, rate_diff, risk_premium.""" + rng = np.random.default_rng(seed) + rate_diff = rng.normal(0.02, 0.01, n) + risk_premium = rng.uniform(0, 0.05, n) + dK = 2.0 * rate_diff - 10.0 * risk_premium + rng.normal(0, 0.5, n) + return pd.DataFrame({"dK": dK, "rate_diff": rate_diff, "risk_premium": risk_premium}) + + +def run_capital_flow_sensitivity( + data: Optional[pd.DataFrame] = None, +) -> sm.regression.linear_model.RegressionResultsWrapper: + """ + Model 3: ΔK_t = θ (i_d - i_f)_t − ω RiskPremium_t + ε_t. + Data columns: dK, rate_diff, risk_premium. + """ + if data is None: + data = generate_synthetic_capital_flow() + X = sm.add_constant(data[["rate_diff", "risk_premium"]]) + y = data["dK"] + return sm.OLS(y, X).fit() diff --git a/src/fqbm/ipsas/__init__.py b/src/fqbm/ipsas/__init__.py new file mode 100644 index 0000000..5d92f5f --- /dev/null +++ b/src/fqbm/ipsas/__init__.py @@ -0,0 +1,32 @@ +""" +IPSAS-aligned presentation layer for FQBM outputs. + +Supports IPSAS 1 (statement of financial position, statement of financial performance), +IPSAS 2 (cash flow statement structure), IPSAS 24 (budget vs actual). See docs/IPSAS_COMPLIANCE.md. +""" + +from fqbm.ipsas.presentation import ( + statement_of_financial_position, + budget_vs_actual_structure, + cash_flow_statement_structure, + statement_of_financial_performance_structure, + statement_of_changes_in_net_assets_structure, + cash_flow_from_state_changes, + fx_translate, + notes_to_financial_statements_structure, + statement_of_financial_position_comparative, + maturity_risk_disclosure_structure, +) + +__all__ = [ + "statement_of_financial_position", + "budget_vs_actual_structure", + "cash_flow_statement_structure", + "statement_of_financial_performance_structure", + "statement_of_changes_in_net_assets_structure", + "cash_flow_from_state_changes", + "fx_translate", + "notes_to_financial_statements_structure", + "statement_of_financial_position_comparative", + "maturity_risk_disclosure_structure", +] diff --git a/src/fqbm/ipsas/presentation.py b/src/fqbm/ipsas/presentation.py new file mode 100644 index 0000000..46d98fa --- /dev/null +++ b/src/fqbm/ipsas/presentation.py @@ -0,0 +1,330 @@ +""" +IPSAS 1 and IPSAS 24 presentation structures. + +IPSAS 1: Statement of financial position with current/non-current classification +(paragraphs 89–94). IPSAS 24: Budget vs actual comparison template. +""" + +from typing import Literal, Optional + +import pandas as pd + +from fqbm.state import FQBMState + + +def statement_of_financial_position( + state: FQBMState, + entity: Literal["central_bank", "commercial_bank", "consolidated"] = "central_bank", + reporting_date: Optional[str] = None, +) -> pd.DataFrame: + """ + Build an IPSAS 1-style statement of financial position (balance sheet). + + Uses current/non-current classification where applicable. Entity: + - central_bank: Assets B, L_cb; Liabilities R, C; Net assets E_cb. + - commercial_bank: Assets Loans; Liabilities Deposits; Net assets E_b. + - consolidated: Combined CB + bank view. + + reporting_date: optional label (e.g. "2024-12-31"); falls back to state.reporting_date if set. + """ + rows = [] + reporting_label = reporting_date or getattr(state, "reporting_date", None) or "Reporting date" + + if entity == "central_bank": + # Identity: B + L_cb = R + C + E_cb. Assets = B, L_cb; Liabilities = R, C; Net assets = E_cb. + rows.append(("Non-current assets", "", "")) + rows.append(("Financial assets – government securities", "B", state.B)) + rows.append(("Financial assets – loans to banks (L_cb)", "L_cb", getattr(state, "L_cb", 0.0))) + rows.append(("Total non-current assets", "", state.B + getattr(state, "L_cb", 0.0))) + rows.append(("Current assets", "", "")) + rows.append(("(Other current assets)", "", 0.0)) + rows.append(("Total current assets", "", 0.0)) + rows.append(("TOTAL ASSETS", "", state.B + getattr(state, "L_cb", 0.0))) + rows.append(("", "", "")) + rows.append(("Current liabilities", "", "")) + rows.append(("Currency in circulation", "C", state.C)) + rows.append(("Reserve balances (deposits of banks)", "R", state.R)) + rows.append(("Total current liabilities", "", state.C + state.R)) + rows.append(("Net assets / Equity", "E_cb", state.E_cb)) + rows.append(("TOTAL LIABILITIES AND NET ASSETS", "", state.C + state.R + state.E_cb)) # = B + L_cb + + elif entity == "commercial_bank": + rows.append(("Current assets", "", "")) + rows.append(("Loans and receivables", "Loans", state.Loans)) + rows.append(("Total current assets", "", state.Loans)) + rows.append(("TOTAL ASSETS", "", state.Loans)) + rows.append(("", "", "")) + rows.append(("Current liabilities", "", "")) + rows.append(("Deposits from customers", "Deposits", state.Deposits)) + rows.append(("Total current liabilities", "", state.Deposits)) + rows.append(("Net assets / Equity", "E_b", state.E_b)) + rows.append(("TOTAL LIABILITIES AND NET ASSETS", "", state.Deposits + state.E_b)) + + else: # consolidated + rows.append(("Non-current assets", "", "")) + rows.append(("Financial assets – government securities", "B", state.B)) + rows.append(("Total non-current assets", "", state.B)) + rows.append(("Current assets", "", "")) + rows.append(("Reserves", "R", state.R)) + rows.append(("Loans", "Loans", state.Loans)) + rows.append(("Total current assets", "", state.R + state.Loans)) + rows.append(("TOTAL ASSETS", "", state.B + state.R + state.Loans)) + rows.append(("", "", "")) + rows.append(("Current liabilities", "", "")) + rows.append(("Currency in circulation", "C", state.C)) + rows.append(("Deposits", "Deposits", state.Deposits)) + rows.append(("Total current liabilities", "", state.C + state.Deposits)) + rows.append(("Net assets / Equity (E_cb + E_b)", "", state.E_cb + state.E_b)) + rows.append(("TOTAL LIABILITIES AND NET ASSETS", "", state.C + state.Deposits + state.E_cb + state.E_b)) + + df = pd.DataFrame(rows, columns=["Line item", "FQBM code", "Amount"]) + df.attrs["entity"] = entity + df.attrs["reporting_date"] = reporting_label + return df + + +def budget_vs_actual_structure( + line_items: Optional[list[str]] = None, +) -> pd.DataFrame: + """ + IPSAS 24-style template: budget vs actual comparison. + + Columns: Line item, Original budget, Final budget, Actual, Variance, Material (Y/N). + Materiality threshold for variance (e.g. >10%) is for the user to apply. + If line_items is None, uses a minimal set (Total revenue, Total expense, Net surplus/deficit, Total assets, Total liabilities, Net assets). + """ + default_items = [ + "Total revenue", + "Total expense", + "Net surplus/(deficit)", + "Total assets", + "Total liabilities", + "Net assets", + ] + items = line_items or default_items + n = len(items) + df = pd.DataFrame({ + "Line item": items, + "Original budget": [None] * n, + "Final budget": [None] * n, + "Actual": [None] * n, + "Variance": [None] * n, + "Material (Y/N)": [None] * n, + }) + df.attrs["ipsas_24"] = True + return df + + +def cash_flow_statement_structure( + line_items: Optional[list[tuple[str, str]]] = None, +) -> pd.DataFrame: + """ + IPSAS 2-style cash flow statement template: Operating, Investing, Financing. + + Columns: Category, Line item, Amount. Category is one of Operating, Investing, Financing. + FQBM does not classify cash flows; amounts are for user fill. If line_items is None, + uses default structure (empty amounts). + """ + default = [ + ("Operating", "Cash from exchange revenue", None), + ("Operating", "Cash to suppliers and employees", None), + ("Operating", "Net cash from operating activities", None), + ("Investing", "Purchase of financial assets", None), + ("Investing", "Proceeds from sale of financial assets", None), + ("Investing", "Net cash from investing activities", None), + ("Financing", "Proceeds from borrowings", None), + ("Financing", "Repayment of borrowings", None), + ("Financing", "Net cash from financing activities", None), + ("", "Net increase/(decrease) in cash and equivalents", None), + ] + items = line_items or default + if items and len(items[0]) == 2: + items = [(c, l, None) for c, l in items] + df = pd.DataFrame(items, columns=["Category", "Line item", "Amount"]) + df.attrs["ipsas_2"] = True + return df + + +def statement_of_financial_performance_structure( + line_items: Optional[list[str]] = None, +) -> pd.DataFrame: + """ + IPSAS 1 statement of financial performance (income/expense) template. + + Columns: Line item, Amount. Revenue and expense leading to surplus/deficit. + FQBM does not model revenue/expense; amounts for user fill. + """ + default = [ + "Revenue from exchange transactions", + "Revenue from non-exchange transactions", + "Total revenue", + "Expenses", + "Surplus/(deficit) from operations", + "Finance costs", + "Net surplus/(deficit)", + ] + items = line_items or default + n = len(items) + df = pd.DataFrame({"Line item": items, "Amount": [None] * n}) + df.attrs["ipsas_1_performance"] = True + return df + + +def statement_of_changes_in_net_assets_structure( + line_items: Optional[list[str]] = None, +) -> pd.DataFrame: + """ + IPSAS 1 statement of changes in net assets/equity template. + + Columns: Line item, Opening balance, Changes (surplus/deficit, other), Closing balance. + """ + default = [ + "Net assets/equity – opening", + "Surplus/(deficit) for the period", + "Other changes in net assets", + "Net assets/equity – closing", + ] + items = line_items or default + n = len(items) + df = pd.DataFrame({ + "Line item": items, + "Opening balance": [None] * n, + "Changes": [None] * n, + "Closing balance": [None] * n, + }) + df.attrs["ipsas_1_changes"] = True + return df + + +def cash_flow_from_state_changes( + state_prev: FQBMState, + state_curr: FQBMState, +) -> pd.DataFrame: + """ + Infer IPSAS 2-style cash flow from balance sheet changes (Δstate). + + Classifies: ΔR, ΔC as financing; ΔB as investing; ΔLoans/ΔDeposits as operating/financing proxy. + """ + dR = state_curr.R - state_prev.R + dC = state_curr.C - state_prev.C + dB = state_curr.B - state_prev.B + dLoans = state_curr.Loans - state_prev.Loans + dDeposits = state_curr.Deposits - state_prev.Deposits + rows = [ + ("Operating", "Net change in loans/deposits (proxy)", dDeposits - dLoans), + ("Operating", "Net cash from operating activities", dDeposits - dLoans), + ("Investing", "Purchase / (sale) of government securities", -dB), + ("Investing", "Net cash from investing activities", -dB), + ("Financing", "Change in reserves", dR), + ("Financing", "Change in currency", dC), + ("Financing", "Net cash from financing activities", dR + dC), + ("", "Net increase/(decrease) in cash and equivalents", (dDeposits - dLoans) + (-dB) + (dR + dC)), + ] + df = pd.DataFrame(rows, columns=["Category", "Line item", "Amount"]) + df.attrs["ipsas_2_from_state"] = True + return df + + +def fx_translate( + fc_amount: float, + S_prev: float, + S_curr: float, +) -> dict: + """ + IPSAS 4: Translate foreign-currency amount to local currency and FX gain/loss. + + fc_amount: amount in foreign currency. Returns local-currency equivalent at S_curr and period gain/loss. + """ + if S_prev <= 0: + S_prev = S_curr + local_prev = fc_amount * S_prev + local_curr = fc_amount * S_curr + gain_loss = local_curr - local_prev + return { + "fc_amount": fc_amount, + "local_prev": local_prev, + "local_curr": local_curr, + "fx_gain_loss": gain_loss, + } + + +def notes_to_financial_statements_structure( + note_titles: Optional[list[str]] = None, +) -> pd.DataFrame: + """ + Template for notes to the financial statements (IPSAS 1 and general practice). + Columns: Note number, Title, Content (for user fill). + """ + default = [ + "1. Basis of preparation", + "2. Statement of compliance", + "3. Accounting policies", + "4. Financial instruments – classification and measurement", + "5. Financial instruments – maturity and risk", + "6. Foreign exchange", + "7. Related parties", + "8. Budget comparison", + ] + titles = note_titles or default + n = len(titles) + numbers = [str(i + 1) for i in range(n)] + df = pd.DataFrame({ + "Note": numbers, + "Title": titles, + "Content": [None] * n, + }) + df.attrs["notes_template"] = True + return df + + +def statement_of_financial_position_comparative( + state_prior: FQBMState, + state_current: FQBMState, + entity: Literal["central_bank", "commercial_bank", "consolidated"] = "central_bank", + reporting_date_prior: Optional[str] = None, + reporting_date_current: Optional[str] = None, +) -> pd.DataFrame: + """ + IPSAS 1 comparative presentation: Prior and Current period amounts. + Returns DataFrame with Line item, Prior, Current, Change. + """ + df_prior = statement_of_financial_position(state_prior, entity=entity, reporting_date=reporting_date_prior) + df_curr = statement_of_financial_position(state_current, entity=entity, reporting_date=reporting_date_current) + df = df_prior[["Line item", "FQBM code"]].copy() + prior_amt = pd.to_numeric(df_prior["Amount"], errors="coerce").fillna(0) + curr_amt = pd.to_numeric(df_curr["Amount"], errors="coerce").fillna(0) + df["Prior"] = prior_amt.values + df["Current"] = curr_amt.values + df["Change"] = (curr_amt - prior_amt).values + df.attrs["comparative"] = True + df.attrs["entity"] = entity + return df + + +def maturity_risk_disclosure_structure( + line_items: Optional[list[str]] = None, +) -> pd.DataFrame: + """ + Template for financial instrument maturity (IPSAS 15/30/41) and risk disclosure. + Columns: Line item, 0-1Y, 1-5Y, 5Y+, Total, Interest rate +100bp, Credit exposure, ECL. + """ + default = [ + "Financial assets – government securities", + "Loans and receivables", + "Deposits from customers", + "Borrowings", + ] + items = line_items or default + n = len(items) + df = pd.DataFrame({ + "Line item": items, + "0-1Y": [None] * n, + "1-5Y": [None] * n, + "5Y+": [None] * n, + "Total": [None] * n, + "Interest rate +100bp": [None] * n, + "Credit exposure": [None] * n, + "ECL": [None] * n, + }) + df.attrs["maturity_risk_disclosure"] = True + return df diff --git a/src/fqbm/matrix.py b/src/fqbm/matrix.py new file mode 100644 index 0000000..a347dfd --- /dev/null +++ b/src/fqbm/matrix.py @@ -0,0 +1,59 @@ +""" +Part I: Four-Quadrant Balance Sheet Matrix. + +Explicit mapping of FQBM state to the four quadrants: + Assets (Dr) | Assets (Cr) | Liabilities (Dr) | Liabilities (Cr) +All monetary operations must balance across this structure. +""" + +from typing import Optional + +import pandas as pd + +from fqbm.state import FQBMState + + +def four_quadrant_matrix( + state: FQBMState, + L_cb: Optional[float] = None, +) -> pd.DataFrame: + """ + Build the four-quadrant matrix (Part I) from FQBM state. + + Columns: Assets (Dr) | Assets (Cr) | Liabilities (Dr) | Liabilities (Cr). + L_cb from state unless overridden by L_cb argument. + Identity: A = L + E; sectoral B + L_cb = R + C + E_cb, etc. + """ + L_cb_val = L_cb if L_cb is not None else getattr(state, "L_cb", 0.0) + rows = [ + ["Assets (Dr)", "Assets (Cr)", "Liabilities (Dr)", "Liabilities (Cr)"], + ["Government securities (B)", "", "Currency (C)", ""], + [state.B, "", state.C, ""], + ["Loans to banks (L_cb)", "", "Reserve balances (R)", ""], + [L_cb_val, "", state.R, ""], + ["Loans (bank)", "", "Deposits", ""], + [state.Loans, "", state.Deposits, ""], + ["", "", "Net assets (E_cb)", ""], + ["", "", state.E_cb, ""], + ["", "", "Net assets (E_b)", ""], + ["", "", state.E_b, ""], + ["Total assets (Dr)", "Total assets (Cr)", "Total liab (Dr)", "Total liab (Cr)"], + [state.B + L_cb_val + state.Loans, 0.0, state.C + state.R + state.Deposits, state.E_cb + state.E_b], + ] + df = pd.DataFrame(rows[1:], columns=rows[0]) + df.attrs["identity"] = "A = L + E; Total assets (Dr) = Total liab (Dr) + Total liab (Cr)" + return df + + +def four_quadrant_summary(state: FQBMState, L_cb: Optional[float] = None) -> dict[str, float]: + """Return totals by quadrant. Identity: total_assets_dr = total_liabilities_dr + total_liabilities_cr.""" + L_cb_val = L_cb if L_cb is not None else getattr(state, "L_cb", 0.0) + total_assets_dr = state.B + L_cb_val + state.Loans + total_liab_dr = state.C + state.R + state.Deposits + total_liab_cr = state.E_cb + state.E_b + return { + "total_assets_dr": total_assets_dr, + "total_liabilities_dr": total_liab_dr, + "total_liabilities_cr": total_liab_cr, + "identity_A_eq_L_plus_E": abs((total_assets_dr) - (total_liab_dr + total_liab_cr)) < 1e-6, + } diff --git a/src/fqbm/scenarios/__init__.py b/src/fqbm/scenarios/__init__.py new file mode 100644 index 0000000..b3ff829 --- /dev/null +++ b/src/fqbm/scenarios/__init__.py @@ -0,0 +1,9 @@ +""" +Part XI: Historical case expansion — preset parameter sets for stress and policy analysis. + +Scenarios: 1997 Asian Financial Crisis, 2008 GFC, 2020 Pandemic, 2022–23 Global Rate Shock. +""" + +from fqbm.scenarios.presets import get_scenario, list_scenarios, ScenarioPreset + +__all__ = ["get_scenario", "list_scenarios", "ScenarioPreset"] diff --git a/src/fqbm/scenarios/presets.py b/src/fqbm/scenarios/presets.py new file mode 100644 index 0000000..839a4a3 --- /dev/null +++ b/src/fqbm/scenarios/presets.py @@ -0,0 +1,162 @@ +""" +Part XI: Historical case presets. + +1997 Asian: fixed FX, short-term foreign debt, sudden stop, reserve depletion, currency collapse. +2008 GFC: shadow leverage via repo, MBS collapse, capital erosion, QE stabilization. +2020 Pandemic: massive fiscal expansion, global swap lines, balance sheet expansion >$4T. +2022–23: dollar surge, EM stress, duration losses in advanced economies. +""" + +from dataclasses import dataclass +from typing import Any, Optional + +from fqbm.state import FQBMState +from fqbm.sheets.central_bank import CentralBankParams +from fqbm.sheets.commercial_bank import CommercialBankParams +from fqbm.sheets.fx_parity import FXParams +from fqbm.sheets.sovereign_debt import SovereignParams +from fqbm.sheets.commodity import CommodityParams +from fqbm.sheets.monte_carlo import ShockSpec + + +@dataclass +class ScenarioPreset: + """Named scenario: initial state and parameter overrides for workbook/MC.""" + name: str + description: str + state: FQBMState + cb_params: Optional[CentralBankParams] = None + bank_params: Optional[CommercialBankParams] = None + fx_params: Optional[FXParams] = None + sovereign_params: Optional[SovereignParams] = None + commodity_params: Optional[CommodityParams] = None + shock_spec: Optional[ShockSpec] = None + + +def _asia_1997() -> ScenarioPreset: + """1997 Asian Financial Crisis: fixed regimes, short-term foreign debt, sudden stop, reserve depletion.""" + state = FQBMState( + R=80, # low reserves relative to potential run + Deposits=1000, + Loans=900, + E_b=100, + S=1.0, + Spread=0.05, + ) + return ScenarioPreset( + name="asia_1997", + description="1997 Asian crisis: fixed FX, sudden stop, reserve depletion, liability revaluation shock", + state=state, + sovereign_params=SovereignParams( + debt_gdp=0.7, + reserves_gdp=0.15, + fx_vol=0.25, + growth_differential=-0.02, + ), + fx_params=FXParams(i_d=0.15, i_f=0.05, lambda_dornbusch=2.0), + shock_spec=ShockSpec( + deposit_outflow_mean=-0.10, + deposit_outflow_std=0.08, + fx_vol_mean=0.25, + fx_vol_std=0.1, + spread_widening_mean=0.02, + spread_widening_std=0.01, + ), + ) + + +def _gfc_2008() -> ScenarioPreset: + """2008 GFC: shadow leverage, MBS collapse, capital erosion, QE.""" + state = FQBMState( + R=150, + Deposits=2000, + Loans=2200, # high leverage + E_b=80, # thin equity + B=500, + E_cb=50, + ) + return ScenarioPreset( + name="gfc_2008", + description="2008 GFC: shadow leverage via repo, MBS collapse, capital erosion, QE stabilization", + state=state, + cb_params=CentralBankParams(d_B=100), # QE + sovereign_params=SovereignParams(debt_gdp=0.75, fx_vol=0.15), + shock_spec=ShockSpec( + default_rate_mean=0.05, + default_rate_std=0.03, + deposit_outflow_mean=-0.05, + deposit_outflow_std=0.06, + ), + ) + + +def _pandemic_2020() -> ScenarioPreset: + """2020 Pandemic: massive fiscal expansion, swap lines, balance sheet expansion.""" + state = FQBMState( + R=800, + B=2000, + C=100, + E_cb=100, + Deposits=3000, + Loans=2700, + E_b=200, + ) + return ScenarioPreset( + name="pandemic_2020", + description="2020 Pandemic: massive fiscal expansion, global swap lines, balance sheet expansion >$4T", + state=state, + cb_params=CentralBankParams(d_B=500), + bank_params=CommercialBankParams(d_deposits=200), + sovereign_params=SovereignParams(debt_gdp=0.9, reserves_gdp=0.25, growth_differential=-0.05), + ) + + +def _rate_shock_2022() -> ScenarioPreset: + """2022–23: dollar surge, EM stress, duration losses.""" + state = FQBMState( + R=200, + Deposits=1000, + Loans=900, + E_b=100, + S=1.2, # strong dollar + Spread=0.04, + ) + return ScenarioPreset( + name="rate_shock_2022", + description="2022–23 Global rate shock: dollar surge, EM stress, duration losses in advanced economies", + state=state, + fx_params=FXParams(i_d=0.05, i_f=0.02, lambda_dornbusch=1.5), + sovereign_params=SovereignParams( + debt_gdp=0.65, + reserves_gdp=0.18, + fx_vol=0.18, + r=0.06, + g=0.01, + ), + shock_spec=ShockSpec( + fx_vol_mean=0.15, + fx_vol_std=0.08, + spread_widening_mean=0.01, + spread_widening_std=0.008, + ), + ) + + +_REGISTRY: dict[str, ScenarioPreset] = {} + + +def _build_registry() -> dict[str, ScenarioPreset]: + if not _REGISTRY: + for preset in [_asia_1997(), _gfc_2008(), _pandemic_2020(), _rate_shock_2022()]: + _REGISTRY[preset.name] = preset + return _REGISTRY + + +def list_scenarios() -> list[str]: + """Return scenario names.""" + return list(_build_registry().keys()) + + +def get_scenario(name: str) -> Optional[ScenarioPreset]: + """Return preset by name, or None.""" + return _build_registry().get(name) diff --git a/src/fqbm/sheets/__init__.py b/src/fqbm/sheets/__init__.py new file mode 100644 index 0000000..d2de446 --- /dev/null +++ b/src/fqbm/sheets/__init__.py @@ -0,0 +1,21 @@ +"""Workbook sheets (Part XVI): Central Bank, Commercial Bank, Stress, FX, Sovereign, Commodity, Monte Carlo, Dashboard.""" + +from fqbm.sheets.central_bank import central_bank_step +from fqbm.sheets.commercial_bank import commercial_bank_step +from fqbm.sheets.capital_stress import stress_tables +from fqbm.sheets.fx_parity import fx_parity_step +from fqbm.sheets.sovereign_debt import sovereign_debt_step +from fqbm.sheets.commodity import commodity_step +from fqbm.sheets.monte_carlo import run_n_simulations +from fqbm.sheets.dashboard import dashboard_aggregate + +__all__ = [ + "central_bank_step", + "commercial_bank_step", + "stress_tables", + "fx_parity_step", + "sovereign_debt_step", + "commodity_step", + "run_n_simulations", + "dashboard_aggregate", +] diff --git a/src/fqbm/sheets/capital_stress.py b/src/fqbm/sheets/capital_stress.py new file mode 100644 index 0000000..ad292f1 --- /dev/null +++ b/src/fqbm/sheets/capital_stress.py @@ -0,0 +1,62 @@ +""" +Sheet 3: Capital and liquidity stress tables. + +Part XII — Quantitative stress: deposit run vs RCR; capital shock vs CR. +""" + +from dataclasses import dataclass +from typing import Optional + +import pandas as pd + +from fqbm.state import FQBMState + + +@dataclass +class StressParams: + """Parameters for stress scenarios.""" + reserve_coverage_ratio_min: float = 0.0 # threshold for "intervention" + capital_ratio_min: float = 0.08 # e.g. 8% + + +def stress_tables( + state: FQBMState, + params: Optional[StressParams] = None, +) -> dict: + """ + Compute liquidity stress (deposit run vs RCR) and capital shock table. + Returns dict with 'liquidity_stress' and 'capital_stress' DataFrames. + """ + params = params or StressParams() + reserves = state.R + deposits = state.Deposits + equity = state.E_b + + # Liquidity: RCR = Reserves / (Deposits * run_rate) proxy as Reserves / (run_amount) + run_rates = [0.10, 0.30, 0.50] + rows_liq = [] + for run in run_rates: + run_amount = deposits * run + rcr = (reserves / run_amount * 100) if run_amount > 0 else 0 + if rcr >= 80: + status = "Stable" + elif rcr >= 25: + status = "Tension" + else: + status = "Intervention" + rows_liq.append({"Deposit Run": f"{run*100:.0f}%", "Reserves": reserves, "RCR": f"{rcr:.0f}%", "Status": status}) + liquidity_stress = pd.DataFrame(rows_liq) + + # Capital: assume RWA ≈ Loans for simplicity + rwa = state.Loans + cr_baseline = (equity / rwa * 100) if rwa > 0 else 0 + loss_rates = [0, 0.08] + rows_cap = [] + for loss_pct in loss_rates: + eq = equity * (1 - loss_pct) + cr = (eq / rwa * 100) if rwa > 0 else 0 + status = "Compliant" if cr >= params.capital_ratio_min * 100 else "Breach" + rows_cap.append({"Loss": f"{loss_pct*100:.0f}%", "Equity": eq, "CR": f"{cr:.1f}%", "Status": status}) + capital_stress = pd.DataFrame(rows_cap) + + return {"liquidity_stress": liquidity_stress, "capital_stress": capital_stress} diff --git a/src/fqbm/sheets/cbdc.py b/src/fqbm/sheets/cbdc.py new file mode 100644 index 0000000..a79870d --- /dev/null +++ b/src/fqbm/sheets/cbdc.py @@ -0,0 +1,30 @@ +""" +Part IX: CBDC and digital reserve architecture (stub). + +Deposit -> CBDC shift: ΔBank Deposits = −X, ΔBank Reserves = −X, ΔCBDC Liability = +X. +Funding gap = Deposit Loss − Wholesale Replacement. +Not yet implemented; placeholder for gap documentation. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class CBDCParams: + """Placeholder for CBDC parameters.""" + deposit_shift: float = 0.0 # X in Part IX + wholesale_replacement: float = 0.0 + + +def deposit_to_cbdc_shift(X: float) -> dict[str, float]: + """ + Part IX: ΔBank Deposits = −X, ΔBank Reserves = −X, ΔCBDC Liability = +X. + Returns dict with keys: d_deposits, d_reserves, d_cbdc_liability. + """ + return {"d_deposits": -X, "d_reserves": -X, "d_cbdc_liability": X} + + +def funding_gap(deposit_loss: float, wholesale_replacement: float) -> float: + """Funding Gap = Deposit Loss − Wholesale Replacement (Part IX).""" + return deposit_loss - wholesale_replacement diff --git a/src/fqbm/sheets/ccp.py b/src/fqbm/sheets/ccp.py new file mode 100644 index 0000000..fdd739f --- /dev/null +++ b/src/fqbm/sheets/ccp.py @@ -0,0 +1,73 @@ +""" +Part VIII: Derivatives clearing and CCP structure. + +CCP identity: Assets (Margin Posted) = Liabilities (Margin Obligations). +Variation margin ensures daily neutrality. Default waterfall on VM call excess over liquidity buffer. +""" + +from dataclasses import dataclass +from typing import Optional, Sequence + + +@dataclass +class CCPParams: + """Parameters for CCP margin identity and default waterfall (Part VIII).""" + margin_posted: float = 0.0 + margin_obligations: float = 0.0 + vm_calls: float = 0.0 + liquidity_buffer: float = 0.0 + + +def ccp_identity(margin_posted: float, margin_obligations: float) -> bool: + """CCP identity: Margin Posted = Margin Obligations (Part VIII).""" + return abs(margin_posted - margin_obligations) < 1e-6 + + +def default_waterfall_triggered(vm_calls: float, liquidity_buffer: float) -> bool: + """If VM calls exceed liquidity buffer, default waterfall activated (Part VIII).""" + return vm_calls > liquidity_buffer + + +def variation_margin_flow( + mark_to_market_change: float, + member_pays_when_positive: bool = True, +) -> float: + """ + Variation margin call from mark-to-market change. + Convention: positive MTM change = gain for member; VM call = -MTM so member pays when they lose. + Returns VM amount (positive = member must pay). + """ + if member_pays_when_positive: + return -mark_to_market_change + return mark_to_market_change + + +def ccp_clearing_simulation( + vm_calls_per_period: Sequence[float], + liquidity_buffer_start: float, + initial_margin_posted: float = 0.0, +) -> list[dict]: + """ + Full clearing simulation over multiple periods. Each period: VM call; buffer is used first; + if buffer insufficient, default waterfall triggered for that period. + Returns list of dicts per period: buffer_start, vm_call, buffer_end, waterfall_triggered. + """ + buffer = liquidity_buffer_start + results = [] + for vm in vm_calls_per_period: + buffer_start = buffer + vm_call = max(0.0, vm) + buffer_after = buffer_start - vm_call + if buffer_after < 0: + waterfall = True + buffer = 0.0 + else: + waterfall = False + buffer = buffer_after + results.append({ + "buffer_start": buffer_start, + "vm_call": vm_call, + "buffer_end": buffer, + "waterfall_triggered": waterfall, + }) + return results diff --git a/src/fqbm/sheets/central_bank.py b/src/fqbm/sheets/central_bank.py new file mode 100644 index 0000000..5d70d7a --- /dev/null +++ b/src/fqbm/sheets/central_bank.py @@ -0,0 +1,38 @@ +""" +Sheet 1: Central Bank Model. + +Part II — 2.1. Identity: B + L_cb = R + C + E_cb. +Differential: dB + dL_cb = dR + dC + dE_cb. +QE: dB > 0 → dR > 0; QT: dB < 0 → dR < 0. +""" + +from dataclasses import dataclass +from typing import Optional + +from fqbm.state import FQBMState + + +@dataclass +class CentralBankParams: + """Parameters for central bank balance sheet dynamics.""" + # Optional policy shock (e.g. QE/QT): net change in bonds held by CB + d_B: float = 0.0 + d_L_cb: float = 0.0 + + +def central_bank_step( + state: FQBMState, + params: Optional[CentralBankParams] = None, +) -> FQBMState: + """ + Apply one period central bank balance sheet identity. + Identity: B + L_cb = R + C + E_cb. We assume dR absorbs the residual of dB + dL_cb - dC - dE_cb + (e.g. QE: buy bonds → increase R). + """ + params = params or CentralBankParams() + out = state.copy() + out.B += params.d_B + out.L_cb += params.d_L_cb + # Identity: dB + dL_cb = dR + dC + dE_cb; simplified dR absorbs + out.R += params.d_B + params.d_L_cb + return out diff --git a/src/fqbm/sheets/commercial_bank.py b/src/fqbm/sheets/commercial_bank.py new file mode 100644 index 0000000..2b9a1d1 --- /dev/null +++ b/src/fqbm/sheets/commercial_bank.py @@ -0,0 +1,42 @@ +""" +Sheet 2: Commercial Bank Model. + +Part II — 2.2. Loan creation: dLoans = dDeposits. +Capital constraint: CR = Equity / RWA >= k. Loans_max = Equity / k. +""" + +from dataclasses import dataclass +from typing import Optional + +from fqbm.state import FQBMState + + +@dataclass +class CommercialBankParams: + """Parameters for commercial bank credit.""" + k: float = 0.08 # minimum capital ratio (e.g. 8%) + d_deposits: float = 0.0 # exogenous deposit change → dLoans = dDeposits + + +def commercial_bank_step( + state: FQBMState, + params: Optional[CommercialBankParams] = None, +) -> FQBMState: + """ + Apply one period: dLoans = dDeposits (endogenous money). Equity unchanged unless losses. + """ + params = params or CommercialBankParams() + out = state.copy() + out.Loans += params.d_deposits + out.Deposits += params.d_deposits + return out + + +def loans_max(equity: float, k: float) -> float: + """Maximum credit capacity: Loans_max = Equity / k (Part II — 2.2).""" + return equity / k if k > 0 else 0.0 + + +def capital_ratio(equity: float, rwa: float) -> float: + """CR = Equity / RWA.""" + return equity / rwa if rwa > 0 else 0.0 diff --git a/src/fqbm/sheets/commodity.py b/src/fqbm/sheets/commodity.py new file mode 100644 index 0000000..0fb2988 --- /dev/null +++ b/src/fqbm/sheets/commodity.py @@ -0,0 +1,36 @@ +""" +Sheet 6: Commodity shock channel. + +Part VI — π = π_core + δ ΔO + β ΔS. Energy-importing economies: dual channel. +""" + +from dataclasses import dataclass +from typing import Optional + +from fqbm.state import FQBMState + + +@dataclass +class CommodityParams: + """Parameters for commodity pass-through.""" + pi_core: float = 0.02 + delta_oil: float = 0.1 # weight on oil price change + beta_fx: float = 0.2 + d_O: float = 0.0 + d_S: float = 0.0 + + +def inflation_composite(pi_core: float, d_O: float, d_S: float, delta: float, beta: float) -> float: + """π = π_core + δ ΔO + β ΔS (Part VI).""" + return pi_core + delta * d_O + beta * d_S + + +def commodity_step( + state: FQBMState, + params: Optional[CommodityParams] = None, +) -> FQBMState: + """Update commodity price level in state (O) and return state.""" + params = params or CommodityParams() + out = state.copy() + out.O = state.O * (1 + params.d_O) if state.O else (1 + params.d_O) + return out diff --git a/src/fqbm/sheets/dashboard.py b/src/fqbm/sheets/dashboard.py new file mode 100644 index 0000000..cfe3c3e --- /dev/null +++ b/src/fqbm/sheets/dashboard.py @@ -0,0 +1,55 @@ +""" +Sheet 8: Consolidated macro dashboard. + +Aggregate time series, key ratios, stress flags from workbook and Monte Carlo. +""" + +from typing import Any, Optional + +import pandas as pd + +from fqbm.state import FQBMState +from fqbm.sheets.monte_carlo import run_n_simulations, ShockSpec + + +def dashboard_aggregate( + state: FQBMState, + mc_runs: int = 0, + shock_spec: Optional[ShockSpec] = None, +) -> dict[str, Any]: + """ + Build dashboard: state snapshot, key ratios, optional MC summary. + """ + rwa = state.Loans + cr = (state.E_b / rwa) if rwa > 0 else 0 + ratios = { + "capital_ratio": cr, + "reserves_to_deposits": (state.R / state.Deposits) if state.Deposits else 0, + "loans_to_deposits": (state.Loans / state.Deposits) if state.Deposits else 0, + } + snapshot = { + "B": state.B, "R": state.R, "C": state.C, + "Loans": state.Loans, "Deposits": state.Deposits, + "E_cb": state.E_cb, "E_b": state.E_b, + "S": state.S, "Spread": state.Spread, "O": state.O, + "L_cb": getattr(state, "L_cb", 0), + } + out = {"state": snapshot, "ratios": ratios} + # Part VII: shadow banking leverage ratio (bank) + try: + from fqbm.sheets.shadow_banking import leverage_ratio + total_assets_bank = state.Loans + out["ratios"]["leverage_ratio_bank"] = leverage_ratio(total_assets_bank, state.E_b) + except Exception: + pass + + if mc_runs > 0: + df = run_n_simulations(mc_runs, state, shock_spec) + out["mc_summary"] = { + "p_insolvency": df["insolvent"].mean(), + "p_reserve_breach": df["reserve_breach"].mean(), + "inflation_mean": df["inflation"].mean(), + "inflation_std": df["inflation"].std(), + "p_debt_unsustainable": 1 - df["debt_sustainable"].mean(), + } + return out diff --git a/src/fqbm/sheets/fx_parity.py b/src/fqbm/sheets/fx_parity.py new file mode 100644 index 0000000..68ebdc3 --- /dev/null +++ b/src/fqbm/sheets/fx_parity.py @@ -0,0 +1,52 @@ +""" +Sheet 4: FX and parity engine. + +Part III — 3.2 CIP: (1+i_d) = (1+i_f)(F/S). UIP: E(ΔS/S) ≈ i_d - i_f. +Part III — 3.3 Dornbusch: s_t = s* + λ(i_t - i*). Part IV: π_import = β × ΔS. +""" + +from dataclasses import dataclass +from typing import Optional + +from fqbm.state import FQBMState + + +@dataclass +class FXParams: + """Parameters for FX and pass-through.""" + i_d: float = 0.05 + i_f: float = 0.03 + s_star: float = 1.0 + lambda_dornbusch: float = 1.5 + beta_pass_through: float = 0.2 # IMF range short-run 0.1–0.4 + + +def covered_interest_parity_fwd(s: float, i_d: float, i_f: float) -> float: + """(1+i_d) = (1+i_f)(F/S) => F = S * (1+i_d)/(1+i_f).""" + return s * (1 + i_d) / (1 + i_f) + + +def uncovered_interest_parity_ds(s: float, i_d: float, i_f: float) -> float: + """E(ΔS/S) ≈ i_d - i_f => expected proportional change in S.""" + return i_d - i_f + + +def dornbusch_s(s_star: float, i_d: float, i_f: float, lam: float) -> float: + """s_t = s* + λ(i_t - i*).""" + return s_star + lam * (i_d - i_f) + + +def fx_pass_through_inflation(dS: float, beta: float) -> float: + """π_import = β × ΔS (Part IV).""" + return beta * dS + + +def fx_parity_step( + state: FQBMState, + params: Optional[FXParams] = None, +) -> FQBMState: + """Update state with FX-consistent level (Dornbusch) and leave S in state.""" + params = params or FXParams() + out = state.copy() + out.S = dornbusch_s(params.s_star, params.i_d, params.i_f, params.lambda_dornbusch) + return out diff --git a/src/fqbm/sheets/monte_carlo.py b/src/fqbm/sheets/monte_carlo.py new file mode 100644 index 0000000..2f7c9a8 --- /dev/null +++ b/src/fqbm/sheets/monte_carlo.py @@ -0,0 +1,119 @@ +""" +Sheet 7: Monte Carlo stress engine. + +Part XIII — Shock variables: default rate, deposit outflow, FX vol, oil vol, sovereign spread. +Outputs: P(insolvency), reserve breach probability, inflation trajectory distribution, +debt sustainability risk threshold. +""" + +from dataclasses import dataclass, replace +from typing import Optional + +import numpy as np +import pandas as pd + +from fqbm.state import FQBMState +from fqbm.sheets.central_bank import central_bank_step, CentralBankParams +from fqbm.sheets.commercial_bank import commercial_bank_step, CommercialBankParams, capital_ratio +from fqbm.sheets.capital_stress import stress_tables, StressParams +from fqbm.sheets.sovereign_debt import sovereign_debt_step, SovereignParams, debt_sustainable +from fqbm.sheets.commodity import commodity_step, CommodityParams, inflation_composite + + +@dataclass +class ShockSpec: + """Distributions for shock variables (mean, std or scale).""" + default_rate_mean: float = 0.02 + default_rate_std: float = 0.01 + deposit_outflow_mean: float = 0.0 + deposit_outflow_std: float = 0.05 + fx_vol_mean: float = 0.1 + fx_vol_std: float = 0.05 + oil_vol_mean: float = 0.0 + oil_vol_std: float = 0.1 + spread_widening_mean: float = 0.0 + spread_widening_std: float = 0.005 + seed: Optional[int] = None + + +def _draw_shocks(spec: ShockSpec, rng: np.random.Generator) -> dict: + return { + "default_rate": float(rng.normal(spec.default_rate_mean, max(1e-6, spec.default_rate_std))), + "deposit_outflow": float(rng.normal(spec.deposit_outflow_mean, max(1e-6, spec.deposit_outflow_std))), + "fx_vol": float(rng.normal(spec.fx_vol_mean, max(1e-6, spec.fx_vol_std))), + "oil_vol": float(rng.normal(spec.oil_vol_mean, max(1e-6, spec.oil_vol_std))), + "spread_widening": float(rng.normal(spec.spread_widening_mean, max(1e-6, spec.spread_widening_std))), + } + + +def _single_run( + base_state: FQBMState, + shock: dict, + capital_ratio_min: float, + reserve_threshold: float, + sovereign_params: SovereignParams, +) -> dict: + state = base_state.copy() + # Apply deposit outflow + state.Deposits *= 1 + shock["deposit_outflow"] + state.Loans *= 1 + shock["deposit_outflow"] + # Defaults erode equity + state.E_b -= state.Loans * max(0, shock["default_rate"]) + state.Loans *= 1 - max(0, min(1, shock["default_rate"])) + # Reserves (simplified: no change from outflow in this stub) + # Sovereign (copy params so we don't mutate shared object) + sp = replace(sovereign_params, fx_vol=shock["fx_vol"]) + state = sovereign_debt_step(state, sp) + state.Spread += shock["spread_widening"] + # Commodity / inflation + pi = inflation_composite(0.02, shock["oil_vol"], shock["fx_vol"], 0.1, 0.2) + + rwa = state.Loans + cr = capital_ratio(state.E_b, rwa) if rwa > 0 else 0 + insolvent = state.E_b <= 0 or cr < capital_ratio_min + reserve_breach = state.R < reserve_threshold + sust = debt_sustainable( + sovereign_params.primary_balance_gdp, + sovereign_params.r, + sovereign_params.g, + sovereign_params.debt_gdp, + ) + + return { + "insolvent": insolvent, + "reserve_breach": reserve_breach, + "inflation": pi, + "debt_sustainable": sust, + "CR": cr, + "Spread": state.Spread, + } + + +def run_n_simulations( + n: int, + base_state: Optional[FQBMState] = None, + shock_spec: Optional[ShockSpec] = None, + capital_ratio_min: float = 0.08, + reserve_threshold: float = 0.0, + sovereign_params: Optional[SovereignParams] = None, +) -> pd.DataFrame: + """ + Run n Monte Carlo simulations. Returns DataFrame of outcomes and summary stats. + """ + base_state = base_state or FQBMState(R=200, Deposits=1000, Loans=900, E_b=100) + shock_spec = shock_spec or ShockSpec() + sovereign_params = sovereign_params or SovereignParams() + if reserve_threshold == 0: + reserve_threshold = base_state.R * 0.5 + + rng = np.random.default_rng(shock_spec.seed) + rows = [] + for _ in range(n): + shock = _draw_shocks(shock_spec, rng) + row = _single_run( + base_state, shock, capital_ratio_min, reserve_threshold, sovereign_params + ) + rows.append(row) + + df = pd.DataFrame(rows) + return df diff --git a/src/fqbm/sheets/shadow_banking.py b/src/fqbm/sheets/shadow_banking.py new file mode 100644 index 0000000..64129c4 --- /dev/null +++ b/src/fqbm/sheets/shadow_banking.py @@ -0,0 +1,102 @@ +""" +Part VII: Shadow banking and leverage. + +Leverage = Total Assets / Equity. Repo multiplier and margin spiral simulation. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ShadowBankingParams: + """Parameters for shadow banking / repo simulation.""" + haircut: float = 0.02 + repo_rounds: int = 3 + margin_initial_ratio: float = 0.1 + fire_sale_impact: float = 0.1 # price impact per unit of forced sale + + +def leverage_ratio(total_assets: float, equity: float) -> float: + """Leverage = Total Assets / Equity (Part VII). Returns 0 if equity <= 0.""" + return total_assets / equity if equity > 0 else 0.0 + + +def repo_multiplier( + initial_collateral: float, + haircut: float = 0.02, + rounds: int = 3, +) -> dict: + """ + Repo multiplier: re-use of collateral in successive repo rounds increases effective exposure. + Each round: (1 - haircut) of collateral can be re-pledged. Total effective financing + capacity ≈ initial_collateral * sum_{k=0}^{rounds-1} (1 - haircut)^k (simplified). + Returns dict with total_effective_collateral, multiplier_implied, rounds_used. + """ + if initial_collateral <= 0 or rounds <= 0: + return {"total_effective_collateral": 0.0, "multiplier_implied": 0.0, "rounds_used": 0} + factor = 1.0 - haircut + total = initial_collateral * sum(factor**k for k in range(rounds)) + multiplier = total / initial_collateral if initial_collateral else 0 + return { + "total_effective_collateral": total, + "multiplier_implied": multiplier, + "rounds_used": rounds, + } + + +def margin_spiral_risk( + collateral_value: float, + margin_requirement: float, + haircut: float, +) -> dict: + """ + One-step check: Collateral decline -> margin calls. Returns margin_call_triggered, shortfall. + """ + required = margin_requirement * (1 + haircut) + shortfall = max(0.0, required - collateral_value) + return {"margin_call_triggered": shortfall > 0, "shortfall": shortfall} + + +def margin_spiral_simulation( + initial_collateral: float, + margin_requirement: float, + haircut: float, + liquidity_buffer: float, + fire_sale_impact: float = 0.1, + max_rounds: int = 20, +) -> dict: + """ + Full margin spiral: collateral decline -> margin call -> forced selling -> collateral decline. + Iterates until no shortfall or max_rounds. Fire sale reduces collateral by fire_sale_impact * shortfall_sold. + Returns path of collateral, margin_calls, cumulative_forced_sales, waterfall_triggered. + """ + path_collateral = [initial_collateral] + path_margin_calls = [0.0] + cumulative_forced_sales = 0.0 + collateral = initial_collateral + required = margin_requirement * (1 + haircut) + waterfall_triggered = False + + for _ in range(max_rounds): + shortfall = max(0.0, required - collateral) + if shortfall <= 0: + break + if shortfall > liquidity_buffer: + waterfall_triggered = True + forced_sale = shortfall + cumulative_forced_sales += forced_sale + collateral = collateral - forced_sale * fire_sale_impact + collateral = max(0.0, collateral) + path_collateral.append(collateral) + path_margin_calls.append(shortfall) + if collateral <= 0: + break + + return { + "path_collateral": path_collateral, + "path_margin_calls": path_margin_calls, + "cumulative_forced_sales": cumulative_forced_sales, + "waterfall_triggered": waterfall_triggered, + "final_collateral": path_collateral[-1] if path_collateral else 0, + } diff --git a/src/fqbm/sheets/sovereign_debt.py b/src/fqbm/sheets/sovereign_debt.py new file mode 100644 index 0000000..cc63442 --- /dev/null +++ b/src/fqbm/sheets/sovereign_debt.py @@ -0,0 +1,46 @@ +""" +Sheet 5: Sovereign debt and spread model. + +Part V — Spread = f(Debt/GDP, FX vol, Reserve adequacy, Growth differential). +CDS ≈ Prob(Default) × LGD. Debt sustainability: Primary Balance >= (r - g) × Debt/GDP. +""" + +from dataclasses import dataclass +from typing import Optional + +from fqbm.state import FQBMState + + +@dataclass +class SovereignParams: + """Parameters for sovereign spread and sustainability.""" + debt_gdp: float = 0.6 + reserves_gdp: float = 0.2 + fx_vol: float = 0.1 + growth_differential: float = 0.0 + r: float = 0.05 + g: float = 0.02 + primary_balance_gdp: float = 0.0 + + +def spread_model(debt_gdp: float, reserves_gdp: float, fx_vol: float, growth_diff: float) -> float: + """Spread = f(Debt/GDP, Reserves/GDP, FXVol, Growth differential). Linear proxy.""" + return 0.01 * debt_gdp - 0.02 * reserves_gdp + 0.5 * fx_vol - 0.01 * growth_diff + + +def debt_sustainable(primary_balance_gdp: float, r: float, g: float, debt_gdp: float) -> bool: + """Primary Balance >= (r - g) × Debt/GDP (Part V).""" + return primary_balance_gdp >= (r - g) * debt_gdp + + +def sovereign_debt_step( + state: FQBMState, + params: Optional[SovereignParams] = None, +) -> FQBMState: + """Update sovereign spread in state from params (GDP ratios and vol).""" + params = params or SovereignParams() + out = state.copy() + out.Spread = max(0.0, spread_model( + params.debt_gdp, params.reserves_gdp, params.fx_vol, params.growth_differential + )) + return out diff --git a/src/fqbm/state.py b/src/fqbm/state.py new file mode 100644 index 0000000..691224a --- /dev/null +++ b/src/fqbm/state.py @@ -0,0 +1,91 @@ +""" +Shared state vector / context for the FQBM workbook and differential model. + +Part XIV state: X = [B, R, C, Loans, Deposits, E_cb, E_b, S, K, Spread, O, L_cb] +- B: government bonds (CB assets) +- R: reserves +- C: currency +- Loans: commercial bank loans +- Deposits: commercial bank deposits +- E_cb: central bank equity +- E_b: bank equity +- S: exchange rate (domestic per foreign) +- K: capital flows +- Spread: sovereign spread +- O: oil/commodity price index +- L_cb: central bank loans (Part II identity: B + L_cb = R + C + E_cb) +""" + +from dataclasses import dataclass, field +from typing import Optional + +# Default vector length for from_vector (supports 11 for backward compat) +STATE_VECTOR_LEN = 12 + + +@dataclass +class FQBMState: + """State vector for the full system (Part XIV).""" + + B: float = 0.0 + R: float = 0.0 + C: float = 0.0 + Loans: float = 0.0 + Deposits: float = 0.0 + E_cb: float = 0.0 + E_b: float = 0.0 + S: float = 1.0 + K: float = 0.0 + Spread: float = 0.0 + O: float = 1.0 + L_cb: float = 0.0 + reporting_date: Optional[str] = field(default=None, compare=False) + + def to_vector(self) -> list[float]: + return [ + self.B, self.R, self.C, self.Loans, self.Deposits, + self.E_cb, self.E_b, self.S, self.K, self.Spread, self.O, self.L_cb, + ] + + @classmethod + def from_vector(cls, x: list[float]) -> "FQBMState": + if len(x) < STATE_VECTOR_LEN: + x = list(x) + [0.0] * (STATE_VECTOR_LEN - len(x)) + return cls( + B=x[0], R=x[1], C=x[2], Loans=x[3], Deposits=x[4], + E_cb=x[5], E_b=x[6], S=x[7], K=x[8], Spread=x[9], O=x[10], + L_cb=x[11] if len(x) > 11 else 0.0, + ) + + def copy(self) -> "FQBMState": + return FQBMState( + B=self.B, R=self.R, C=self.C, Loans=self.Loans, Deposits=self.Deposits, + E_cb=self.E_cb, E_b=self.E_b, S=self.S, K=self.K, Spread=self.Spread, O=self.O, + L_cb=self.L_cb, reporting_date=self.reporting_date, + ) + + +def open_economy_view( + A_dom: float, + A_ext: float, + L_dom: float, + L_ext: float, + E: float, +) -> dict: + """ + Part I extended identity: A_dom + A_ext = L_dom + L_ext + E. + Returns dict with totals and identity_holds bool. + """ + total_assets = A_dom + A_ext + total_liab_equity = L_dom + L_ext + E + identity_holds = abs(total_assets - total_liab_equity) < 1e-9 + return { + "A_dom": A_dom, + "A_ext": A_ext, + "L_dom": L_dom, + "L_ext": L_ext, + "E": E, + "total_assets": total_assets, + "total_liab_equity": total_liab_equity, + "identity_holds": identity_holds, + } diff --git a/src/fqbm/system/__init__.py b/src/fqbm/system/__init__.py new file mode 100644 index 0000000..465d524 --- /dev/null +++ b/src/fqbm/system/__init__.py @@ -0,0 +1,5 @@ +"""Part XIV: Full system differential model and stability checks.""" + +from fqbm.system.differential_model import solve_trajectory, check_stability + +__all__ = ["solve_trajectory", "check_stability"] diff --git a/src/fqbm/system/differential_model.py b/src/fqbm/system/differential_model.py new file mode 100644 index 0000000..20b54e5 --- /dev/null +++ b/src/fqbm/system/differential_model.py @@ -0,0 +1,89 @@ +""" +Part XIV: Full system differential model. + +State vector X = [B, R, C, Loans, Deposits, E_cb, E_b, S, K, Spread, O, L_cb]. +dX/dt = F(monetary policy, fiscal policy, capital flows, commodity shocks, credit cycle). +Stability: CR >= k, Reserves >= SuddenStopThreshold, Debt/GDP sustainable under (r-g). +""" + +from dataclasses import dataclass +from typing import Callable, Optional + +import numpy as np +from scipy.integrate import solve_ivp + +from fqbm.state import FQBMState + + +@dataclass +class DifferentialParams: + """Policy and shock parameters for dX/dt.""" + # Placeholder policy inputs (can be time-varying in full version) + monetary_shock: float = 0.0 + fiscal_shock: float = 0.0 + capital_flow: float = 0.0 + commodity_shock: float = 0.0 + credit_cycle: float = 0.0 + + +def _dynamics(t: float, x: np.ndarray, params: DifferentialParams) -> np.ndarray: + """ + dX/dt = F(...). Index: 0=B, 1=R, 2=C, 3=Loans, 4=Deposits, 5=E_cb, 6=E_b, 7=S, 8=K, 9=Spread, 10=O, 11=L_cb. + """ + n = len(x) + B, R, C, L, D, E_cb, E_b, S, K, Spread, O = x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], x[8], x[9], x[10] + L_cb = x[11] if n > 11 else 0.0 + dB = params.monetary_shock + dR = dB + dC = 0.0 + dE_cb = 0.0 + dL_cb = 0.0 + dL = params.credit_cycle * L if L != 0 else 0 + dD = dL + dE_b = 0.0 + dS = params.capital_flow * S if S != 0 else 0 + dK = params.capital_flow + dSpread = -0.01 * params.capital_flow + dO = params.commodity_shock * O if O != 0 else 0 + return np.array([dB, dR, dC, dL, dD, dE_cb, dE_b, dS, dK, dSpread, dO, dL_cb]) + + +def solve_trajectory( + x0: FQBMState, + t_span: tuple[float, float], + params: Optional[DifferentialParams] = None, + t_eval: Optional[np.ndarray] = None, +) -> tuple[np.ndarray, np.ndarray]: + """ + Integrate dX/dt = F(...). Returns (t, X) where X is (n_times, 12). + """ + params = params or DifferentialParams() + y0 = np.array(x0.to_vector(), dtype=float) + sol = solve_ivp( + lambda t, y: _dynamics(t, y, params), + t_span, + y0, + t_eval=t_eval, + method="RK45", + ) + return sol.t, sol.y.T + + +def check_stability( + x: FQBMState, + k: float = 0.08, + reserve_threshold: float = 0.0, + debt_gdp: float = 0.6, + r: float = 0.05, + g: float = 0.02, + primary_balance_gdp: float = 0.0, +) -> dict[str, bool]: + """ + Stability conditions: (1) CR >= k, (2) Reserves >= threshold, (3) Debt sustainable. + """ + rwa = x.Loans + cr = (x.E_b / rwa) if rwa > 0 else 0 + ok_cr = cr >= k + ok_reserves = x.R >= reserve_threshold + ok_debt = primary_balance_gdp >= (r - g) * debt_gdp + return {"CR_ok": ok_cr, "reserves_ok": ok_reserves, "debt_sustainable": ok_debt} diff --git a/src/fqbm/workbook/__init__.py b/src/fqbm/workbook/__init__.py new file mode 100644 index 0000000..8103d43 --- /dev/null +++ b/src/fqbm/workbook/__init__.py @@ -0,0 +1,5 @@ +"""Workbook runner: execute full sheet sequence and optional Excel export.""" + +from fqbm.workbook.runner import run_workbook + +__all__ = ["run_workbook"] diff --git a/src/fqbm/workbook/runner.py b/src/fqbm/workbook/runner.py new file mode 100644 index 0000000..0c27944 --- /dev/null +++ b/src/fqbm/workbook/runner.py @@ -0,0 +1,170 @@ +""" +Workbook runner: execute full sheet sequence (Part XVI). + +Flow: Params + state → Sheet 1 (CB) → Sheet 2 (Bank) → Sheets 4,5,6 (FX, Sovereign, Commodity) +→ Sheet 3 (Stress) and Sheet 7 (MC) → Sheet 8 (Dashboard). Optional Excel export. +""" + +from typing import Any, Optional + +from fqbm.state import FQBMState +from fqbm.sheets.central_bank import central_bank_step, CentralBankParams +from fqbm.sheets.commercial_bank import commercial_bank_step, CommercialBankParams +from fqbm.sheets.fx_parity import fx_parity_step, FXParams +from fqbm.sheets.sovereign_debt import sovereign_debt_step, SovereignParams +from fqbm.sheets.commodity import commodity_step, CommodityParams +from fqbm.sheets.capital_stress import stress_tables, StressParams +from fqbm.sheets.monte_carlo import run_n_simulations, ShockSpec +from fqbm.sheets.dashboard import dashboard_aggregate + + +def run_workbook( + initial_state: Optional[FQBMState] = None, + cb_params: Optional[CentralBankParams] = None, + bank_params: Optional[CommercialBankParams] = None, + fx_params: Optional[FXParams] = None, + sovereign_params: Optional[SovereignParams] = None, + commodity_params: Optional[CommodityParams] = None, + stress_params: Optional[StressParams] = None, + mc_runs: int = 0, + shock_spec: Optional[ShockSpec] = None, + export_path: Optional[str] = None, + scenario: Optional[str] = None, + cbdc_params: Optional[Any] = None, + ccp_params: Optional[Any] = None, +) -> dict[str, Any]: + """ + Run full workbook: Sheet 1 → 2 → 4,5,6 → 3,7 → 8. Optionally export to Excel. + If scenario is set (e.g. 'asia_1997', 'gfc_2008', 'pandemic_2020', 'rate_shock_2022'), + initial state and params are overridden by the Part XI preset. + """ + if scenario: + from fqbm.scenarios import get_scenario + preset = get_scenario(scenario) + if preset: + state = preset.state.copy() + cb_params = cb_params or preset.cb_params + bank_params = bank_params or preset.bank_params + fx_params = fx_params or preset.fx_params + sovereign_params = sovereign_params or preset.sovereign_params + commodity_params = commodity_params or preset.commodity_params + shock_spec = shock_spec or preset.shock_spec + else: + state = (initial_state or FQBMState()).copy() + else: + state = (initial_state or FQBMState()).copy() + state = central_bank_step(state, cb_params) + state = commercial_bank_step(state, bank_params) + state = fx_parity_step(state, fx_params) + state = sovereign_debt_step(state, sovereign_params) + state = commodity_step(state, commodity_params) + + # Part IX: optional CBDC deposit shift (ΔDeposits = −X, ΔR = −X, ΔCBDC liability = +X) + cbdc_result = None + if cbdc_params is not None: + from fqbm.sheets.cbdc import deposit_to_cbdc_shift, CBDCParams + p = cbdc_params if isinstance(cbdc_params, CBDCParams) else CBDCParams(deposit_shift=getattr(cbdc_params, "deposit_shift", 0)) + if p.deposit_shift != 0: + d = deposit_to_cbdc_shift(p.deposit_shift) + state.Deposits += d["d_deposits"] + state.R += d["d_reserves"] + cbdc_result = {"d_deposits": d["d_deposits"], "d_reserves": d["d_reserves"], "cbdc_liability": d["d_cbdc_liability"]} + + stress = stress_tables(state, stress_params) + if mc_runs > 0: + mc_df = run_n_simulations(mc_runs, state, shock_spec) + else: + mc_df = None + + dashboard = dashboard_aggregate(state, mc_runs=mc_runs, shock_spec=shock_spec) + + # Part VIII: optional CCP metrics (margin identity check) + ccp_result = None + if ccp_params is not None: + try: + from fqbm.sheets.ccp import ccp_identity, default_waterfall_triggered, CCPParams as CCPParamsClass + if isinstance(ccp_params, CCPParamsClass): + margin_posted, margin_obligations = ccp_params.margin_posted, ccp_params.margin_obligations + vm_calls, liquidity_buffer = ccp_params.vm_calls, ccp_params.liquidity_buffer + else: + margin_posted = getattr(ccp_params, "margin_posted", 0) + margin_obligations = getattr(ccp_params, "margin_obligations", 0) + vm_calls = getattr(ccp_params, "vm_calls", 0) + liquidity_buffer = getattr(ccp_params, "liquidity_buffer", 0) + ccp_result = {"ccp_identity_holds": ccp_identity(margin_posted, margin_obligations), "waterfall_triggered": default_waterfall_triggered(vm_calls, liquidity_buffer)} + except Exception: + pass + + result = { + "state": state, + "stress": stress, + "mc_results": mc_df, + "dashboard": dashboard, + "cbdc": cbdc_result, + "ccp": ccp_result, + } + + if export_path and export_path.endswith((".xlsx", ".xls")): + _export_excel(result, export_path, include_ipsas=True) + + return result + + +def main() -> None: + """CLI entry point: run workbook and print dashboard summary.""" + state = FQBMState(R=200, Deposits=1000, Loans=900, E_b=100, B=500, E_cb=50) + result = run_workbook( + initial_state=state, + mc_runs=100, + export_path="fqbm_workbook.xlsx", + ) + print("Dashboard ratios:", result["dashboard"].get("ratios", {})) + if result["dashboard"].get("mc_summary"): + print("MC summary:", result["dashboard"]["mc_summary"]) + print("Done. Export: fqbm_workbook.xlsx") + + +_STATE_NAMES = ["B", "R", "C", "Loans", "Deposits", "E_cb", "E_b", "S", "K", "Spread", "O", "L_cb"] + + +def _export_excel(result: dict[str, Any], path: str, include_ipsas: bool = True) -> None: + """Write workbook results to Excel (optional dependency openpyxl). IPSAS sheet added when include_ipsas=True.""" + try: + from openpyxl import Workbook + except ImportError: + return + wb = Workbook() + ws = wb.active + ws.title = "Dashboard" + state = result["state"] + ws.append(["Variable", "Value"]) + for name, val in zip(_STATE_NAMES, state.to_vector()): + ws.append([name, val]) + if include_ipsas: + try: + from fqbm.ipsas.presentation import statement_of_financial_position + for entity, sheet_name in [("central_bank", "IPSAS_CB"), ("commercial_bank", "IPSAS_Bank")]: + df = statement_of_financial_position(state, entity=entity) + ws_ipsas = wb.create_sheet(sheet_name) + ws_ipsas.append(list(df.columns)) + for _, row in df.iterrows(): + ws_ipsas.append(row.tolist()) + except Exception: + pass + stress = result["stress"] + for sheet_name, df in [("Liquidity Stress", stress["liquidity_stress"]), ("Capital Stress", stress["capital_stress"])]: + ws2 = wb.create_sheet(sheet_name) + ws2.append(list(df.columns)) + for _, row in df.iterrows(): + ws2.append(row.tolist()) + if result.get("mc_results") is not None: + ws3 = wb.create_sheet("Monte Carlo") + df = result["mc_results"] + ws3.append(list(df.columns)) + for _, row in df.head(1000).iterrows(): + ws3.append(row.tolist()) + wb.save(path) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..aa8ab49 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# FQBM tests diff --git a/tests/test_capital_stress.py b/tests/test_capital_stress.py new file mode 100644 index 0000000..bc03319 --- /dev/null +++ b/tests/test_capital_stress.py @@ -0,0 +1,24 @@ +"""Tests for Sheet 3: Capital and liquidity stress (Part XII).""" + +from fqbm.state import FQBMState +from fqbm.sheets.capital_stress import stress_tables, StressParams + + +def test_stress_tables_structure(): + state = FQBMState(R=200, Deposits=1000, Loans=900, E_b=100) + out = stress_tables(state) + assert "liquidity_stress" in out + assert "capital_stress" in out + assert len(out["liquidity_stress"]) == 3 + assert len(out["capital_stress"]) == 2 + assert "RCR" in out["liquidity_stress"].columns + assert "Status" in out["liquidity_stress"].columns + assert "CR" in out["capital_stress"].columns + + +def test_stress_tables_with_params(): + state = FQBMState(R=50, Deposits=500, Loans=400, E_b=50) + params = StressParams(capital_ratio_min=0.10) + out = stress_tables(state, params) + assert out["capital_stress"] is not None + assert "Breach" in out["capital_stress"]["Status"].values or "Compliant" in out["capital_stress"]["Status"].values diff --git a/tests/test_central_bank.py b/tests/test_central_bank.py new file mode 100644 index 0000000..d835528 --- /dev/null +++ b/tests/test_central_bank.py @@ -0,0 +1,23 @@ +"""Unit tests for Sheet 1: Central Bank (Part II — 2.1).""" + +import pytest +from fqbm.state import FQBMState +from fqbm.sheets.central_bank import central_bank_step, CentralBankParams + + +def test_central_bank_identity_qe(): + """QE: dB > 0 → dR > 0.""" + state = FQBMState(B=100, R=50, C=20, E_cb=30) + params = CentralBankParams(d_B=10, d_L_cb=0) + out = central_bank_step(state, params) + assert out.B == 110 + assert out.R == 60 + + +def test_central_bank_identity_qt(): + """QT: dB < 0 → dR < 0.""" + state = FQBMState(B=100, R=50, C=20, E_cb=30) + params = CentralBankParams(d_B=-5, d_L_cb=0) + out = central_bank_step(state, params) + assert out.B == 95 + assert out.R == 45 diff --git a/tests/test_commercial_bank.py b/tests/test_commercial_bank.py new file mode 100644 index 0000000..3fed799 --- /dev/null +++ b/tests/test_commercial_bank.py @@ -0,0 +1,31 @@ +"""Unit tests for Sheet 2: Commercial Bank (Part II — 2.2).""" + +import pytest +from fqbm.state import FQBMState +from fqbm.sheets.commercial_bank import ( + commercial_bank_step, + CommercialBankParams, + loans_max, + capital_ratio, +) + + +def test_loan_deposit_identity(): + """dLoans = dDeposits.""" + state = FQBMState(Loans=900, Deposits=1000, E_b=100) + params = CommercialBankParams(d_deposits=50) + out = commercial_bank_step(state, params) + assert out.Loans == 950 + assert out.Deposits == 1050 + + +def test_loans_max(): + """Loans_max = Equity / k.""" + assert loans_max(100, 0.08) == pytest.approx(1250) + assert loans_max(100, 0.10) == 1000 + + +def test_capital_ratio(): + """CR = Equity / RWA.""" + assert capital_ratio(100, 1000) == 0.1 + assert capital_ratio(80, 1000) == 0.08 diff --git a/tests/test_commodity.py b/tests/test_commodity.py new file mode 100644 index 0000000..8f2bc38 --- /dev/null +++ b/tests/test_commodity.py @@ -0,0 +1,32 @@ +"""Tests for Sheet 6: Commodity shock channel (Part VI).""" + +import pytest +from fqbm.state import FQBMState +from fqbm.sheets.commodity import ( + inflation_composite, + commodity_step, + CommodityParams, +) + + +def test_inflation_composite(): + pi = inflation_composite(0.02, 0.1, 0.05, 0.1, 0.2) + assert pi == pytest.approx(0.02 + 0.1 * 0.1 + 0.2 * 0.05) + assert pi > 0.02 + + +def test_commodity_step(): + state = FQBMState(O=1.0) + params = CommodityParams(d_O=0.1) + out = commodity_step(state, params) + assert out.O == pytest.approx(1.1) + params2 = CommodityParams(d_O=-0.05) + out2 = commodity_step(out, params2) + assert out2.O == pytest.approx(1.1 * 0.95) + + +def test_commodity_step_zero_o(): + state = FQBMState(O=0.0) + params = CommodityParams(d_O=0.1) + out = commodity_step(state, params) + assert out.O == pytest.approx(1.1) \ No newline at end of file diff --git a/tests/test_differential_model.py b/tests/test_differential_model.py new file mode 100644 index 0000000..08a9f39 --- /dev/null +++ b/tests/test_differential_model.py @@ -0,0 +1,30 @@ +"""Tests for Part XIV: differential model and stability checks.""" + +import numpy as np +from fqbm.state import FQBMState +from fqbm.system.differential_model import ( + solve_trajectory, + check_stability, + DifferentialParams, +) + + +def test_solve_trajectory(): + x0 = FQBMState(B=100, R=50, Loans=500, Deposits=600, E_b=50, S=1.0) + params = DifferentialParams(monetary_shock=1.0, credit_cycle=0.01) + t, X = solve_trajectory(x0, (0, 1), params, t_eval=np.linspace(0, 1, 11)) + assert t.shape[0] == 11 + assert X.shape == (11, 12) + assert X[-1, 0] > X[0, 0] # B increased + assert X[-1, 1] > X[0, 1] # R increased + assert X[-1, 3] >= X[0, 3] # Loans + + +def test_check_stability(): + x = FQBMState(R=100, Loans=1000, E_b=100) + checks = check_stability(x, k=0.08, reserve_threshold=50, debt_gdp=0.5, r=0.05, g=0.02, primary_balance_gdp=0.02) + assert "CR_ok" in checks + assert "reserves_ok" in checks + assert "debt_sustainable" in checks + assert checks["CR_ok"] is True + assert checks["reserves_ok"] is True diff --git a/tests/test_fx_parity.py b/tests/test_fx_parity.py new file mode 100644 index 0000000..14ea3ea --- /dev/null +++ b/tests/test_fx_parity.py @@ -0,0 +1,42 @@ +"""Tests for Sheet 4: FX parity (Part III–IV).""" + +import pytest +from fqbm.state import FQBMState +from fqbm.sheets.fx_parity import ( + covered_interest_parity_fwd, + uncovered_interest_parity_ds, + dornbusch_s, + fx_pass_through_inflation, + fx_parity_step, + FXParams, +) + + +def test_cip(): + F = covered_interest_parity_fwd(1.0, 0.05, 0.03) + assert F > 1.0 + assert abs(F - 1.0 * (1.05 / 1.03)) < 1e-6 + + +def test_uip(): + ds = uncovered_interest_parity_ds(1.0, 0.05, 0.03) + assert abs(ds - 0.02) < 1e-6 + + +def test_dornbusch(): + s = dornbusch_s(1.0, 0.05, 0.03, 1.5) + assert s > 1.0 + assert abs(s - 1.0 - 1.5 * 0.02) < 1e-6 + + +def test_fx_pass_through(): + pi = fx_pass_through_inflation(0.1, 0.2) + assert pi == pytest.approx(0.02) + + +def test_fx_parity_step(): + state = FQBMState(S=1.0) + params = FXParams(s_star=1.0, i_d=0.05, i_f=0.03, lambda_dornbusch=2.0) + out = fx_parity_step(state, params) + assert out.S != 1.0 + assert out.S == pytest.approx(1.0 + 2.0 * 0.02) diff --git a/tests/test_ipsas.py b/tests/test_ipsas.py new file mode 100644 index 0000000..6afe90e --- /dev/null +++ b/tests/test_ipsas.py @@ -0,0 +1,111 @@ +"""Tests for IPSAS presentation layer.""" + +import pytest +from fqbm.state import FQBMState +from fqbm.ipsas.presentation import ( + statement_of_financial_position, + budget_vs_actual_structure, +) + + +def test_statement_of_financial_position_central_bank(): + state = FQBMState(B=100, R=50, C=30, E_cb=20) + df = statement_of_financial_position(state, entity="central_bank") + assert "Line item" in df.columns + assert "Amount" in df.columns + total_assets = df[df["Line item"] == "TOTAL ASSETS"]["Amount"].iloc[0] + assert total_assets == 100 + total_liab = df[df["Line item"] == "TOTAL LIABILITIES AND NET ASSETS"]["Amount"].iloc[0] + assert total_liab == 30 + 50 + 20 + + +def test_statement_of_financial_position_commercial_bank(): + state = FQBMState(Loans=900, Deposits=800, E_b=100) + df = statement_of_financial_position(state, entity="commercial_bank") + total_assets = df[df["Line item"] == "TOTAL ASSETS"]["Amount"].iloc[0] + assert total_assets == 900 + total_liab = df[df["Line item"] == "TOTAL LIABILITIES AND NET ASSETS"]["Amount"].iloc[0] + assert total_liab == 800 + 100 + + +def test_statement_of_financial_position_consolidated(): + state = FQBMState(B=50, R=100, C=20, Loans=500, Deposits=480, E_cb=50, E_b=100) + df = statement_of_financial_position(state, entity="consolidated") + total_assets = df[df["Line item"] == "TOTAL ASSETS"]["Amount"].iloc[0] + assert total_assets == 50 + 100 + 500 + total_liab = df[df["Line item"] == "TOTAL LIABILITIES AND NET ASSETS"]["Amount"].iloc[0] + assert total_liab == 20 + 480 + 50 + 100 + + +def test_budget_vs_actual_structure(): + df = budget_vs_actual_structure() + assert "Original budget" in df.columns + assert "Final budget" in df.columns + assert "Actual" in df.columns + assert "Variance" in df.columns + assert "Material (Y/N)" in df.columns + assert len(df) >= 6 + assert df.attrs.get("ipsas_24") is True + + +def test_budget_vs_actual_custom_lines(): + df = budget_vs_actual_structure(line_items=["Revenue", "Expense"]) + assert list(df["Line item"]) == ["Revenue", "Expense"] + assert df["Actual"].isna().all() + + +def test_cash_flow_statement_structure(): + from fqbm.ipsas.presentation import cash_flow_statement_structure + df = cash_flow_statement_structure() + assert "Category" in df.columns + assert "Line item" in df.columns + assert "Amount" in df.columns + assert df.attrs.get("ipsas_2") is True + cats = set(df["Category"].dropna().unique()) + assert "Operating" in cats + assert "Investing" in cats + assert "Financing" in cats + + +def test_statement_of_financial_performance_structure(): + from fqbm.ipsas.presentation import statement_of_financial_performance_structure + df = statement_of_financial_performance_structure() + assert "Line item" in df.columns + assert "Amount" in df.columns + assert df.attrs.get("ipsas_1_performance") is True + assert "Revenue" in " ".join(df["Line item"].astype(str)) + + +def test_notes_to_financial_statements_structure(): + from fqbm.ipsas.presentation import notes_to_financial_statements_structure + df = notes_to_financial_statements_structure() + assert "Note" in df.columns + assert "Title" in df.columns + assert "Content" in df.columns + assert df.attrs.get("notes_template") is True + assert len(df) >= 5 + + +def test_statement_of_financial_position_comparative(): + from fqbm.ipsas.presentation import statement_of_financial_position_comparative + prior = FQBMState(B=80, R=40, Loans=400, Deposits=380, E_b=20) + current = FQBMState(B=90, R=45, Loans=420, Deposits=398, E_b=22) + df = statement_of_financial_position_comparative(prior, current, entity="commercial_bank") + assert "Prior" in df.columns + assert "Current" in df.columns + assert "Change" in df.columns + assert df.attrs.get("comparative") is True + # Change should equal Current - Prior for numeric rows + num_mask = df["Prior"].apply(lambda x: isinstance(x, (int, float))) + if num_mask.any(): + assert (df.loc[num_mask, "Current"] - df.loc[num_mask, "Prior"] - df.loc[num_mask, "Change"]).abs().max() < 1e-6 + + +def test_maturity_risk_disclosure_structure(): + from fqbm.ipsas.presentation import maturity_risk_disclosure_structure + df = maturity_risk_disclosure_structure() + assert "0-1Y" in df.columns + assert "1-5Y" in df.columns + assert "Interest rate +100bp" in df.columns + assert "ECL" in df.columns + assert df.attrs.get("maturity_risk_disclosure") is True diff --git a/tests/test_matrix.py b/tests/test_matrix.py new file mode 100644 index 0000000..54e0fcc --- /dev/null +++ b/tests/test_matrix.py @@ -0,0 +1,32 @@ +"""Tests for Part I: Four-quadrant matrix.""" + +from fqbm.state import FQBMState +from fqbm.matrix import four_quadrant_matrix, four_quadrant_summary + + +def test_four_quadrant_matrix(): + state = FQBMState(B=100, R=50, C=30, Loans=200, Deposits=180, E_cb=20, E_b=20) + df = four_quadrant_matrix(state) + assert "Assets (Dr)" in df.columns + assert "Liabilities (Dr)" in df.columns + assert "Liabilities (Cr)" in df.columns + last = df.iloc[-1] + assert float(last["Assets (Dr)"]) == 300 + assert float(last["Liabilities (Cr)"]) == 40 + + +def test_four_quadrant_matrix_with_L_cb(): + state = FQBMState(B=80, R=40, C=20, Loans=100, Deposits=90, E_cb=20, E_b=10) + df = four_quadrant_matrix(state, L_cb=10) + last = df.iloc[-1] + assert float(last["Assets (Dr)"]) == 190 + + +def test_four_quadrant_summary_identity(): + state = FQBMState(B=50, R=30, C=10, Loans=100, Deposits=95, E_cb=10, E_b=5) + s = four_quadrant_summary(state) + assert "total_assets_dr" in s + assert "identity_A_eq_L_plus_E" in s + assert s["total_assets_dr"] == 150 + assert s["total_liabilities_dr"] + s["total_liabilities_cr"] == 150 + assert s["identity_A_eq_L_plus_E"] is True diff --git a/tests/test_regressions.py b/tests/test_regressions.py new file mode 100644 index 0000000..15b1927 --- /dev/null +++ b/tests/test_regressions.py @@ -0,0 +1,33 @@ +"""Tests for Part X empirical regressions.""" + +import pytest +from fqbm.empirical.regressions import ( + run_inflation_pass_through, + run_sovereign_spread, + run_capital_flow_sensitivity, + generate_synthetic_inflation, + generate_synthetic_spread, + generate_synthetic_capital_flow, +) + + +def test_inflation_pass_through(): + data = generate_synthetic_inflation(50, seed=42) + res = run_inflation_pass_through(data) + assert hasattr(res, "params") + assert "dS" in res.params.index + assert res.rsquared >= 0 + + +def test_sovereign_spread(): + data = generate_synthetic_spread(50, seed=42) + res = run_sovereign_spread(data) + assert "debt_gdp" in res.params.index + assert "reserves_gdp" in res.params.index + + +def test_capital_flow_sensitivity(): + data = generate_synthetic_capital_flow(50, seed=42) + res = run_capital_flow_sensitivity(data) + assert "rate_diff" in res.params.index + assert "risk_premium" in res.params.index diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py new file mode 100644 index 0000000..bc88f25 --- /dev/null +++ b/tests/test_scenarios.py @@ -0,0 +1,29 @@ +"""Tests for Part XI: Historical scenario presets.""" + +from fqbm.scenarios import get_scenario, list_scenarios +from fqbm.workbook.runner import run_workbook + + +def test_list_scenarios(): + names = list_scenarios() + assert "asia_1997" in names + assert "gfc_2008" in names + assert "pandemic_2020" in names + assert "rate_shock_2022" in names + + +def test_get_scenario(): + p = get_scenario("asia_1997") + assert p is not None + assert p.name == "asia_1997" + assert p.state.R == 80 + assert get_scenario("nonexistent") is None + + +def test_run_workbook_with_scenario(): + result = run_workbook(scenario="asia_1997", mc_runs=3) + assert result["state"].R == 80 + assert "dashboard" in result + result2 = run_workbook(scenario="gfc_2008", mc_runs=2) + assert result2["state"].Loans == 2200 + assert result2["state"].E_b == 80 diff --git a/tests/test_sovereign_debt.py b/tests/test_sovereign_debt.py new file mode 100644 index 0000000..2acec01 --- /dev/null +++ b/tests/test_sovereign_debt.py @@ -0,0 +1,31 @@ +"""Tests for Sheet 5: Sovereign debt and spread (Part V).""" + +from fqbm.state import FQBMState +from fqbm.sheets.sovereign_debt import ( + spread_model, + debt_sustainable, + sovereign_debt_step, + SovereignParams, +) + + +def test_spread_model(): + s = spread_model(0.8, 0.1, 0.2, -0.01) + assert s > 0 + # higher debt_gdp -> higher spread; higher reserves_gdp -> lower spread + assert spread_model(0.9, 0.1, 0.1, 0) > spread_model(0.5, 0.1, 0.1, 0) + assert spread_model(0.5, 0.3, 0.1, 0) < spread_model(0.5, 0.1, 0.1, 0) + + +def test_debt_sustainable(): + assert debt_sustainable(0.03, 0.05, 0.02, 0.6) is True # 0.03 >= 0.018 + assert debt_sustainable(0.01, 0.05, 0.02, 0.6) is False + assert debt_sustainable(0.0, 0.05, 0.02, 0.5) is False + + +def test_sovereign_debt_step(): + state = FQBMState(Spread=0.0) + params = SovereignParams(debt_gdp=0.7, reserves_gdp=0.2, fx_vol=0.1) + out = sovereign_debt_step(state, params) + assert out.Spread >= 0 + assert out.Spread != state.Spread or (params.debt_gdp == 0.6 and params.reserves_gdp == 0.2 and params.fx_vol == 0.1) diff --git a/tests/test_state_and_ipsas_remnants.py b/tests/test_state_and_ipsas_remnants.py new file mode 100644 index 0000000..6372700 --- /dev/null +++ b/tests/test_state_and_ipsas_remnants.py @@ -0,0 +1,61 @@ +"""Tests for state (L_cb, open economy) and new IPSAS helpers.""" + +import pytest +from fqbm.state import FQBMState, open_economy_view +from fqbm.ipsas.presentation import ( + statement_of_changes_in_net_assets_structure, + cash_flow_from_state_changes, + fx_translate, +) + + +def test_state_has_L_cb(): + state = FQBMState(B=100, L_cb=20, R=80, C=30, E_cb=10) + assert state.L_cb == 20 + assert len(state.to_vector()) == 12 + assert state.to_vector()[-1] == 20 + + +def test_from_vector_backward_compat(): + # 11 elements still supported + state = FQBMState.from_vector([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + assert state.L_cb == 0 + state12 = FQBMState.from_vector([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + assert state12.L_cb == 12 + + +def test_open_economy_view(): + v = open_economy_view(100, 50, 80, 40, 30) + assert v["total_assets"] == 150 + assert v["total_liab_equity"] == 150 + assert v["identity_holds"] is True + v2 = open_economy_view(100, 50, 90, 40, 30) + assert v2["identity_holds"] is False + + +def test_statement_of_changes_in_net_assets_structure(): + df = statement_of_changes_in_net_assets_structure() + assert "Opening balance" in df.columns + assert "Closing balance" in df.columns + assert df.attrs.get("ipsas_1_changes") is True + + +def test_cash_flow_from_state_changes(): + prev = FQBMState(R=100, C=20, B=50, Loans=200, Deposits=180) + curr = FQBMState(R=110, C=22, B=55, Loans=210, Deposits=195) + df = cash_flow_from_state_changes(prev, curr) + assert "Category" in df.columns + assert "Amount" in df.columns + assert df.attrs.get("ipsas_2_from_state") is True + # Net change should be consistent + row = df[df["Line item"].str.contains("Net increase", na=False)] + assert len(row) == 1 + + +def test_fx_translate(): + r = fx_translate(100.0, 1.0, 1.1) + assert r["local_prev"] == pytest.approx(100) + assert r["local_curr"] == pytest.approx(110) + assert r["fx_gain_loss"] == pytest.approx(10) + r2 = fx_translate(100.0, 1.2, 1.0) + assert r2["fx_gain_loss"] == pytest.approx(-20) diff --git a/tests/test_stubs.py b/tests/test_stubs.py new file mode 100644 index 0000000..e68268d --- /dev/null +++ b/tests/test_stubs.py @@ -0,0 +1,93 @@ +"""Tests for Part VII, VIII, IX (shadow banking, CCP, CBDC).""" + +import pytest +from fqbm.sheets.shadow_banking import ( + leverage_ratio, + margin_spiral_risk, + repo_multiplier, + margin_spiral_simulation, +) +from fqbm.sheets.ccp import ( + ccp_identity, + default_waterfall_triggered, + variation_margin_flow, + ccp_clearing_simulation, +) +from fqbm.sheets.cbdc import deposit_to_cbdc_shift, funding_gap + + +def test_leverage_ratio(): + assert leverage_ratio(1000, 100) == 10.0 + assert leverage_ratio(500, 0) == 0.0 + + +def test_margin_spiral_risk(): + r = margin_spiral_risk(100, 80, 0.1) + assert "margin_call_triggered" in r + assert "shortfall" in r + assert r["shortfall"] >= 0 + + +def test_ccp_identity(): + assert ccp_identity(100, 100) is True + assert ccp_identity(100, 99) is False + + +def test_default_waterfall_triggered(): + assert default_waterfall_triggered(150, 100) is True + assert default_waterfall_triggered(50, 100) is False + + +def test_deposit_to_cbdc_shift(): + d = deposit_to_cbdc_shift(50) + assert d["d_deposits"] == -50 + assert d["d_reserves"] == -50 + assert d["d_cbdc_liability"] == 50 + + +def test_funding_gap(): + assert funding_gap(100, 60) == 40 + assert funding_gap(50, 80) == -30 + + +def test_repo_multiplier(): + r = repo_multiplier(100.0, haircut=0.02, rounds=3) + assert r["total_effective_collateral"] > 100 + assert r["multiplier_implied"] > 1 + assert r["rounds_used"] == 3 + r0 = repo_multiplier(0, rounds=2) + assert r0["total_effective_collateral"] == 0 + + +def test_margin_spiral_simulation(): + r = margin_spiral_simulation( + initial_collateral=100, + margin_requirement=90, + haircut=0.1, + liquidity_buffer=5, + fire_sale_impact=0.2, + max_rounds=10, + ) + assert "path_collateral" in r + assert "path_margin_calls" in r + assert "cumulative_forced_sales" in r + assert "waterfall_triggered" in r + assert len(r["path_collateral"]) >= 1 + + +def test_variation_margin_flow(): + vm = variation_margin_flow(-10.0, member_pays_when_positive=True) + assert vm == pytest.approx(10.0) + vm2 = variation_margin_flow(5.0, member_pays_when_positive=True) + assert vm2 == pytest.approx(-5.0) + + +def test_ccp_clearing_simulation(): + results = ccp_clearing_simulation( + vm_calls_per_period=[5, 10, 15], + liquidity_buffer_start=20, + ) + assert len(results) == 3 + assert results[0]["buffer_end"] == 15 + assert results[1]["buffer_end"] == 5 + assert results[2]["waterfall_triggered"] is True \ No newline at end of file diff --git a/tests/test_workbook.py b/tests/test_workbook.py new file mode 100644 index 0000000..8953871 --- /dev/null +++ b/tests/test_workbook.py @@ -0,0 +1,65 @@ +"""Tests for workbook runner and dashboard.""" + +import os +import tempfile + +from fqbm.state import FQBMState +from fqbm.workbook.runner import run_workbook +from fqbm.sheets.dashboard import dashboard_aggregate +from fqbm.sheets.monte_carlo import run_n_simulations, ShockSpec + + +def test_run_workbook(): + state = FQBMState(R=200, Deposits=1000, Loans=900, E_b=100) + result = run_workbook(initial_state=state, mc_runs=5) + assert "state" in result + assert "stress" in result + assert "dashboard" in result + assert "liquidity_stress" in result["stress"] + assert "capital_stress" in result["stress"] + assert "ratios" in result["dashboard"] + + +def test_dashboard_aggregate(): + state = FQBMState(R=100, Deposits=500, Loans=400, E_b=80) + dash = dashboard_aggregate(state, mc_runs=3, shock_spec=ShockSpec(seed=42)) + assert "state" in dash + assert "ratios" in dash + assert "mc_summary" in dash + assert "p_insolvency" in dash["mc_summary"] + + +def test_run_n_simulations(): + df = run_n_simulations(20, shock_spec=ShockSpec(seed=1)) + assert len(df) == 20 + assert "insolvent" in df.columns + assert "reserve_breach" in df.columns + assert "inflation" in df.columns + assert "debt_sustainable" in df.columns + + +def test_run_workbook_with_cbdc(): + from fqbm.sheets.cbdc import CBDCParams + state = FQBMState(R=100, Deposits=500, Loans=400, E_b=80) + result = run_workbook(initial_state=state, cbdc_params=CBDCParams(deposit_shift=10)) + assert result["state"].Deposits == 490 + assert result["state"].R == 90 + assert result["cbdc"] is not None + assert result["cbdc"]["cbdc_liability"] == 10 + + +def test_run_workbook_with_ccp(): + from fqbm.sheets.ccp import CCPParams + result = run_workbook(ccp_params=CCPParams(margin_posted=100, margin_obligations=100, vm_calls=5, liquidity_buffer=10)) + assert result["ccp"] is not None + assert result["ccp"]["ccp_identity_holds"] is True + assert result["ccp"]["waterfall_triggered"] is False + + +def test_workbook_excel_export(): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "fqbm_out.xlsx") + state = FQBMState(R=100, Deposits=500, Loans=400, E_b=80) + run_workbook(initial_state=state, mc_runs=3, export_path=path) + assert os.path.isfile(path) + assert os.path.getsize(path) > 0