Skip to content

Commit da13020

Browse files
committed
accrue_interest!
1 parent b3711d7 commit da13020

6 files changed

Lines changed: 218 additions & 13 deletions

File tree

src/interest.jl

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,40 @@ using Dates
44
accrue_interest!(acc, dt; year_basis=365.0)
55
66
Accrues interest on cash balances between the last accrual timestamp and `dt`.
7-
Positive balances earn broker lend rates, negative balances pay broker borrow
8-
rates. Rates are evaluated at the accrual window start (`last_interest_dt`).
7+
Negative balances pay broker borrow rates.
8+
9+
For positive balances, short-sale proceeds on principal-exchange spot shorts are
10+
handled via `broker_short_proceeds_rates`:
11+
- excluded fraction of locked short proceeds is removed from regular lend base,
12+
- optional rebate rate is applied to locked short proceeds.
13+
14+
Rates are evaluated at the accrual window start (`last_interest_dt`).
915
Interest is applied to both balances and equities and recorded as
10-
`CashflowKind.LendInterest` or `CashflowKind.BorrowInterest`.
16+
`CashflowKind.LendInterest` or `CashflowKind.BorrowInterest` based on net sign.
1117
"""
18+
@inline function _fill_short_proceeds_by_settle_cash!(
19+
acc::Account,
20+
proceeds::Vector{Price},
21+
)
22+
fill!(proceeds, 0.0)
23+
24+
@inbounds for pos in acc.positions
25+
qty = pos.quantity
26+
qty < 0.0 || continue
27+
28+
inst = pos.inst
29+
inst.contract_kind == ContractKind.Spot || continue
30+
inst.settlement == SettlementStyle.PrincipalExchange || continue
31+
32+
settled_proceeds = -qty * pos.avg_entry_price_settle * inst.multiplier
33+
settled_proceeds > 0.0 || continue
34+
35+
proceeds[inst.settle_cash_index] += settled_proceeds
36+
end
37+
38+
nothing
39+
end
40+
1241
function accrue_interest!(
1342
acc::Account{TTime,TBroker},
1443
dt::TTime;
@@ -29,12 +58,30 @@ function accrue_interest!(
2958
cfs = acc.cashflows
3059
rate_dt = acc.last_interest_dt
3160
ledger = acc.ledger
61+
short_proceeds_by_cash = ledger.short_proceeds_by_cash_buffer
62+
short_proceeds_ready = false
3263
@inbounds for i in eachindex(ledger.balances)
3364
bal = ledger.balances[i]
34-
cash_symbol = ledger.cash[i].symbol
35-
borrow_rate, lend_rate = broker_interest_rates(acc.broker, cash_symbol, rate_dt, bal)
36-
rate = bal >= 0 ? lend_rate : borrow_rate
37-
interest = bal * rate * yearfrac
65+
cash = ledger.cash[i]
66+
interest = if bal < 0.0
67+
borrow_rate, _ = broker_interest_rates(acc.broker, cash, rate_dt, bal)
68+
bal * borrow_rate * yearfrac
69+
else
70+
exclude_fraction, rebate_rate = broker_short_proceeds_rates(acc.broker, cash, rate_dt)
71+
72+
locked = 0.0
73+
if exclude_fraction != 0.0 || rebate_rate != 0.0
74+
if !short_proceeds_ready
75+
_fill_short_proceeds_by_settle_cash!(acc, short_proceeds_by_cash)
76+
short_proceeds_ready = true
77+
end
78+
locked = min(bal, short_proceeds_by_cash[i])
79+
end
80+
81+
lend_base = max(0.0, bal - exclude_fraction * locked)
82+
_, lend_rate = broker_interest_rates(acc.broker, cash, rate_dt, lend_base)
83+
lend_base * lend_rate * yearfrac + locked * rebate_rate * yearfrac
84+
end
3885
interest == 0.0 && continue
3986
ledger.balances[i] += interest
4087
ledger.equities[i] += interest

test/binance_broker.jl

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ using TestItemRunner
55
using Test, Fastback, Dates
66

77
@test BinanceBroker() isa BinanceBroker{DateTime}
8+
@test BinanceBroker().short_proceeds_exclusion == 1.0
9+
@test BinanceBroker().short_proceeds_rebate == 0.0
810

911
borrow_by_cash = Dict(:USD=>StepSchedule([(DateTime(2025, 1, 1), 0.10)]))
1012
broker = BinanceBroker(; borrow_by_cash=borrow_by_cash)
@@ -31,3 +33,15 @@ end
3133

3234
@test_throws MethodError BinanceBroker(; borrow_by_cash=borrow_by_cash)
3335
end
36+
37+
@testitem "BinanceBroker short proceeds defaults to full exclusion without rebate" begin
38+
using Test, Fastback, Dates
39+
40+
broker = BinanceBroker()
41+
dt = DateTime(2025, 1, 1)
42+
usd = Cash(1, :USD, 2)
43+
exclude_frac, rebate_rate = broker_short_proceeds_rates(broker, usd, dt)
44+
45+
@test exclude_frac == 1.0
46+
@test rebate_rate == 0.0
47+
end

test/events.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ using TestItemRunner
4545
cf_count = length(acc.cashflows)
4646

4747
yearfrac = 1 / 365
48-
rate = bal_before >= 0 ? 0.05 : 0.10
49-
expected_interest = bal_before * rate * yearfrac
5048
pos = get_position(acc, inst)
49+
short_proceeds = abs(pos.quantity) * pos.avg_entry_price_settle * inst.multiplier
50+
expected_interest = (bal_before - short_proceeds) * 0.05 * yearfrac
5151
expected_borrow = abs(pos.quantity) * pos.mark_price * inst.multiplier * 0.20 * yearfrac
5252
@test bal_after_first - bal_before (expected_interest - expected_borrow) atol=1e-8
5353

test/fx_conversion_helpers.jl

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,9 @@ end
165165
advance_time!(acc, dt1; accrue_interest=true, accrue_borrow_fees=true)
166166

167167
yearfrac = Dates.value(Dates.Millisecond(dt1 - dt0)) / (1000 * 60 * 60 * 24 * 365.0)
168-
expected_interest = bal_before * 0.02 * yearfrac
169-
expected_fee_settle = abs(qty) * price * inst.multiplier * inst.short_borrow_rate * yearfrac * usd_to_chf
168+
short_proceeds_settle = abs(qty) * price * inst.multiplier * usd_to_chf
169+
expected_interest = (bal_before - short_proceeds_settle) * 0.02 * yearfrac
170+
expected_fee_settle = short_proceeds_settle * inst.short_borrow_rate * yearfrac
170171
expected_net = expected_interest - expected_fee_settle
171172

172173
@test acc.ledger.balances[chf_idx] bal_before + expected_net atol=1e-8

test/ibkr_pro_fixed_broker.jl

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ using TestItemRunner
1111
@test broker isa IBKRProFixedBroker{DateTime}
1212
@test broker.benchmark_by_cash == benchmark_by_cash
1313
@test broker.benchmark_by_cash isa Dict{Symbol,StepSchedule{DateTime,Price}}
14+
@test broker.short_proceeds_exclusion == 1.0
15+
@test broker.short_proceeds_rebate_spread == broker.lend_spread
1416

1517
benchmark_by_cash_date = Dict(:USD=>StepSchedule([(Date(2025, 1, 1), 0.03)]))
1618
broker_date = IBKRProFixedBroker(; time_type=Date, benchmark_by_cash=benchmark_by_cash_date)
@@ -19,3 +21,25 @@ using TestItemRunner
1921

2022
@test_throws MethodError IBKRProFixedBroker(; benchmark_by_cash=benchmark_by_cash_date)
2123
end
24+
25+
@testitem "IBKRProFixedBroker short proceeds uses benchmark rebate spread" begin
26+
using Test, Fastback, Dates
27+
28+
benchmark_by_cash = Dict(:USD=>StepSchedule([(DateTime(2025, 1, 1), 0.03)]))
29+
broker = IBKRProFixedBroker(;
30+
benchmark_by_cash=benchmark_by_cash,
31+
short_proceeds_exclusion=1.0,
32+
short_proceeds_rebate_spread=0.01,
33+
)
34+
dt = DateTime(2025, 1, 10)
35+
usd = Cash(1, :USD, 2)
36+
eur = Cash(2, :EUR, 2)
37+
exclude_frac, rebate_rate = broker_short_proceeds_rates(broker, usd, dt)
38+
39+
@test exclude_frac == 1.0
40+
@test rebate_rate 0.02 atol=1e-12
41+
42+
ex_unknown, rebate_unknown = broker_short_proceeds_rates(broker, eur, dt)
43+
@test ex_unknown == 1.0
44+
@test rebate_unknown == 0.0
45+
end

test/spot_margin.jl

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ end
147147
@test cf.amount -expected_fee atol=1e-6
148148
end
149149

150-
@testitem "Margin spot short accrues borrow fee and interest" begin
150+
@testitem "Margin spot short accrues borrow fee and lend on free cash only" begin
151151
using Test, Fastback, Dates
152152

153153
base_currency=CashSpec(:USD)
@@ -195,7 +195,8 @@ end
195195
advance_time!(acc, dt1; accrue_interest=true, accrue_borrow_fees=true)
196196

197197
yearfrac = Dates.value(Dates.Millisecond(dt1 - dt0)) / (1000 * 60 * 60 * 24 * 365.0)
198-
expected_interest = bal_before * 0.02 * yearfrac
198+
short_proceeds = abs(qty) * price * inst.multiplier
199+
expected_interest = (bal_before - short_proceeds) * 0.02 * yearfrac
199200
expected_fee = abs(qty) * price * inst.multiplier * inst.short_borrow_rate * yearfrac
200201
expected_delta = expected_interest - expected_fee
201202

@@ -216,6 +217,124 @@ end
216217
@test fee_cf.amount -expected_fee atol=1e-8
217218
end
218219

220+
@testitem "IBKR short excludes proceeds from lend base and applies rebate" begin
221+
using Test, Fastback, Dates
222+
223+
benchmark_by_cash = Dict(:USD=>StepSchedule([(DateTime(2026, 1, 1), 0.03)]))
224+
base_currency=CashSpec(:USD)
225+
acc = Account(
226+
;
227+
broker=IBKRProFixedBroker(
228+
benchmark_by_cash=benchmark_by_cash,
229+
borrow_spread=0.015,
230+
lend_spread=0.005,
231+
credit_no_interest_balance=0.0,
232+
short_proceeds_exclusion=1.0,
233+
short_proceeds_rebate_spread=0.01,
234+
),
235+
funding=AccountFunding.Margined,
236+
base_currency=base_currency,
237+
)
238+
deposit!(acc, :USD, 10_100.0)
239+
240+
inst = register_instrument!(acc, Instrument(
241+
Symbol("SPOTIBKR/USD"),
242+
:SPOTIBKR,
243+
:USD;
244+
contract_kind=ContractKind.Spot,
245+
settlement=SettlementStyle.PrincipalExchange,
246+
margin_requirement=MarginRequirement.PercentNotional,
247+
margin_init_long=0.5,
248+
margin_maint_long=0.25,
249+
margin_init_short=0.5,
250+
margin_maint_short=0.25,
251+
))
252+
253+
dt0 = DateTime(2026, 1, 1)
254+
price = 100.0
255+
qty = -200.0
256+
trade = fill_order!(acc, Order(oid!(acc), inst, dt0, price, qty); dt=dt0, fill_price=price, bid=price, ask=price, last=price)
257+
@test trade isa Trade
258+
259+
usd = cash_asset(acc, :USD)
260+
bal_before = cash_balance(acc, usd)
261+
eq_before = equity(acc, usd)
262+
@test bal_before 30_099.0 atol=1e-8
263+
@test eq_before 10_099.0 atol=1e-8
264+
265+
accrue_interest!(acc, dt0)
266+
@test isempty(acc.cashflows)
267+
268+
dt1 = dt0 + Day(1)
269+
accrue_interest!(acc, dt1)
270+
271+
yearfrac = Dates.value(Dates.Millisecond(dt1 - dt0)) / (1000 * 60 * 60 * 24 * 365.0)
272+
short_proceeds = abs(qty) * price * inst.multiplier
273+
free_cash = bal_before - short_proceeds
274+
expected_lend = free_cash * (0.03 - 0.005) * yearfrac
275+
expected_rebate = short_proceeds * (0.03 - 0.01) * yearfrac
276+
expected_interest = expected_lend + expected_rebate
277+
278+
@test cash_balance(acc, usd) bal_before + expected_interest atol=1e-8
279+
@test equity(acc, usd) eq_before + expected_interest atol=1e-8
280+
@test length(acc.cashflows) == 1
281+
cf = only(acc.cashflows)
282+
@test cf.kind == CashflowKind.LendInterest
283+
@test cf.cash_index == usd.index
284+
@test cf.amount expected_interest atol=1e-8
285+
end
286+
287+
@testitem "Flat fee broker can include short proceeds in lend base" begin
288+
using Test, Fastback, Dates
289+
290+
base_currency=CashSpec(:USD)
291+
acc = Account(
292+
;
293+
broker=FlatFeeBroker(
294+
lend_by_cash=Dict(:USD=>0.02),
295+
short_proceeds_exclusion_by_cash=Dict(:USD=>0.0),
296+
),
297+
funding=AccountFunding.Margined,
298+
base_currency=base_currency,
299+
)
300+
deposit!(acc, :USD, 10_000.0)
301+
302+
inst = register_instrument!(acc, Instrument(
303+
Symbol("SPOTINCL/USD"),
304+
:SPOTINCL,
305+
:USD;
306+
contract_kind=ContractKind.Spot,
307+
settlement=SettlementStyle.PrincipalExchange,
308+
margin_requirement=MarginRequirement.PercentNotional,
309+
margin_init_long=0.5,
310+
margin_maint_long=0.25,
311+
margin_init_short=0.5,
312+
margin_maint_short=0.25,
313+
))
314+
315+
dt0 = DateTime(2026, 1, 1)
316+
price = 100.0
317+
qty = -200.0
318+
trade = fill_order!(acc, Order(oid!(acc), inst, dt0, price, qty); dt=dt0, fill_price=price, bid=price, ask=price, last=price)
319+
@test trade isa Trade
320+
321+
usd = cash_asset(acc, :USD)
322+
bal_before = cash_balance(acc, usd)
323+
@test bal_before 30_000.0 atol=1e-8
324+
325+
accrue_interest!(acc, dt0)
326+
dt1 = dt0 + Day(1)
327+
accrue_interest!(acc, dt1)
328+
329+
yearfrac = Dates.value(Dates.Millisecond(dt1 - dt0)) / (1000 * 60 * 60 * 24 * 365.0)
330+
expected_interest = bal_before * 0.02 * yearfrac
331+
332+
@test cash_balance(acc, usd) bal_before + expected_interest atol=1e-8
333+
@test equity(acc, usd) 10_000.0 + expected_interest atol=1e-8
334+
@test length(acc.cashflows) == 1
335+
@test only(acc.cashflows).amount expected_interest atol=1e-8
336+
end
337+
219338
@testitem "Fully funded account applies margin checks to spot" begin
220339
using Test, Fastback, Dates
221340

0 commit comments

Comments
 (0)