Dividend, funding, and interest accruals
Non-trade cash movements that change equity without touching the trade blotter. Ex-date dividends, perp funding, coupon interest, cash-sweep, margin interest.
Not every cash movement is a trade. When a long position goes ex-dividend, cash arrives without a fill. When a perp funding window settles, cash flows in or out at the funding rate. When idle cash earns sweep interest, equity rises without an order.
CashAdjustment records these credits and debits on the ledger. horizon.lifecycle.handlers.accruals provides three event handlers (dividend, funding, interest) that read payloads, compute the amount, and apply the adjustment.
Shape
from horizon.state.ledger import CashAdjustment, PositionLedger
ledger.apply_cash_adjustment(
CashAdjustment(
kind="dividend", # "dividend" | "funding" | "interest" | "fee" | "other"
amount=25.0, # signed: + credit, - debit
timestamp=ts,
market_id="AAPL", # originating instrument, if applicable
reason="Q2 ordinary cash dividend",
account_id="acc_1",
strategy_id="s1",
payload={"dividend_per_share": 0.25, "shares": 100},
)
)
The adjustment rolls into ledger.realized_pnl_total (equity treats dividend cash the same as closing cash) and into per-strategy attribution when strategy_id is set. The trade blotter (ledger.trades()) stays clean; the cash history lives on ledger.adjustments().
Handlers
Install the handlers once per LifecycleManager + PositionLedger pairing:
from horizon.lifecycle.manager import LifecycleManager
from horizon.lifecycle.handlers import register_accrual_handlers
mgr = LifecycleManager()
register_accrual_handlers(
mgr, ledger,
audit_log=audit_log,
install_interest_as_compounding=False,
)
register_accrual_handlers wires:
LifecycleEventKind.DividendAccrual→ dividend handler.LifecycleEventKind.FundingSettlement→ funding handler.LifecycleEventKind.Compounding→ interest handler only ifinstall_interest_as_compounding=True.
Scheduling events
from horizon.lifecycle.events import LifecycleEvent, LifecycleEventKind
# Ex-date dividend. AAPL pays $0.25 on 2026-05-11.
mgr.schedule(LifecycleEvent(
kind=LifecycleEventKind.DividendAccrual,
market_id="AAPL",
fires_at=datetime(2026, 5, 11, 13, 30, tzinfo=timezone.utc),
payload={"dividend_per_share": 0.25, "account_id": "acc_1"},
reason="Q2 ordinary cash dividend",
))
# Perp funding, 8h window. Rate +0.01%, long pays.
mgr.schedule(LifecycleEvent(
kind=LifecycleEventKind.FundingSettlement,
market_id="BTC-PERP",
fires_at=funding_window_close,
payload={"rate": 0.0001, "notional": 100_000.0},
))
The tick loop pops due events via mgr.advance_clock_to(now) and the handler runs.
Payload conventions
Dividend
| Key | Type | Required | Notes |
|---|---|---|---|
dividend_per_share | float | yes | Cash per share on ex-date. |
account_id | str | None | no | Attribution. |
strategy_id | str | no | Per-strategy P&L attribution. |
qualified | bool | no | Records the 1099-DIV classification; not used in the cash math. |
shares_override | float | None | no | Override current ledger quantity (rare; use when the as-of-record-date position differs from what is held today). |
Long positions credit, short positions pay. Zero dividend_per_share is a no-op; a flat position credits zero.
Funding (perps / swaps)
| Key | Type | Required | Notes |
|---|---|---|---|
amount | float | no | Final cash delta. If set, used directly. |
rate | float | if no amount | Funding rate (e.g. 0.0001 = 1 bp for the interval). |
notional | float | None | no | Signed position notional. If absent, computed from ledger position × mark. |
mark | float | None | no | Current mark price for the notional calc. |
account_id, strategy_id | str | None | no | Attribution. |
Sign: when rate > 0, longs pay shorts. The handler applies amount = -rate × notional; pass a pre-signed amount to skip the computation.
Interest
| Key | Type | Required | Notes |
|---|---|---|---|
amount | float | yes | Signed. Positive = earned; negative = margin interest paid. |
rate, period_days | optional | no | Recorded in the adjustment payload for audit. |
No position lookup. Use for bond coupons, cash-sweep, margin interest, custodial rebates — anything the custodian posts directly.
Queries
ledger.adjustments() # all cash adjustments, ordered
ledger.adjustments_total() # sum across all kinds
ledger.adjustments_total("dividend") # sum filtered by kind
realized_pnl() includes adjustments. strategy_realized(strategy_id) includes per-strategy adjustments.
Audit events
| Handler | Category |
|---|---|
| Dividend | DividendAccrued |
| Funding | FundingSettlement |
| Interest | Annotation (no dedicated kind) |
Each event carries the adjustment amount, market id, account id, strategy id, and the input rate / shares / DPS for forensic replay.
Invariants
Seeded property tests in tests/test_l2_ledger_invariants.py:
Σ(adjustments) = Δ(realized_pnl)attributable to adjustments.adjustments_total(kind="X")sums to what the caller fed in, for eachX.- Equity decomposition (
equity = Σ(realized) + Σ(unrealized)) holds after arbitrary mixes of fills and adjustments.
Out of scope
- Dividend capture strategies. The handler records cash; the strategy decides whether to hold through ex-date.
- Qualified vs. ordinary tax treatment.
qualifiedis recorded for 1099-DIV downstream; the handler does not split buckets. - Ex-date price adjustment. Price drops on ex-date are reflected by the market, not this handler. Do not double-count.
- Corporate-action stock dividends. Those live in Corporate actions; they open new lots rather than crediting cash.