summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/BondQuoteProbeTests.fs11
-rw-r--r--tests/FundLab.Api.Tests/BondTradeTests.fs139
-rw-r--r--tests/FundLab.Api.Tests/FundValuationTests.fs77
-rw-r--r--tests/FundLab.Domain.Tests/BondRulesTests.fs70
-rw-r--r--tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj1
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>