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

python
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:

python
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 if install_interest_as_compounding=True.

Scheduling events

python
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

KeyTypeRequiredNotes
dividend_per_sharefloatyesCash per share on ex-date.
account_idstr | NonenoAttribution.
strategy_idstrnoPer-strategy P&L attribution.
qualifiedboolnoRecords the 1099-DIV classification; not used in the cash math.
shares_overridefloat | NonenoOverride 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)

KeyTypeRequiredNotes
amountfloatnoFinal cash delta. If set, used directly.
ratefloatif no amountFunding rate (e.g. 0.0001 = 1 bp for the interval).
notionalfloat | NonenoSigned position notional. If absent, computed from ledger position × mark.
markfloat | NonenoCurrent mark price for the notional calc.
account_id, strategy_idstr | NonenoAttribution.

Sign: when rate > 0, longs pay shorts. The handler applies amount = -rate × notional; pass a pre-signed amount to skip the computation.

Interest

KeyTypeRequiredNotes
amountfloatyesSigned. Positive = earned; negative = margin interest paid.
rate, period_daysoptionalnoRecorded 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

python
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

HandlerCategory
DividendDividendAccrued
FundingFundingSettlement
InterestAnnotation (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 each X.
  • 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. qualified is 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.

Related