Generated file. Source:
docs/ODS_logic.mdEdit the source document and runnpm run docs:syncto refresh this published copy.
ODS Logic¶
This file documents the current OTS calculation engine implementation.
The filename is kept as requested: ODS_logic.md.
The actual bond covered here is OTS (3-miesieczne obligacje skarbowe).
Document Metadata¶
- Document status: handoff / implementation specification
- Scope: current
OTSengine only - Primary code reference:
ots.ts: src/app/domain/bond-engine/strategies/ots.ts- Related tests:
ots.strategy.test.ts: tests/ots.strategy.test.ts- Related prompt source:
init_prompt.md: ai/init_prompt.md- Last reviewed against code: 2026-03-23
Status¶
This is an implementation document, not a legal or product-source document.
It describes:
- what the current code does
- which modeling decisions are explicit
- which semantics are still product assumptions
If the product specification changes, this file should be updated together with:
- the strategy implementation
- the automated tests
- any user-facing documentation
Purpose¶
This document is intended to let another programmer:
- understand the business logic of the
OTSstrategy - understand the technical model used by the engine
- understand the meaning of outputs used by the UI
- change the code safely with clear traceability to tests
Scope¶
Included in scope:
OTSmonthly simulation- quarterly redemption logic
- reinvestment logic
- purchase-event history for future reporting
- final partial-quarter settlement
- monthly snapshots
- yearly result derivation
- profitability summary outputs
Out of scope:
- other bond types
- generic bond engine architecture outside the
OTSstrategy - legal validation of Treasury bond product rules
- UI layout and presentation details
- future non-annual user-input horizons
Glossary¶
bond unit price- nominal value of one bond, currently
100 PLN batch- one purchase lot containing some number of bonds, all bought in the same month
natural redemption- standard settlement after a full
3-monthOTScycle early redemption- settlement before completing the full
3-monthcycle cash- liquid uninvested amount currently not stored in active bond batches
gross value- valuation of cash plus active nominal value plus accrued gross interest
liquidation value- hypothetical or actual exit value after applying tax and early redemption cost where relevant
carried value- value of a still-running portfolio without forcing premature liquidation
quarter-aligned horizon- simulation horizon where
totalMonths % 3 === 0 year-aligned horizon- simulation horizon where
totalMonths % 12 === 0
Product Definition¶
Current OTS engine assumptions:
- single bond nominal value:
100 PLN - bond duration:
3 months - interest rate: fixed, annualized
- current catalog rate:
2.50% - capitalization: none
- interest payment timing: only at redemption
- tax rate:
19% - early redemption cost:
3 PLNper bond
Implemented rounding policy:
- internal engine monetary values are rounded to
0.001 PLN - final summary outputs are rounded to
0.01 PLN - percentage display formatting remains a presentation concern outside this strategy
Current business interpretation in code:
- a natural
OTScycle lasts exactly3 months - before month 3 there is no cash payout
- at month 3 the position is redeemed, taxed, and converted back to cash
- available cash is then reinvested into as many full bonds as possible
Requirement / Implementation / Assumption Model¶
Each important rule in this document should be read in one of three categories:
Requirement- expected business behavior the engine must satisfy
Current Implementation- how the code currently realizes that behavior
Open Assumption- behavior currently chosen in code but still needing stronger product/legal confirmation
Business Requirements¶
BR-OTS-001 Product Duration¶
- Requirement:
OTSmust be modeled as a3-monthproduct, not a yearly product.
BR-OTS-002 Interest Timing¶
- Requirement:
- interest is not paid monthly
- interest is realized only at redemption
BR-OTS-003 Natural Redemption¶
- Requirement:
- each batch must redeem after exactly
3 months - principal and net interest must return to cash at that point
BR-OTS-004 Reinvestment¶
- Requirement:
- after natural redemption, all available cash should be used to buy as many full new bonds as possible
BR-OTS-005 Cash Remainder¶
- Requirement:
- any amount below
100 PLNthat cannot buy a new bond remains in cash
BR-OTS-006 Early Redemption at Final Partial Quarter¶
- Requirement:
- if simulation ends before a batch reaches
3 months, that batch must be settled proportionally - early redemption cost must apply
BR-OTS-007 Taxes¶
- Requirement:
- tax is charged on interest realized at settlement
BR-OTS-008 Monthly Simulation Axis¶
- Requirement:
- the internal engine must simulate month by month
BR-OTS-009 Yearly Reporting¶
- Requirement:
- the engine must expose annual reporting values for the rest of the app
BR-OTS-010 Deterministic Results¶
- Requirement:
- same inputs must always produce the same outputs
BR-OTS-011 Purchase History Retention¶
- Requirement:
- the engine must retain purchase events created during the simulation so future reporting layers can present them without replaying the simulation
Engine Scope¶
The OTS path is intentionally separate from the generic yearly bond calculation path.
Reason:
OTSis quarter-based, not year-based- reinvestment happens every quarter
- yearly simulation would distort both tax timing and reinvestment timing
So the engine:
- simulates month-by-month
- settles natural redemptions every 3 months
- emits monthly snapshots
- emits purchase events for initial allocation and reinvestment actions
- derives yearly rows from those monthly snapshots
Input Contract¶
simulateOtsBond(input) expects:
bondId- unit: enum-like bond identifier
- expected current value:
OTS bond- unit: bond catalog definition
- must contain valid
OTSparameters initialAmount- unit:
PLN - expected range:
>= 0 totalMonths- unit: months
- expected range: integer
>= 0 inflationRatePercent- unit: percent, e.g.
2.5means2.5% config.bondUnitPrice- unit:
PLN - current value:
100 config.taxRate- unit: decimal fraction
- current value:
0.19
Relevant runtime types:
OtsSimulationInputOtsMonthSnapshotOtsSimulationResultBondPurchaseEvent
Output Contract¶
simulateOtsBond(input) returns:
monthSnapshots- one item for each simulated month
yearlyResults- one item for each completed annual cut point only
purchaseEvents- one item for each initial allocation or quarterly reinvestment purchase
totalTaxPaid- cumulative tax actually charged during the simulation
totalEarlyRedemptionCosts- cumulative early redemption costs actually charged during the simulation
finalGrossValue- gross valuation of the final month snapshot
finalNetValue- final liquidation outcome of the simulation
totalNominalProfitfinalNetValue - initialAmounttotalRealProfit- inflation-adjusted profit vs
initialAmount irr- internal rate of return on final net value over
totalMonths / 12 cagr- compound annual growth rate on final net value over
totalMonths / 12
calculateOtsBondResult(...) additionally exposes the same purchase history through the shared reusable engine contract:
simulationDetails.strategy- current value:
ots-quarterly-rollover simulationDetails.purchaseEvents- mirrors
simulateOtsBond(...).purchaseEvents
Preconditions¶
The strategy assumes:
initialAmount >= 0totalMonths >= 0bondUnitPrice > 0taxRate >= 0- the
bondobject contains validOTSdata
The strategy does not currently enforce product-schema validation by itself. That validation is expected to happen at the catalog/interface layer.
Internal State¶
Core internal state:
cashpositionspurchaseEventstotalTaxPaidtotalEarlyRedemptionCostsyearTaxPaidyearEarlyRedemptionCosts
Each active position stores:
bonds: number of bonds in the batchpurchaseMonth: month when the batch was created
Why batches exist:
- each quarterly purchase becomes a separate lot
- each lot can be at a different age
- this is necessary for correct quarter-end and partial-final-quarter settlement
Important distinction:
positionsare the invested lotscashis the liquid remainder not currently investedpurchaseEventsis the reporting ledger of buy actions taken during simulation
The engine derives totals from:
- cash mutations
- purchase events
- per-month tax/cost events
- per-year aggregated tax/cost counters
High-Level Flow¶
flowchart TD
A[Start simulation] --> B[Buy initial full bonds]
B --> C[Loop month = 1..totalMonths]
C --> D{Is month divisible by 3?}
D -- Yes --> E[Settle all mature 3-month positions]
E --> F[Add principal and net interest to cash]
F --> G[Reinvest cash into full new bonds]
D -- No --> H[Keep positions active]
G --> I{Is this the final month and not quarter-end?}
H --> I
I -- Yes --> J[Early redeem all remaining positions]
I -- No --> K[Build month snapshot]
J --> K
K --> L{Is month divisible by 12?}
L -- Yes --> M[Build yearly result]
L -- No --> N[Continue]
M --> N
N --> O{More months?}
O -- Yes --> C
O -- No --> P[Build final simulation result]
Initialization¶
Current Implementation:
cash = initialAmount- buy as many full bonds as possible:
initialBondCount = floor(cash / bondUnitPrice)- create one initial batch if
initialBondCount > 0 - reduce cash by purchased nominal value
Example for 1050 PLN:
- buy
10bonds cash = 50 PLN- create batch:
bonds = 10purchaseMonth = 0- create purchase event:
month = 0reason = initial-allocationpurchasedBondCount = 10sourceBondCount = 0additionalBondCountFromEarnings = 0cashBeforePurchase = 1050cashAfterPurchase = 50
If initialAmount < 100 PLN:
- no bonds are purchased
- all value remains in
cash - the simulation still runs, but there are no active positions
Natural Quarterly Redemption¶
Current Implementation:
Every month divisible by 3:
- inspect all active positions
- compute age:
ageInMonths = currentMonth - purchaseMonth- if
ageInMonths === 3, redeem that position naturally
Natural redemption formula:
grossInterest = bonds * bondUnitPrice * annualRate * (3 / 12)taxPaid = grossInterest * taxRatenetCashInflow = principal + grossInterest - taxPaid
Then:
- add
netCashInflowtocash - remove redeemed batch from
positions
Important:
- there is no monthly interest payout
- accrued interest exists economically before maturity, but cash appears only at settlement
- redemption is batch-based, not global-account based
- different batches may redeem in the same month
For reinvestment reporting, the engine also computes:
sourceBondCount- how many matured bonds produced the reinvestment cash pool in that month
additionalBondCountFromEarningspurchasedBondCount - sourceBondCount, floored at0- this explicitly captures when earnings and leftover cash were enough to buy extra bonds
Reinvestment Rule¶
Requirement:
- all available cash should be used to buy as many full new bonds as possible
Current Implementation:
After all natural quarterly redemptions are settled:
- compute:
reinvestedBondCount = floor(cash / bondUnitPrice)- if
reinvestedBondCount > 0and current month is not the final month: - create new batch with:
bonds = reinvestedBondCountpurchaseMonth = currentMonth
- reduce
cashby purchased nominal value
This means:
- principal from mature batches is reinvested
- net interest is reinvested
- old leftover cash is also used
- any remainder below
100 PLNstays in cash
Important:
- reinvestment happens after all mature batches in the month are settled
- reinvestment is skipped in the final month, because the engine is terminating
Final Month Early Redemption¶
Requirement:
- only non-mature active batches at the end of the simulation use the early-redemption path
Current Implementation:
If the final month is not quarter-aligned:
- all remaining active positions are redeemed early
For each remaining position:
ageInMonths = finalMonth - purchaseMonthgrossInterest = bonds * bondUnitPrice * annualRate * (ageInMonths / 12)taxPaid = grossInterest * taxRateearlyRedemptionCost = bonds * bond.earlyRedemptionCost- cash inflow:
principal + grossInterest - taxPaid - earlyRedemptionCost
Then:
- add this amount to
cash - clear all positions
Important:
- partial-quarter interest is proportional
- early redemption cost is charged per bond
- this happens only at final non-quarter-aligned termination
If the final month is quarter-aligned:
- no early redemption path is used
- all naturally maturing batches are settled via the standard quarterly path
- no active positions should remain after settlement
Accrued Interest Function¶
The helper used by the strategy is logically:
accruedInterest = bonds * bondUnitPrice * annualRateDecimal * (monthsHeld / 12)
This is used in two contexts:
- real settlement of a batch at natural or early redemption
- snapshot valuation of active positions
Monthly Snapshot Semantics¶
For every simulated month the strategy produces OtsMonthSnapshot.
Fields:
monthcashactiveBondCountgrossValueliquidationValuetaxPaidearlyRedemptionCosthadNaturalRedemptionhadEarlyRedemption
grossValue¶
Current meaning:
cash- plus nominal value of all active batches
- plus accrued but not yet paid gross interest on active batches
This is a valuation view, not a realized-cash view.
liquidationValue¶
Current meaning:
cash- plus nominal value of all active batches
- plus accrued interest on active batches
- minus tax that would be due if those active batches were liquidated now
- minus early redemption cost that would be due if those active batches were liquidated now
This is a hypothetical immediate-exit value.
Event fields¶
taxPaid: tax actually charged in that monthearlyRedemptionCost: early redemption cost actually charged in that monthhadNaturalRedemption: at least one natural quarterly redemption occurred in that monthhadEarlyRedemption: early redemption occurred in that month
Snapshot timing:
- the monthly snapshot is created after that month’s settlement and reinvestment logic
- therefore the snapshot represents end-of-month state, not beginning-of-month state
Purchase Event Ledger¶
The engine stores a purchase-event ledger for future reporting.
Each BondPurchaseEvent contains:
kind- current value:
purchase month0for initial allocation- quarter-end month for reinvestment
purchasedBondCount- how many full bonds were bought in that action
bondUnitPrice- currently
100 PLN cashBeforePurchase- available cash immediately before the purchase
cashAfterPurchase- uninvested cash remainder immediately after the purchase
sourceBondCount- number of matured bonds that generated the reinvestment pool
0for the initial allocation eventadditionalBondCountFromEarnings- number of extra bonds created beyond simple like-for-like rollover
0when reinvestment only restores the same bond countactiveBondCountAfterPurchase- count of active bonds immediately after the purchase action
reasoninitial-allocationorreinvestment
Interpretation rules:
- there is at most one purchase event in a given month
- quarter-end months can still have a reinvestment event with
additionalBondCountFromEarnings = 0 - the first event with
additionalBondCountFromEarnings > 0is the first point where accumulated earnings plus leftover cash bought extra bonds - no purchase event is recorded in the final month if the simulation ends after settlement instead of reinvesting
Yearly Snapshot Semantics¶
Yearly rows are created only for months divisible by 12.
This has an important consequence:
- if
totalMonths < 12, the simulation returns monthly snapshots and final totals - but it returns no yearly rows
So:
monthSnapshotsalways reflect the true simulated timelinepurchaseEventsalways reflect the true simulated purchase timelineyearlyResultsexist only for completed annual cut points
sequenceDiagram
participant M as Monthly Engine
participant S as Month Snapshot
participant Y as Yearly Result
M->>S: Compute month 12 snapshot
S->>Y: grossValue = snapshot.grossValue
alt final year
S->>Y: netValue = snapshot.liquidationValue
else intermediate year
S->>Y: netValue = snapshot.grossValue
end
S->>Y: attach year tax and early redemption totals
S->>Y: derive nominal and real profits
Current rule:
- for non-final yearly rows:
grossValue = carried value + already realized taxes/costsnetValue = carried portfolio value- for final yearly row:
netValue = liquidationValue
Why:
- intermediate years should represent carried portfolio value, not fake forced liquidation
- intermediate years should still preserve gross-before-tax semantics
- final row should represent actual final realizable outcome
This rule was introduced to fix a bug where OTS showed negative year-1 nominal profit for quarter-aligned multi-year cases.
Practical meaning:
- yearly
grossValueis always a valuation number - yearly
netValueis a reporting number whose meaning depends on whether the row is intermediate or final
That semantic difference is intentional in the current implementation and should be preserved unless product requirements explicitly change.
Final Result Semantics¶
Final result fields:
finalGrossValue = lastMonthSnapshot.grossValuefinalNetValue = lastMonthSnapshot.liquidationValuetotalNominalProfit = finalNetValue - initialAmounttotalRealProfit = finalRealValue - initialAmountirr = calculateIrr(initialAmount, finalNetValue, totalMonths / 12)cagr = calculateCagr(initialAmount, finalNetValue, totalMonths / 12)
Observation:
- for quarter-aligned final horizons,
finalNetValueis the realized final outcome - for non-quarter-aligned final horizons,
finalNetValueincludes proportional interest and early redemption costs
Important distinction:
finalGrossValueis the last gross valuationfinalNetValueis the final liquidation outcome
For quarter-aligned horizons, those two values may still differ at snapshot level conceptually, but in practice finalNetValue is the number used for final profitability metrics.
Mathematical Summary¶
Precision and Rounding Policy¶
This policy is now implemented in code.
Rules:
- intermediate monetary calculations are normalized to
0.001 PLN - monthly snapshots store monetary values at
0.001 PLN - yearly result rows store monetary values at
0.001 PLN - purchase-event cash values are stored at
0.001 PLN - final summary values are rounded to
0.01 PLN
Current implementation helpers:
money.ts: src/app/domain/bond-engine/money.ts
Implemented functions:
roundCurrencyInternal(value)->0.001 PLNroundCurrencyFinal(value)->0.01 PLN
Practical consequence:
- detailed engine state preserves more precision for simulation continuity
- final business-facing totals are rounded to grosz
- sum of yearly rows may differ from displayed final totals by a few thousandths before final rounding
Natural quarter settlement¶
grossInterest = bonds * 100 * annualRate * 3/12
tax = grossInterest * 0.19
quarterNet = bonds * 100 + grossInterest - tax
Early settlement at final non-quarter month¶
grossInterest = bonds * 100 * annualRate * heldMonths/12
tax = grossInterest * 0.19
cost = bonds * 3
earlyNet = bonds * 100 + grossInterest - tax - cost
Reinvestment¶
newBondCount = floor(cash / 100)
cashRemainder = cash - newBondCount * 100
Worked Examples¶
Example A: 1000 PLN, 12 months, 0% inflation¶
Initial state:
- buy
10bonds cash = 0
Quarter 1:
- gross interest =
10 * 100 * 0.025 * 3/12 = 6.25 - tax =
6.25 * 0.19 = 1.1875 - cash after redemption =
1005.0625 - buy
10new bonds - carry
5.0625 PLNcash
Quarter 2:
- redeem
10bonds - receive another
1005.0625 - add previous cash remainder
- buy
10new bonds - carry larger cash remainder
End of year:
- current tested expected value:
netValue = 1020.25nominalProfit = 20.25
Example B: 100 PLN, 1 month, 0% inflation¶
This is a forced early exit.
- proportional interest for
1/12year - tax is charged on that proportional interest
3 PLNearly redemption cost applies
Current tested expected value:
finalNetValue = 97.16875
Example C: threshold reinvestment¶
Case:
- starting amount large enough that quarter proceeds plus leftover cash cross one additional
100 PLNboundary
Example:
20000 PLN- after first quarter the simulation buys
201bonds, not200
Reason:
- net quarter proceeds plus remainder exceed
20100 PLN
Stored event semantics:
- the month-3 purchase event records:
sourceBondCount = 200purchasedBondCount = 201additionalBondCountFromEarnings = 1
Mermaid State Model¶
classDiagram
class OtsPosition {
+number bonds
+number purchaseMonth
}
class OtsMonthSnapshot {
+number month
+number cash
+number activeBondCount
+number grossValue
+number liquidationValue
+number taxPaid
+number earlyRedemptionCost
+boolean hadNaturalRedemption
+boolean hadEarlyRedemption
}
class BondPurchaseEvent {
+string kind
+number month
+number purchasedBondCount
+number bondUnitPrice
+number cashBeforePurchase
+number cashAfterPurchase
+number sourceBondCount
+number additionalBondCountFromEarnings
+number activeBondCountAfterPurchase
+string reason
}
class OtsSimulationResult {
+OtsMonthSnapshot[] monthSnapshots
+YearlyResult[] yearlyResults
+BondPurchaseEvent[] purchaseEvents
+number totalTaxPaid
+number totalEarlyRedemptionCosts
+number finalGrossValue
+number finalNetValue
+number totalNominalProfit
+number totalRealProfit
+number irr
+number cagr
}
OtsSimulationResult --> OtsMonthSnapshot
OtsSimulationResult --> OtsPosition
OtsSimulationResult --> BondPurchaseEvent
Interface and Data Flow¶
flowchart LR
A[Bond catalog OTS definition] --> B[simulateOtsBond]
C[Investment params] --> B
D[Engine config] --> B
B --> E[Month snapshots]
B --> F[Purchase events]
B --> G[Yearly results]
B --> H[Final totals]
B --> I[IRR / CAGR]
Invariants¶
The current implementation is expected to satisfy:
grossValue >= liquidationValuefor every monthly snapshotcash >= 0for every monthly snapshot- final totals must match the last snapshot
totalTaxPaidmust equal the sum of actual tax eventstotalEarlyRedemptionCostsmust equal the sum of actual early-redemption events- if final month is quarter-aligned, no active positions remain after final settlement
- if the yearly row is not final, it must not report false early-redemption losses caused by hypothetical liquidation
Edge Cases¶
The current engine explicitly supports these edge cases:
initialAmount < 100 PLN- leftover cash from initial purchase
- reinvestment thresholds where remainder plus net proceeds buys an extra bond
- stored purchase history for both initial allocation and reinvestment
- final horizon ending after
1or2months into a quarter - large starting principal producing many batches over time
The current engine does not yet formalize:
- legal/product confirmation of the exact proportional-interest rule for every early-redemption case
- alternative reporting semantics for intermediate yearly
netValue
Tested Coverage¶
The current automated OTS tests cover:
- sub-
100 PLNno-purchase case - exact
3,6,12month quarter-aligned cases - multiple starting bond counts on quarter end
- leftover cash carry-over
- initial purchase-event recording
- reinvestment threshold just below and above extra-bond purchase
- first stored event with
additionalBondCountFromEarnings > 0 - no reinvestment event recorded on the terminal month
- partial final quarter exits for
1and2months - yearly regression for
1000 PLN / 10 years - internal consistency between month snapshots, yearly rows, and totals
- gross vs liquidation invariants across a scenario grid
Current test entry point:
npm run test:ots
Test intent:
- deterministic example tests protect known business scenarios
- matrix tests protect thresholds and multi-scenario consistency
- invariant tests protect refactors from silently breaking accounting relationships
Requirement-to-Test Traceability¶
High-level traceability:
BR-OTS-001,BR-OTS-002,BR-OTS-003- covered by quarter-end tests and closed-form quarter settlement tests
BR-OTS-004,BR-OTS-005- covered by reinvestment-threshold and leftover-cash tests
BR-OTS-006- covered by
1 monthand2 monthfinal partial-quarter tests BR-OTS-007- covered by tax-total consistency tests and quarter formula checks
BR-OTS-008- covered indirectly by month-snapshot and partial-quarter tests
BR-OTS-009- covered by yearly regression and yearly consistency tests
BR-OTS-010- covered by deterministic fixed-input assertions
BR-OTS-011- covered by purchase-event recording tests and public-contract exposure tests
Known Design Decisions¶
These are currently implemented decisions, not universal finance truths:
- Tax is computed as raw percentage multiplication without explicit cent rounding rules.
- Monetary values are normalized to
0.001 PLNduring engine execution. - Final summary outputs are rounded to
0.01 PLN. - Early redemption cost is modeled proportionally per active bond on final non-quarter liquidation.
- Intermediate yearly
netValueis treated as carried portfolio value, not hypothetical forced liquidation. - Final result uses liquidation semantics.
These should be treated as contract points for the current engine until product requirements say otherwise.
Open Assumptions / Questions¶
These areas still need stronger product or legal confirmation:
- whether intermediate annual
netValueshould be carried value or hypothetical liquidation value in product language - whether proportional early-settlement interest exactly matches official product behavior in every edge case
- whether UI should ever expose sub-annual horizons directly
Known Risks / Follow-Ups¶
Remaining non-trivial risks are mostly specification risks, not algorithm-coverage risks:
- legal/product confirmation may still be needed for exact early-redemption semantics
- yearly reporting semantics may need separate product approval because
netValuehas different meaning for intermediate vs final rows - if future UI allows non-annual horizons, yearly-only summary assumptions will need extension
Change Management¶
If OTS logic changes, update together:
ots.ts: src/app/domain/bond-engine/strategies/ots.tsots.strategy.test.ts: tests/ots.strategy.test.ts- ODS_logic.md
init_prompt.md: ai/init_prompt.mdif the product spec changes
Do not change only one of these in isolation unless the change is purely editorial.
Handoff Checklist¶
Before another programmer changes OTS, they should:
- Read this document fully.
- Read
ots.ts: src/app/domain/bond-engine/strategies/ots.ts. - Run
npm run test:ots. - Identify whether the intended change is:
- a bug fix
- a business rule change
- a reporting semantics change
- If it is a business rule change, align prompt/spec/tests/code together.
- Re-run:
npm run test:otsnpm run build
Suggested Future Extensions¶
If this engine evolves further, this document should be extended with:
- formal examples with manual calculations in table form
- per-test traceability table with exact test names
- comparison with
ROR/DOR,TOS,COI, andEDO/ROS/RODstrategy families