namespace FundLab.Api.Tests open System open System.Threading open Xunit open FundLab.Api module BondTradeSupport = let stubQuoteProbe (quote: BondQuote) = { new IBondQuoteProbe with member _.GetQuote(_, _) = Ok quote } let failingQuoteProbe (failure: MarketDataFailure) = { new IBondQuoteProbe with member _.GetQuote(_, _) = Error failure } let quote110075 = { Code = "110075" SourceRevision = "akshare-test/hs-cov-spot" Name = Some "南航转债" Price = Some 106.368m CleanPrice = Some 106.368m 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) = { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = probe StockQuotes = NavDateProbeSupport.stubStockQuoteProbe StockDaily = NavDateProbeSupport.stubStockDailyProbe } [] type BondTradeTests(fixture: PostgresFixture) = let sharedRepository = lazy let value = FundRepository(fixture.ConnectionString) value.EnsureSchema() value let repository () = sharedRepository.Value let createFund () = let command = { Name = "债券闭环测试 FOF" InitialCash = 100000.00m InitialUnitNav = 1.00000000m IsSynthetic = true } let key = fixture.Key(sprintf "bond-trade-fund-%s" (Guid.NewGuid().ToString("N"))) match repository().CreateFund(key, command) with | FundWriteResult.Created fund -> fund.Id | other -> failwithf "unexpected fund creation result: %A" other let app probe = let probes = BondTradeSupport.appWithQuote probe App.createApplicationWithProbes (repository ()) probes let buy (probe: IBondQuoteProbe) fundId body idempotencyKey = PersistenceTestHelpers.invoke (app probe) "POST" (sprintf "/api/funds/%O/bond-trades" fundId) [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] body let readPositions (probe: IBondQuoteProbe) fundId = PersistenceTestHelpers.invoke (app probe) "GET" (sprintf "/api/funds/%O/bond-positions" fundId) [ "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" ] "" 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 () let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 let status, body = 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("\"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("1082.36", positionsBody) [] 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) [] 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) [] 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) [] 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) [] member _.``buying an unknown code with an unavailable quote does not create a position``() = let fundId = createFund () let probe = BondTradeSupport.failingQuoteProbe ( MarketDataCollectorUnavailable "AKShare collector failed: no valid quote for 999999" ) let status, body = buy probe fundId """{"instrumentCode":"999999","quantity":"10"}""" (fixture.Key "bond-trade-unknown") Assert.Equal(503, status) Assert.Contains("MARKET_DATA_UNAVAILABLE", body) let status, positionsBody = readPositions probe fundId Assert.Equal(200, status) Assert.DoesNotContain("999999", positionsBody) Assert.Contains("\"positions\":[]", positionsBody) [] member _.``buying with a non-positive quantity is rejected without a position``() = let fundId = createFund () let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 let status, body = buy probe fundId """{"instrumentCode":"110075","quantity":"0"}""" (fixture.Key "bond-trade-zero") Assert.Equal(400, status) Assert.Contains("INVALID_BOND_TRADE_REQUEST", body) let _, positionsBody = readPositions probe fundId Assert.Contains("\"positions\":[]", positionsBody) [] member _.``buying with a malformed code is rejected``() = let fundId = createFund () let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 let status, body = buy probe fundId """{"instrumentCode":"abc","quantity":"10"}""" (fixture.Key "bond-trade-badcode") Assert.Equal(400, status) Assert.Contains("six digits", body) [] member _.``replaying a buy with the same idempotency key does not double the position``() = let fundId = createFund () let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 let key = fixture.Key "bond-trade-replay" let status, _ = buy probe fundId """{"instrumentCode":"110075","quantity":"10"}""" key Assert.Equal(201, status) let status, _ = buy probe fundId """{"instrumentCode":"110075","quantity":"10"}""" key Assert.Equal(200, status) let _, positionsBody = readPositions probe fundId Assert.Contains("\"quantity\":\"10.00000000\"", positionsBody) Assert.DoesNotContain("\"quantity\":\"20.00000000\"", positionsBody) [] member _.``buying a fund that does not exist answers 404``() = let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 let status, body = buy probe (Guid.NewGuid()) """{"instrumentCode":"110075","quantity":"10"}""" (fixture.Key "bond-trade-missing") 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)