From d4b0c26b396bfcf1be029d8db3a1c0fc033a6765 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 08:52:59 +0800 Subject: Add bond sell/redemption ledger, bond cashflow events and maturity calendar (3d-30 A) --- tests/FundLab.Api.Tests/BondTradeTests.fs | 154 ++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) (limited to 'tests/FundLab.Api.Tests/BondTradeTests.fs') 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 + [] 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) + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) -- cgit v1.2.3