Generated file. Source:
docs/ROR_logic.mdEdit the source document and runnpm run docs:syncto refresh this published copy.
ROR Logic¶
This file documents the current ROR calculation engine implementation.
Document Metadata¶
- Document status: handoff / implementation specification
- Scope: current
RORengine and the shared monthly-income strategy family it uses - Primary code references:
monthly-income.ts: src/app/domain/bond-engine/strategies/monthly-income.tsror.ts: src/app/domain/bond-engine/strategies/ror.ts- Related tests:
monthly-income.strategy.test.ts: tests/monthly-income.strategy.test.tsror.strategy.test.ts: tests/ror.strategy.test.ts- Related prompt source:
init_prompt.md: ai/init_prompt.md- Last reviewed against code: 2026-03-23
Purpose¶
This document is intended to let another programmer:
- understand how
RORis modeled today - understand which logic is shared with future monthly-income bonds like
DOR - modify the code without breaking monthly payout or reinvestment behavior
Scope¶
Included in scope:
- shared monthly-income engine behavior
RORmonthly rate rules- monthly interest payout and taxation
- monthly reinvestment
- reinvestment profitability guard near the end of the horizon
- 12-month redemption cycle
- final early redemption
- monthly snapshots
- yearly result derivation
- purchase-event history
Out of scope:
DORspecialization details- other bond families
- UI rendering decisions
Product Definition¶
Current ROR engine assumptions:
- single bond nominal value:
100 PLN - cycle length:
12 months - month 1 annualized rate:
4.25% - months 2-12 annualized rate:
NBP + 0.00% - interest is calculated and paid every month
- each monthly interest payment is taxed immediately at
19% - paid net interest goes to cash and can be reinvested immediately
- reinvestment is allowed only when a newly bought bond is expected to be non-loss-making over the remaining horizon
- natural redemption returns principal after
12 months - early redemption cost:
3 PLNper bond
Implemented rounding policy:
- internal monetary values are normalized to
0.001 PLN - final summary outputs are rounded to
0.01 PLN
Architecture¶
ROR no longer uses the generic yearly calculator.
Instead it uses a shared monthly-income strategy family:
- shared module:
monthly-income.ts: src/app/domain/bond-engine/strategies/monthly-income.tsRORspecialization:ror.ts: src/app/domain/bond-engine/strategies/ror.ts
Design intent:
- common monthly payout mechanics live in one place
- bond-specific rules stay small and explicit
- the same engine can later support
DOR
flowchart LR
A[calculateBondResult] --> B{bondId}
B -->|ROR| C[calculateRorBondResult]
C --> D[simulateMonthlyIncomeBond]
D --> E[Month snapshots]
D --> F[Purchase events]
D --> G[Yearly results]
D --> H[Final totals]
Shared Monthly-Income Contract¶
The shared monthly-income engine expects:
- cycle length in months
- monthly annual-rate resolver
- standard bond definition
- investment amount and horizon
- reference rate and inflation inputs
- tax rate and bond unit price
The shared engine provides:
- monthly simulation
- monthly interest posting
- monthly tax posting
- batch-based redemption
- purchase-event ledger
- reinvestment-decision ledger
- yearly snapshots
- final profitability summary
ROR Specialization¶
ROR configures the shared engine with:
strategyId = monthly-income-rorcycleLengthMonths = 12- rate resolver:
cycleMonth === 1->4.25%- otherwise ->
referenceRate + 0.00%
So the ROR module contains only product-specific policy, while all monthly mechanics remain shared.
Internal State¶
Core shared state:
cashpositionspurchaseEventstotalTaxPaidtotalEarlyRedemptionCostsyearTaxPaidyearEarlyRedemptionCosts
Each active position stores:
bondspurchaseMonth
Monthly Flow¶
For each month m from 1 to totalMonths:
- For every active batch, calculate monthly interest.
- Use the batch age to determine the month inside the 12-month cycle.
- Apply the
RORrate rule: - first month in cycle ->
4.25% - later months ->
referenceRate - Add net interest to cash after tax.
- Redeem any batch that reached age
12. - Reinvest available cash into full new
RORbonds ifcash >= 100and this is not the terminal month. - If this is the final month and active batches remain, early redeem them by returning principal minus
3 PLNper bond. - Store end-of-month snapshot.
flowchart TD
A[Start month] --> B[Calculate monthly interest for each active batch]
B --> C[Post tax and net interest to cash]
C --> D[Redeem batches aged 12 months]
D --> E[Add redeemed principal to cash]
E --> F{cash >= 100 and month < final?}
F -- Yes --> G[Buy new batch and store purchase event]
F -- No --> H[Skip purchase]
G --> I{final month and active positions remain?}
H --> I
I -- Yes --> J[Apply early redemption to remaining batches]
I -- No --> K[Build month snapshot]
J --> K
Interest Formula¶
For one batch in one month:
grossInterest = bonds * 100 * (annualRatePercent / 100 / 12)
taxPaid = grossInterest * 0.19
netInterest = grossInterest - taxPaid
cash += netInterest
annualRatePercent depends on the batch month inside its 12-month cycle.
Redemption Rules¶
Natural redemption:
- happens when
ageInMonths === 12 - only principal is returned at that moment, because monthly interest has already been paid during the cycle
Final early redemption:
- happens only in the terminal month
- applies to every still-active batch
- adds principal to cash
- subtracts
3 PLNper bond - does not add extra monthly interest beyond what the engine already paid during simulated months
Reinvestment Profitability Guard¶
ROR does not automatically buy every late bond candidate.
Current rule:
- if a newly purchased bond can still reach its natural
12-monthmaturity before the simulation ends, reinvestment is allowed - otherwise the engine estimates the net monthly interest that one new bond would earn until the simulation end
- if that expected net interest is lower than the
3 PLNearly redemption cost, reinvestment is blocked
Why:
RORpays interest monthly, but a late purchase can still be net unprofitable because the fixed early redemption cost is large relative to a short remaining holding period
Implementation location:
monthly-income.ts: src/app/domain/bond-engine/strategies/monthly-income.tsror.ts: src/app/domain/bond-engine/strategies/ror.ts
Purchase Event Ledger¶
The shared engine stores BondPurchaseEvent items for:
- initial allocation
- reinvestment purchases
Each event includes:
monthpurchasedBondCountcashBeforePurchasecashAfterPurchasesourceBondCountadditionalBondCountFromEarningsactiveBondCountAfterPurchasereason
Important interpretation:
- for
ROR, reinvestment can happen even without a natural redemption that month, because interest is paid monthly - therefore
sourceBondCountcan be0whileadditionalBondCountFromEarnings > 0 - this is the key signal that interest cash alone crossed a new
100 PLNthreshold
Reinvestment Decision Ledger¶
The shared engine also stores reinvestment decisions for future reporting.
Each decision contains:
monthrequestedBondCountapprovedBondCountblockedBondCountremainingMonthsAfterPurchasecanReachNaturalMaturityexpectedNetInterestPerBondearlyRedemptionCostPerBondreason
Interpretation:
approved- the requested reinvestment was allowed
insufficient-remaining-profitability- the engine intentionally left cash uninvested because a late bond purchase would likely lose money after early redemption cost
Snapshot Semantics¶
Monthly snapshots are created after:
- monthly interest payout
- natural redemptions
- reinvestment
- final early redemption if applicable
Snapshot fields:
grossValue- cash plus nominal value of active batches
liquidationValue- cash plus nominal value of active batches minus hypothetical early redemption costs for active batches
taxPaid- tax actually charged that month
earlyRedemptionCost- early redemption cost actually charged that month
Because ROR pays interest monthly, there is no unpaid accrued interest left between month-end snapshots in the current model.
Yearly Result Semantics¶
Yearly rows are created at months divisible by 12.
Current rule:
- intermediate yearly
grossValue = carried value + already realized taxes/costs - intermediate yearly
netValue = carried portfolio value - final yearly
netValue = liquidationValue
Why:
- intermediate years should preserve both:
- carried value after realized taxes
- gross value before realized taxes/costs
- final year should show actual realizable outcome
Public Result Contract¶
calculateRorBondResult(...) returns a normal BondCalculationResult.
In addition, it exposes strategy details through:
simulationDetails.strategy = monthly-income-rorsimulationDetails.purchaseEventssimulationDetails.reinvestmentDecisions
That keeps the app API stable while preserving detailed simulation data for future reporting.
Tested Coverage¶
Current automated ROR tests cover:
- shared profitability-guard approval and blocking boundaries
- shared profitability-guard sensitivity to low-rate vs high-rate tails
- first-month special rate
- switch to reference-rate-based monthly rate
- 12-month natural redemption
- 1-month final early redemption
- interest-driven reinvestment threshold with enough remaining horizon
- late-horizon reinvestment blocked by profitability guard
- naturally redeemed principal being kept in cash when the remaining tail is still unprofitable
- initial purchase-event recording
- reinvestment-decision recording
- public engine routing through
calculateBondResult
Test entry point:
npm run test:monthly-incomenpm run test:ror
Invariants¶
The current implementation is expected to satisfy:
grossValue >= liquidationValuefor every month snapshotcash >= 0for every month snapshot- no early redemption cost on natural 12-month redemption
- final totals match the last month snapshot after final rounding
Change Management¶
If ROR logic changes, update together:
monthly-income.ts: src/app/domain/bond-engine/strategies/monthly-income.tsror.ts: src/app/domain/bond-engine/strategies/ror.tsror.strategy.test.ts: tests/ror.strategy.test.ts- ROR_logic.md
init_prompt.md: ai/init_prompt.mdif the product rules change
Future Extension Path¶
This design is intentionally shaped so DOR can reuse the same monthly-income engine by changing only:
- cycle length from
12to24 - first-month rate
- later-month margin above NBP