Initial commit: Four-Quadrant Balance Sheet Matrix (FQBM) framework
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -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
|
||||||
52
README.md
Normal file
52
README.md
Normal file
@@ -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).
|
||||||
1
data/.gitkeep
Normal file
1
data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
152
docs/GAPS_AND_MISSING.md
Normal file
152
docs/GAPS_AND_MISSING.md
Normal file
@@ -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.
|
||||||
144
docs/IPSAS_COMPLIANCE.md
Normal file
144
docs/IPSAS_COMPLIANCE.md
Normal file
@@ -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)**.
|
||||||
17
docs/REFERENCES.md
Normal file
17
docs/REFERENCES.md
Normal file
@@ -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*.
|
||||||
37
docs/framework_summary.md
Normal file
37
docs/framework_summary.md
Normal file
@@ -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).
|
||||||
BIN
fqbm_workbook.xlsx
Normal file
BIN
fqbm_workbook.xlsx
Normal file
Binary file not shown.
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=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"
|
||||||
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@@ -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
|
||||||
60
scripts/streamlit_dashboard.py
Normal file
60
scripts/streamlit_dashboard.py
Normal file
@@ -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%}")
|
||||||
9
src/fqbm/__init__.py
Normal file
9
src/fqbm/__init__.py
Normal file
@@ -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"
|
||||||
19
src/fqbm/empirical/__init__.py
Normal file
19
src/fqbm/empirical/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
84
src/fqbm/empirical/regressions.py
Normal file
84
src/fqbm/empirical/regressions.py
Normal file
@@ -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()
|
||||||
32
src/fqbm/ipsas/__init__.py
Normal file
32
src/fqbm/ipsas/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
330
src/fqbm/ipsas/presentation.py
Normal file
330
src/fqbm/ipsas/presentation.py
Normal file
@@ -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
|
||||||
59
src/fqbm/matrix.py
Normal file
59
src/fqbm/matrix.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
9
src/fqbm/scenarios/__init__.py
Normal file
9
src/fqbm/scenarios/__init__.py
Normal file
@@ -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"]
|
||||||
162
src/fqbm/scenarios/presets.py
Normal file
162
src/fqbm/scenarios/presets.py
Normal file
@@ -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)
|
||||||
21
src/fqbm/sheets/__init__.py
Normal file
21
src/fqbm/sheets/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
62
src/fqbm/sheets/capital_stress.py
Normal file
62
src/fqbm/sheets/capital_stress.py
Normal file
@@ -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}
|
||||||
30
src/fqbm/sheets/cbdc.py
Normal file
30
src/fqbm/sheets/cbdc.py
Normal file
@@ -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
|
||||||
73
src/fqbm/sheets/ccp.py
Normal file
73
src/fqbm/sheets/ccp.py
Normal file
@@ -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
|
||||||
38
src/fqbm/sheets/central_bank.py
Normal file
38
src/fqbm/sheets/central_bank.py
Normal file
@@ -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
|
||||||
42
src/fqbm/sheets/commercial_bank.py
Normal file
42
src/fqbm/sheets/commercial_bank.py
Normal file
@@ -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
|
||||||
36
src/fqbm/sheets/commodity.py
Normal file
36
src/fqbm/sheets/commodity.py
Normal file
@@ -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
|
||||||
55
src/fqbm/sheets/dashboard.py
Normal file
55
src/fqbm/sheets/dashboard.py
Normal file
@@ -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
|
||||||
52
src/fqbm/sheets/fx_parity.py
Normal file
52
src/fqbm/sheets/fx_parity.py
Normal file
@@ -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
|
||||||
119
src/fqbm/sheets/monte_carlo.py
Normal file
119
src/fqbm/sheets/monte_carlo.py
Normal file
@@ -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
|
||||||
102
src/fqbm/sheets/shadow_banking.py
Normal file
102
src/fqbm/sheets/shadow_banking.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
46
src/fqbm/sheets/sovereign_debt.py
Normal file
46
src/fqbm/sheets/sovereign_debt.py
Normal file
@@ -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
|
||||||
91
src/fqbm/state.py
Normal file
91
src/fqbm/state.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
5
src/fqbm/system/__init__.py
Normal file
5
src/fqbm/system/__init__.py
Normal file
@@ -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"]
|
||||||
89
src/fqbm/system/differential_model.py
Normal file
89
src/fqbm/system/differential_model.py
Normal file
@@ -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}
|
||||||
5
src/fqbm/workbook/__init__.py
Normal file
5
src/fqbm/workbook/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Workbook runner: execute full sheet sequence and optional Excel export."""
|
||||||
|
|
||||||
|
from fqbm.workbook.runner import run_workbook
|
||||||
|
|
||||||
|
__all__ = ["run_workbook"]
|
||||||
170
src/fqbm/workbook/runner.py
Normal file
170
src/fqbm/workbook/runner.py
Normal file
@@ -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()
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# FQBM tests
|
||||||
24
tests/test_capital_stress.py
Normal file
24
tests/test_capital_stress.py
Normal file
@@ -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
|
||||||
23
tests/test_central_bank.py
Normal file
23
tests/test_central_bank.py
Normal file
@@ -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
|
||||||
31
tests/test_commercial_bank.py
Normal file
31
tests/test_commercial_bank.py
Normal file
@@ -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
|
||||||
32
tests/test_commodity.py
Normal file
32
tests/test_commodity.py
Normal file
@@ -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)
|
||||||
30
tests/test_differential_model.py
Normal file
30
tests/test_differential_model.py
Normal file
@@ -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
|
||||||
42
tests/test_fx_parity.py
Normal file
42
tests/test_fx_parity.py
Normal file
@@ -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)
|
||||||
111
tests/test_ipsas.py
Normal file
111
tests/test_ipsas.py
Normal file
@@ -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
|
||||||
32
tests/test_matrix.py
Normal file
32
tests/test_matrix.py
Normal file
@@ -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
|
||||||
33
tests/test_regressions.py
Normal file
33
tests/test_regressions.py
Normal file
@@ -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
|
||||||
29
tests/test_scenarios.py
Normal file
29
tests/test_scenarios.py
Normal file
@@ -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
|
||||||
31
tests/test_sovereign_debt.py
Normal file
31
tests/test_sovereign_debt.py
Normal file
@@ -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)
|
||||||
61
tests/test_state_and_ipsas_remnants.py
Normal file
61
tests/test_state_and_ipsas_remnants.py
Normal file
@@ -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)
|
||||||
93
tests/test_stubs.py
Normal file
93
tests/test_stubs.py
Normal file
@@ -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
|
||||||
65
tests/test_workbook.py
Normal file
65
tests/test_workbook.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user