summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 08:52:59 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 08:52:59 +0800
commitd4b0c26b396bfcf1be029d8db3a1c0fc033a6765 (patch)
treead36325d5507c1607ad8de8496ded8afb0d73e31 /tests/FundLab.Api.Tests
parent3abe605c205e0524803312eb259dc85b92051ca6 (diff)
downloadfund-lab-d4b0c26b396bfcf1be029d8db3a1c0fc033a6765.tar.gz
Add bond sell/redemption ledger, bond cashflow events and maturity calendar (3d-30 A)
Diffstat (limited to 'tests/FundLab.Api.Tests')
-rw-r--r--tests/FundLab.Api.Tests/BondTradeTests.fs154
1 files changed, 154 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/BondTradeTests.fs b/tests/FundLab.Api.Tests/BondTradeTests.fs
index b176aa8..5fdcc4b 100644
--- a/tests/FundLab.Api.Tests/BondTradeTests.fs
+++ b/tests/FundLab.Api.Tests/BondTradeTests.fs
@@ -105,6 +105,33 @@ type BondTradeTests(fixture: PostgresFixture) =
[ "Authorization", "Bearer test-token" ]
""
+ let sell (probe: IBondQuoteProbe) fundId body idempotencyKey =
+ PersistenceTestHelpers.invoke
+ (app probe)
+ "POST"
+ (sprintf "/api/funds/%O/bond-sells" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let readSells (probe: IBondQuoteProbe) fundId =
+ PersistenceTestHelpers.invoke
+ (app probe)
+ "GET"
+ (sprintf "/api/funds/%O/bond-sells" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let readCalendar (probe: IBondQuoteProbe) fundId fromDate toDate =
+ PersistenceTestHelpers.invoke
+ (app probe)
+ "GET"
+ (sprintf "/api/funds/%O/bond-calendar?from=%s&to=%s" fundId fromDate toDate)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let buyTen (probe: IBondQuoteProbe) fundId key =
+ buy probe fundId """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" key
+
[<Fact>]
member _.``buying a bond records clean price, accrued interest, dirty price and settlement``() =
let fundId = createFund ()
@@ -297,3 +324,130 @@ type BondTradeTests(fixture: PostgresFixture) =
Assert.Equal(404, status)
Assert.Contains("FUND_NOT_FOUND", body)
+
+ [<Fact>]
+ member _.``selling part of a bond position credits dirty proceeds and trims the position``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-sell-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body =
+ sell probe fundId """{"instrumentCode":"110075","quantity":"4","tradeDate":"2026-09-21"}""" (fixture.Key "bond-sell-partial")
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"cleanPrice\":\"106.36800000\"", body)
+ Assert.Contains("\"accruedInterest\":\"1.86849300\"", body)
+ Assert.Contains("\"price\":\"108.23649300\"", body)
+ Assert.Contains("\"proceeds\":\"432.95\"", body)
+ Assert.Contains("\"costReleased\":\"432.94\"", body)
+ Assert.Contains("\"realizedPnl\":\"0.01\"", body)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"quantity\":\"6.00000000\"", positionsBody)
+
+ [<Fact>]
+ member _.``selling the whole bond position removes it and reports zero realized pnl at cost``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-sell-full-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body =
+ sell probe fundId """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" (fixture.Key "bond-sell-full")
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"realizedPnl\":\"0.00\"", body)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"positions\":[]", positionsBody)
+
+ [<Fact>]
+ member _.``selling more than held is rejected and leaves the position untouched``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-sell-over-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body =
+ sell probe fundId """{"instrumentCode":"110075","quantity":"20","tradeDate":"2026-09-21"}""" (fixture.Key "bond-sell-over")
+
+ Assert.Equal(400, status)
+ Assert.Contains("INSUFFICIENT_BOND_HOLDINGS", body)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"quantity\":\"10.00000000\"", positionsBody)
+
+ [<Fact>]
+ member _.``replaying a bond sell with the same idempotency key does not sell twice``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-sell-replay-buy")
+ Assert.Equal(201, buyStatus)
+
+ let key = fixture.Key "bond-sell-replay"
+ let body = """{"instrumentCode":"110075","quantity":"4","tradeDate":"2026-09-21"}"""
+
+ let firstStatus, _ = sell probe fundId body key
+ Assert.Equal(201, firstStatus)
+
+ let secondStatus, secondBody = sell probe fundId body key
+ Assert.Equal(200, secondStatus)
+ Assert.Contains("\"proceeds\":\"432.95\"", secondBody)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"quantity\":\"6.00000000\"", positionsBody)
+
+ [<Fact>]
+ member _.``bond calendar exposes scheduled maturity and recorded cashflow in range``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-calendar-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body = readCalendar probe fundId "2026-09-01" "2026-10-31"
+ Assert.Equal(200, status)
+ Assert.Contains("\"fromDate\":\"2026-09-01\"", body)
+ Assert.Contains("\"toDate\":\"2026-10-31\"", body)
+ Assert.Contains("\"eventType\":\"maturity\"", body)
+ Assert.Contains("\"eventDate\":\"2026-10-15\"", body)
+ Assert.Contains("\"source\":\"scheduled\"", body)
+
+ let couponStatus, _ =
+ recordCashflow
+ probe
+ fundId
+ """{"instrumentCode":"110075","eventType":"coupon","eventDate":"2026-10-15","quantity":"10","amount":"20.00"}"""
+ (fixture.Key "bond-calendar-coupon")
+
+ Assert.Equal(201, couponStatus)
+
+ let _, withRecorded = readCalendar probe fundId "2026-09-01" "2026-10-31"
+ Assert.Contains("\"source\":\"recorded\"", withRecorded)
+ Assert.Contains("\"eventType\":\"coupon\"", withRecorded)
+
+ [<Fact>]
+ member _.``redemption records like maturity and clears the position``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-redeem-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body =
+ recordCashflow
+ probe
+ fundId
+ """{"instrumentCode":"110075","eventType":"redemption","eventDate":"2026-06-10","quantity":"10","amount":"1050.00"}"""
+ (fixture.Key "bond-redeem-event")
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"eventType\":\"redemption\"", body)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"positions\":[]", positionsBody)