summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/BondTradeTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Api.Tests/BondTradeTests.fs')
-rw-r--r--tests/FundLab.Api.Tests/BondTradeTests.fs139
1 files changed, 134 insertions, 5 deletions
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``() =