๐งฎ Financial Math Layer
Every financial calculation in LibreFolio โ purchase cost, P&L, capital, NAV, returns, FX
value, yields โ has exactly one implementation, and its home is
backend/app/services/financial_math/. The services, AI Export, the risk engine and the PAC
planner call this layer; the frontend shows the fields it computes.
This page is for developers who add or change a calculation: the rule, the package map, the boundaries of the layer, how to add a calculation, and the guards that keep each calculation in one place. The details of each engine are on the pages listed under Deep Pages.
Where the rule is written
The rule is decision 10 of .github/copilot-instructions.md (developer decision of
2026-10-11). Two instruction files carry it for coding agents:
.github/instructions/backend.instructions.md, section ยซFinancial Math โ One Homeยป, maps
where each calculation lives; .github/instructions/financial-math.instructions.md, loaded
for every file of the layer, holds its rules and its guards.
๐ฏ The Rule
- Search before writing. Before computing anything financial, look in
financial_math/. If the calculation exists, call it โ with parameters when your case differs โ instead of deriving it again. - Add there. A new calculation goes into
financial_math/, next to the ones it relates to, with its unit tests. - No local or partial copies. A loop over transactions that recomputes cost, capital, NAV or P&L outside these engines is a defect, even when it serves a single card or export: read the engine's output instead.
- Report copies. Report any copy you meet, old or new. Before closing work that touched financial math, search the project for copies of what you changed.
The technical indicators (services/signal_plugins/, services/signal_runtime.py), the
risk engine (services/risk/) and the PAC allocator
(services/pac_allocator/) stay where they are, but take the common math โ returns,
annualization, conversions, average cost, valuation โ from the layer. FX conversion itself stays
in the FX service: convert_bulk() in services/fx.py is the primitive the calculations call.
๐บ๏ธ Package Map
backend/app/services/financial_math/
โโโ __init__.py docstring only: the rule and the module map
โโโ average_cost.py average purchase cost (WAC): pools, cost steps, missing conversions
โโโ native_cash.py cash per broker and currency: balances, daily value, flows, FX result
โโโ returns.py simple ROI, TWRR, MWRR (values and daily series), annualization
โโโ valuation.py holding value from quantity, price and quote_base_quantity
โโโ price_marks.py daily mark of an asset: MARKET, TRADE_AVG, CARRIED or MISSING
โโโ yield_on_cost.py Yield on Cost of holdings, replayed from the transaction ledger
โโโ fifo/ FIFO lots
โ โโโ __init__.py docstring only
โ โโโ models.py input transactions, events, lots, fragments, closures, result
โ โโโ eligibility.py eligible_income_quantity(): D-1 eligibility, shared with Yield on Cost
โ โโโ engine.py FifoLotEngine, run_fifo_lot_engine()
โโโ portfolio/ daily portfolio state and the views derived from it
โ โโโ __init__.py docstring only
โ โโโ models.py classified transactions, daily states, contributions, results
โ โโโ classifier.py ScopeAwareTransactionClassifier: internal, external, in transit
โ โโโ daily_state.py DailyStateBuilder: the daily state vector, without I/O
โ โโโ derived_views.py DerivedViewsBuilder (history, P&L, allocationโฆ), cost positions
โ โโโ data_quality.py FX pair slug, configured pair sets, build_data_quality_report()
โ โโโ engine.py PortfolioCalculationEngine: loads a scope, runs the steps, caches
โโโ lots/ lots analysis math, called by lots_analysis_service.py
โโโ __init__.py docstring only
โโโ prices.py PriceHistoryLookup, opening_reference_price()
โโโ conversions.py FxRateResolver and the conversions each view needs
โโโ economic_events.py income and FEE/TAX as engine inputs, their outputs, what is withheld
โโโ histories.py value, return, price and performance histories
โโโ average_cost_lines.py per-broker and cumulative WAC history points
fifo/models.py also holds the engine's economic allocations (events, groups, per-lot
accumulators); portfolio/models.py holds ValuationSource, EngineEndState and
PortfolioCalculationResult.
โ๏ธ The Portfolio Package at Work
The portfolio engine has no developer page of its own: its cost side is in
WAC & Cost Basis, its valuation in
Price Resolver, and its model in the theory page
Portfolio Engine โ Mathematical Model.
One call of PortfolioCalculationEngine.calculate() (portfolio/engine.py):
- resolves the target currency (by default
get_effective_base_currency()) and the broker scope, each broker at itseffective_share(); - loads the scope's transactions and their split links, and classifies them
(
ScopeAwareTransactionClassifier,classifier.py); - returns the cached
PortfolioCalculationResultwhen theportfolio_blobcache holds one for the same key โ scope, currency, options and data fingerprints โ that starts on or before the requested start and ends on the requested end date; - loads the prices,
quote_base_quantity, currencies, types and classifications of the held assets, and preloads the FX rates in oneconvert_bulk()call (_preload_fx_rates()); - computes the average cost of every position (
compute_average_costs()overbuild_cost_positions()), oneAssetPriceSeriesper held asset (build_asset_price_series()) and the native cash (compute_native_cash()); - runs
DailyStateBuilder(daily_state.py) and stores its result in the cache.
Callers derive their views from the result with DerivedViewsBuilder (derived_views.py) โ
history, per-broker P&L, candles, acquisition funding, performance inputs, allocation โ and the
data-quality report with build_data_quality_report() (data_quality.py).
๐งฑ Layer Rules
flowchart LR
subgraph Callers["Callers"]
Services["portfolio_service<br/>broker_service<br/>lots_analysis_service"]
Others["AI Export<br/>risk ยท PAC planner"]
end
subgraph Layer["financial_math/"]
Math["the calculations"]
end
subgraph Infra["Infrastructure"]
FX["fx.convert_bulk()"]
Settings["settings_service<br/>base currency"]
Thresholds["data_quality_thresholds"]
Cache["utils/runtime/cache<br/>TTL cache"]
DB[("Database<br/>named read points")]
end
Services --> Math
Others --> Math
Math --> FX
Math --> Settings
Math --> Thresholds
Math --> Cache
Math --> DB
- A calculation owns its whole problem. It takes the plain data of the problem โ
movements, currencies, dates โ and calls the infrastructure it needs to complete it:
fx.convert_bulk()for conversions, the base currency fromsettings_service, the thresholds ofdata_quality_thresholds, the cache. Its caller does not prepare values for it:compute_average_costs()receives the amounts actually paid and converts each acquisition itself, at its own date, in one batched call. A calculation reads its own data from the database only at named points: the portfolio engine (portfolio/engine.py, withload_configured_fx_pair_sets()inportfolio/data_quality.pyfor the configured FX routes) and the Yield on Cost loaders (yield_on_cost.py). - Callers call the layer, never the other way round. The layer never imports the
orchestration services (
portfolio_service,broker_service,transaction_service,lots_analysis_service), the API, AI Export, risk, PAC or signals: they call it. - No silent fallback. A missing rate, price or cost is reported โ
MissingConversion(average cost),MissingCashConversion(native cash),PortfolioCalculationResult.missing_fx(portfolio engine), a data-quality issue โ and the value is unavailable: never a number, never the unconverted amount. -
No re-exports. Every
__init__.pyof the layer holds only its docstring. Import a name from the module that defines it:# โ From the module that defines the name from backend.app.services.financial_math.fifo.engine import run_fifo_lot_engine from backend.app.services.financial_math.fifo.models import FifoLot from backend.app.services.financial_math.portfolio.engine import PortfolioCalculationEngine # โ Not from the package: its __init__.py exports nothing from backend.app.services.financial_math.portfolio import PortfolioCalculationEngineTests replace module globals โ
convert_bulkabove all โ on the module that reads them.from โฆ import โฆbinds the name in the importing module when it loads: replacing it anywhere else, in the FX service or in a package that re-exported it, leaves that binding untouched, and the patch is mute. 5. One implementation. Before writing a calculation, search the layer. If it exists, call it, with parameters when your case differs:cumulative_to_annualized(total_return, days, min_days=โฆ)takes its 30-day floor as a parameter. Never keep a local or partial copy elsewhere, and report any copy you find. 6. The frontend computes none of these quantities. It shows the fields the backend computes. Interactive measures are the only exceptions โ the chart measuring tool (MeasureSignal.ts) and the what-if curves of the synthetic benchmarks (CompoundSignal.ts,LinearSignal.ts,SineSignal.ts), all infrontend/src/lib/charts/signals/โ and each exception must be declared where it lives.
โ Adding a Calculation
- Search. Look in the package map and grep the layer, for example
grep -rn "annualiz" backend/app/services/financial_math/. If the calculation exists, call it. - Place it. Put it next to the calculations it relates to. A new problem gets a new
module; a large engine becomes a sub-package with one module per step, like
fifo/andportfolio/. - Keep the boundaries. Import only the infrastructure and the layer's own modules, and report what cannot be computed instead of returning a number (see Layer Rules).
-
Test it. Write its unit tests, pure where possible: no database, the session
None, andconvert_bulkreplaced on the module that reads it. Adapted fromTestFxRateResolverLoadinbackend/test_scripts/test_services/test_lots_analysis_pure.py:from datetime import date from decimal import Decimal import pytest import backend.app.services.financial_math.lots.conversions as conversions_module from backend.app.services.financial_math.lots.conversions import FxRateResolver @pytest.mark.asyncio async def test_a_day_with_no_published_rate_is_left_unresolved(monkeypatch): async def fake_convert_bulk(_session, conversions, raise_on_error=False): return [None] * len(conversions), [] # Replaced where conversions.py reads it, not in backend.app.services.fx monkeypatch.setattr(conversions_module, "convert_bulk", fake_convert_bulk) resolver = FxRateResolver("EUR") resolver.need("USD", date(2024, 1, 10)) await resolver.load(None) assert resolver.convert(Decimal("100"), "USD", date(2024, 1, 10)) is NoneThe modules that read
convert_bulkareaverage_cost.py,native_cash.py,yield_on_cost.py,lots/conversions.pyandportfolio/engine.py. A pure test of a layer module can live inbackend/test_scripts/test_services/test_financial_math/, whichservices financial-mathruns as a whole, as a pure unit. 5. Update the maps. The module map in the docstring offinancial_math/__init__.py(and of the sub-package, for a module offifo/,portfolio/orlots/), the table in.github/instructions/financial-math.instructions.md, the map in.github/instructions/backend.instructions.md(section ยซFinancial Math โ One Homeยป), and the package map on this page. 6. Look for copies. Before closing, search the project for copies of what you changed, and report them.
๐ก๏ธ Guards and Tests
๐ง The Layer Guard
backend/test_scripts/test_services/test_financial_math/test_layer_boundaries.py parses the
source of every module of the layer with ast โ it imports none of them: no database, no
server โ and fails when:
- a module imports an orchestration service, the API, AI Export, risk (
risk_pluginsincluded), PAC (pac_allocator,portfolio_allocation_source) or signals (everysignal*module), rule 2. Every import counts, a lazy one inside a function or one underif TYPE_CHECKING:included; the infrastructure (fx,settings_service,data_quality_thresholds,utils,db,schemas) and the layer's own modules stay allowed; - an
__init__.pyholds anything besides its docstring โ an import, an assignment,__all__โ rule 4; - one of the old homes below exists again, for example after merging a branch older than the
move. A
__pycache__folder left behind by git does not count.
Each detector first proves, on synthetic sources, that it sees what it looks for, and the scan must reach the layer's known modules: a rule that asserts an absence never passes on an empty scan. The guard runs with the layer's tests:
| Old home (removed) | Now in financial_math/ |
|---|---|
backend/app/utils/financial/roi_utils.py |
returns.py |
backend/app/utils/financial/valuation_utils.py |
valuation.py |
backend/app/utils/financial/__init__.py (lazy re-export barrel) |
Nothing: import from the module that defines the name |
backend/app/services/price_resolver.py |
price_marks.py |
backend/app/services/yield_on_cost.py |
yield_on_cost.py |
backend/app/services/fifo_lot_engine.py |
fifo/: models.py, eligibility.py, engine.py |
backend/app/services/portfolio_engine.py |
portfolio/: models.py, classifier.py, daily_state.py, derived_views.py, data_quality.py, engine.py |
The sync builders of LotsAnalysisService that never touched the database moved too, as
functions of lots/; the service keeps the orchestration, the database loaders, the response
builders and the analysis' data-quality report (see
Lots Analysis Service).
๐ฏ Targeted Test Selectors
| Command | Runs (under backend/test_scripts/) |
Covers |
|---|---|---|
./dev.py test services financial-math |
test_services/test_financial_math/ |
average_cost.py, native_cash.py and the layer guard; a pure unit |
./dev.py test services roi-fifo-utils |
test_services/test_financial/ |
Returns, FIFO lots, price marks, Yield on Cost, the portfolio engine (test_portfolio_engine/), and the portfolio and lots services that call them |
./dev.py test services portfolio-engine |
test_services/test_portfolio_engine_vnext.py |
DailyStateBuilder |
./dev.py test services lots-analysis-pure |
test_services/test_lots_analysis_pure.py |
lots/ and the service's sync builders; a pure unit |
./dev.py test utils roi-utils |
test_utilities/test_roi_utils.py |
returns.py |
Test files keep their historical names
The move changed no test path: test_roi_utils.py, test_fifo_lot_engine.py,
test_price_resolver.py and the test_portfolio_engine/ folder keep the names of the
modules they were written for, and import from the new homes.
๐ Deep Pages
| Modules | Developer page | Theory |
|---|---|---|
average_cost.py, native_cash.py |
WAC & Cost Basis | Weighted Average Cost |
fifo/ |
FIFO Lot Engine | FIFO Engine |
lots/ |
Lots Analysis Service | FIFO Lot Analysis |
price_marks.py |
Price Resolver | Price Resolution |
portfolio/ |
The portfolio package at work; its data-quality report in Data Quality Banner; its cache in FX Architecture | Portfolio Engine โ Mathematical Model |
returns.py |
โ | ROI, TWRR, MWRR |
yield_on_cost.py |
โ | Yield on Cost |
๐ Related
- โ๏ธ WAC & Cost Basis โ The average-cost and native-cash functions, and every caller
- ๐งฌ FIFO Lot Engine โ The event-sourced lot replay
- ๐ Lots Analysis Service โ The orchestration over
fifo/andlots/ - ๐ Price Resolver โ The single daily valuation mark
- ๐ Risk Engine โ Risk analytics, a caller of the layer