diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 08:07:00 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 08:07:00 +0800 |
| commit | b05e728ae23d088ca6c9ecccf6ed00d2ab6f3839 (patch) | |
| tree | 4846648aa87263742c1181c189287b53c3bf84de /tests | |
| parent | 27a85d9070abd000245b2a2e6460ddf9fd5eb97e (diff) | |
| download | fund-lab-b05e728ae23d088ca6c9ecccf6ed00d2ab6f3839.tar.gz | |
Add bond full milestone: profile probe, coupon/accrual rules, ledger and point-in-time valuation (3d-29)
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 11 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/BondTradeTests.fs | 139 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundValuationTests.fs | 77 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/BondRulesTests.fs | 70 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj | 1 |
5 files changed, 292 insertions, 6 deletions
diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs index 1e19f77..e8194fa 100644 --- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs +++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs @@ -225,6 +225,17 @@ type BondQuoteProbeTests(fixture: PostgresFixture) = AccruedInterest = None Date = None MaturityDate = Some(DateOnly(2026, 6, 3)) + ParValue = None + IssuePrice = None + ValueDate = None + ListingDate = None + PublishDate = None + PayInterestDay = None + CouponRate = None + CouponRateExplain = None + BondExpireYears = None + Rating = None + DataStatus = None }) let status, body = diff --git a/tests/FundLab.Api.Tests/BondTradeTests.fs b/tests/FundLab.Api.Tests/BondTradeTests.fs index 76827f5..b176aa8 100644 --- a/tests/FundLab.Api.Tests/BondTradeTests.fs +++ b/tests/FundLab.Api.Tests/BondTradeTests.fs @@ -24,6 +24,17 @@ module BondTradeSupport = AccruedInterest = None Date = None MaturityDate = Some(DateOnly(2026, 10, 15)) + ParValue = Some 100m + IssuePrice = Some 100m + ValueDate = Some(DateOnly(2020, 10, 15)) + ListingDate = Some(DateOnly(2020, 11, 3)) + PublishDate = None + PayInterestDay = Some "10-15" + CouponRate = Some 0.02m + CouponRateExplain = None + BondExpireYears = Some "6" + Rating = Some "AAA" + DataStatus = Some "complete" } let appWithQuote (probe: IBondQuoteProbe) = @@ -78,25 +89,143 @@ type BondTradeTests(fixture: PostgresFixture) = [ "Authorization", "Bearer test-token" ] "" + let recordCashflow (probe: IBondQuoteProbe) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app probe) + "POST" + (sprintf "/api/funds/%O/bond-cashflows" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let readCashflows (probe: IBondQuoteProbe) fundId = + PersistenceTestHelpers.invoke + (app probe) + "GET" + (sprintf "/api/funds/%O/bond-cashflows" fundId) + [ "Authorization", "Bearer test-token" ] + "" + [<Fact>] - member _.``buying a bond with a live quote records a trade and a position``() = + member _.``buying a bond records clean price, accrued interest, dirty price and settlement``() = let fundId = createFund () let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 let status, body = - buy probe fundId """{"instrumentCode":"110075","quantity":"10"}""" (fixture.Key "bond-trade-buy-ok") + buy + probe + fundId + """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" + (fixture.Key "bond-trade-buy-ok") Assert.Equal(201, status) Assert.Contains("\"instrumentCode\":\"110075\"", body) Assert.Contains("南航转债", body) - Assert.Contains("106.368", body) - Assert.Contains("1063.68", body) + Assert.Contains("\"cleanPrice\":\"106.36800000\"", body) + Assert.Contains("\"accruedInterest\":\"1.86849300\"", body) + Assert.Contains("\"price\":\"108.23649300\"", body) + Assert.Contains("\"parValue\":\"100.00000000\"", body) + Assert.Contains("\"couponRate\":\"0.02000000\"", body) + Assert.Contains("\"valueDate\":\"2020-10-15\"", body) + Assert.Contains("\"maturityDate\":\"2026-10-15\"", body) + Assert.Contains("\"settlementDate\":\"2026-09-21\"", body) + Assert.Contains("\"tradeDate\":\"2026-09-21\"", body) + Assert.Contains("1082.36", body) let status, positionsBody = readPositions probe fundId Assert.Equal(200, status) Assert.Contains("\"instrumentCode\":\"110075\"", positionsBody) Assert.Contains("\"quantity\":\"10.00000000\"", positionsBody) - Assert.Contains("1063.68", positionsBody) + Assert.Contains("1082.36", positionsBody) + + [<Fact>] + member _.``buying a quantity below the minimum unit is rejected``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let status, body = + buy + probe + fundId + """{"instrumentCode":"110075","quantity":"5","tradeDate":"2026-09-21"}""" + (fixture.Key "bond-trade-min-unit") + + Assert.Equal(400, status) + Assert.Contains("INVALID_BOND_TRADE_REQUEST", body) + Assert.Contains("multiple", body) + + [<Fact>] + member _.``a coupon event credits cash and is recorded idempotently``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let buyStatus, _ = + buy + probe + fundId + """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" + (fixture.Key "bond-coupon-buy") + + Assert.Equal(201, buyStatus) + + let key = fixture.Key "bond-coupon-event" + + let body = + """{"instrumentCode":"110075","eventType":"coupon","eventDate":"2026-10-15","quantity":"10","amount":"20.00"}""" + + let status, response = recordCashflow probe fundId body key + Assert.Equal(201, status) + Assert.Contains("\"eventType\":\"coupon\"", response) + Assert.Contains("\"amount\":\"20.00\"", response) + + let replayStatus, _ = recordCashflow probe fundId body key + Assert.Equal(200, replayStatus) + + let status, events = readCashflows probe fundId + Assert.Equal(200, status) + Assert.Contains("\"eventType\":\"coupon\"", events) + Assert.DoesNotContain("\"eventType\":\"maturity\"", events) + + [<Fact>] + member _.``a maturity event settles the position and records the cashflow``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let buyStatus, _ = + buy + probe + fundId + """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" + (fixture.Key "bond-maturity-buy") + + Assert.Equal(201, buyStatus) + + let status, response = + recordCashflow + probe + fundId + """{"instrumentCode":"110075","eventType":"maturity","eventDate":"2026-10-15","quantity":"10","amount":"1000.00"}""" + (fixture.Key "bond-maturity-event") + + Assert.Equal(201, status) + Assert.Contains("\"eventType\":\"maturity\"", response) + + let _, positionsBody = readPositions probe fundId + Assert.Contains("\"positions\":[]", positionsBody) + + [<Fact>] + member _.``a cashflow for a bond the fund does not hold answers 404``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let status, body = + recordCashflow + probe + fundId + """{"instrumentCode":"110075","eventType":"coupon","eventDate":"2026-10-15","quantity":"10","amount":"20.00"}""" + (fixture.Key "bond-coupon-no-position") + + Assert.Equal(404, status) + Assert.Contains("BOND_POSITION_NOT_FOUND", body) [<Fact>] member _.``buying an unknown code with an unavailable quote does not create a position``() = diff --git a/tests/FundLab.Api.Tests/FundValuationTests.fs b/tests/FundLab.Api.Tests/FundValuationTests.fs index 1897fd3..197686d 100644 --- a/tests/FundLab.Api.Tests/FundValuationTests.fs +++ b/tests/FundLab.Api.Tests/FundValuationTests.fs @@ -19,6 +19,17 @@ module FundValuationSupport = AccruedInterest = None Date = None MaturityDate = None + ParValue = None + IssuePrice = None + ValueDate = None + ListingDate = None + PublishDate = None + PayInterestDay = None + CouponRate = None + CouponRateExplain = None + BondExpireYears = None + Rating = None + DataStatus = None } let stockProbeReturning (prices: Map<string, decimal>) = @@ -109,12 +120,52 @@ type FundValuationTests(fixture: PostgresFixture) = repository().CreateBondTrade( key, fundId, - { InstrumentCode = code; BondName = Some "南航转债"; Quantity = quantity; Price = price } + { + InstrumentCode = code + BondName = Some "南航转债" + Quantity = quantity + Price = price + CleanPrice = price + AccruedInterest = 0m + ParValue = 100m + SettlementDate = DateOnly(2026, 9, 21) + CouponRate = None + ValueDate = None + MaturityDate = None + TradeDate = Some(DateOnly(2026, 9, 21)) + } ) with | BondTradeWriteResult.BondTradeCreated _ -> () | other -> failwithf "unexpected bond trade result: %A" other + let buyBondWithTerms fundId code quantity cleanPrice = + let key = fixture.Key(sprintf "valuation-bond-terms-%s" (Guid.NewGuid().ToString("N"))) + + match + repository().CreateBondTrade( + key, + fundId, + { + InstrumentCode = code + BondName = Some "南航转债" + Quantity = quantity + Price = cleanPrice + CleanPrice = cleanPrice + AccruedInterest = 0m + ParValue = 100m + SettlementDate = DateOnly(2026, 9, 21) + CouponRate = Some 0.02m + ValueDate = Some(DateOnly(2020, 10, 15)) + MaturityDate = Some(DateOnly(2026, 10, 15)) + TradeDate = Some(DateOnly(2026, 9, 21)) + } + ) + with + | BondTradeWriteResult.BondTradeCreated _ -> () + | other -> failwithf "unexpected bond trade result: %A" other + + let app stockPrices bondPrices = App.createApplicationWithProbes (repository ()) (FundValuationSupport.probes stockPrices bondPrices) @@ -143,6 +194,30 @@ type FundValuationTests(fixture: PostgresFixture) = Assert.Contains("\"unavailablePositions\":0", body) [<Fact>] + member _.``bond valuation accrues interest point-in-time and values on a dirty basis``() = + let fundId = createFund 10000.00m + buyBondWithTerms fundId "110075" 10m 106.368m + + let valuationAt asOf = + PersistenceTestHelpers.invoke + (app Map.empty (Map.ofList [ "110075", 106.368m ])) + "GET" + (sprintf "/api/funds/%O/valuation?asOfDate=%s" fundId asOf) + [ "Authorization", "Bearer test-token" ] + "" + + let status, early = valuationAt "2026-09-21" + Assert.Equal(200, status) + Assert.Contains("\"accruedInterest\":\"1.86849300\"", early) + Assert.Contains("\"dirtyPrice\":\"108.23649300\"", early) + Assert.Contains("\"valueBasis\":\"dirty\"", early) + Assert.Contains("\"marketValue\":\"1082.36\"", early) + + let _, later = valuationAt "2026-10-01" + Assert.Contains("\"accruedInterest\":\"1.92328800\"", later) + Assert.Contains("\"marketValue\":\"1082.91\"", later) + + [<Fact>] member _.``all-unavailable valuation keeps cash intact and marks positions unavailable without zeroing``() = let fundId = createFund 5000.00m buyStock fundId "600519" 100m 1000.00m diff --git a/tests/FundLab.Domain.Tests/BondRulesTests.fs b/tests/FundLab.Domain.Tests/BondRulesTests.fs new file mode 100644 index 0000000..31069f1 --- /dev/null +++ b/tests/FundLab.Domain.Tests/BondRulesTests.fs @@ -0,0 +1,70 @@ +namespace FundLab.Domain.Tests + +module BondRulesTests = + + open System + open Xunit + open FundLab.Domain + + let private annualCb = + BondTerms.create + 100m + 0.02m + 1 + (DateOnly(2020, 10, 15)) + (DateOnly(2026, 10, 15)) + 10m + 0 + 0m + + [<Fact>] + let ``coupon schedule runs from value date through maturity inclusive`` () = + let schedule = BondRules.couponSchedule annualCb + + Assert.Equal(7, List.length schedule) + Assert.Equal(DateOnly(2020, 10, 15), List.head schedule) + Assert.Equal(DateOnly(2026, 10, 15), List.last schedule) + Assert.Equal(DateOnly(2025, 10, 15), List.item 5 schedule) + + [<Fact>] + let ``accrued interest uses actual over actual days in the current period`` () = + // Period 2025-10-15 .. 2026-10-15 is 365 days; 2026-04-15 is 182 days in. + let accrued = BondRules.accruedInterest annualCb (DateOnly(2026, 4, 15)) + Assert.Equal(0.997260m, accrued) + + [<Fact>] + let ``accrued interest is zero before the value date and after maturity`` () = + Assert.Equal(0m, BondRules.accruedInterest annualCb (DateOnly(2020, 10, 14))) + Assert.Equal(0m, BondRules.accruedInterest annualCb (DateOnly(2027, 1, 1))) + + [<Fact>] + let ``dirty price is clean price plus accrued interest`` () = + Assert.Equal(106.368m, BondRules.dirtyPrice 106.368m 0m) + Assert.Equal(107.368m, BondRules.dirtyPrice 106.368m 1.0m) + + [<Fact>] + let ``trade cost is quantity times price for a 100 par bond`` () = + // 10 张 * 106.368 = 1063.68, matching the existing bond-trade contract. + Assert.Equal(1063.68m, BondRules.tradeCost annualCb 10m 106.368m) + + [<Fact>] + let ``trade cost applies the instrument fee rate`` () = + let terms = { annualCb with FeeRate = 0.0002m } + // 1063.68 gross + 0.212736 -> 0.21 fee. + Assert.Equal(1063.89m, BondRules.tradeCost terms 10m 106.368m) + + [<Fact>] + let ``settlement date is T+0 for exchange bonds and skips weekends otherwise`` () = + Assert.Equal(DateOnly(2026, 9, 18), BondRules.settlementDate annualCb (DateOnly(2026, 9, 18))) + let t1 = { annualCb with SettlementDays = 1 } + Assert.Equal(DateOnly(2026, 9, 21), BondRules.settlementDate t1 (DateOnly(2026, 9, 18))) + + [<Fact>] + let ``quantity must be positive and a whole multiple of the minimum unit`` () = + match BondRules.validateQuantity annualCb 10m with + | Ok value -> Assert.Equal(10m, value) + | Error message -> failwith message + + Assert.True(BondRules.validateQuantity annualCb 15m |> Result.isError) + Assert.True(BondRules.validateQuantity annualCb 0m |> Result.isError) + Assert.True(BondRules.validateQuantity annualCb -10m |> Result.isError) diff --git a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj index a9c6fc0..468ee16 100644 --- a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj +++ b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj @@ -21,6 +21,7 @@ <ItemGroup> <Compile Include="DomainTests.fs" /> <Compile Include="InvestmentPlanTests.fs" /> + <Compile Include="BondRulesTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> |
