summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 04:59:12 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 04:59:12 +0800
commit545f2a507fd0138f7fd50ed7fb2631d0273db625 (patch)
treef9eedee8d82b632474408e774cb95e9e01a9750d /tests
parent0183b86f18d9897ff9487e6d864898954397991d (diff)
downloadfund-lab-545f2a507fd0138f7fd50ed7fb2631d0273db625.tar.gz
Integrate bond and stock positions into fund valuation (3d-23)
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/FundValuationTests.fs178
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs69
3 files changed, 248 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index dffe48f..38a9e7e 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -36,6 +36,7 @@
<Compile Include="StockDailyProbeTests.fs" />
<Compile Include="StockTradeTests.fs" />
<Compile Include="BondTradeTests.fs" />
+ <Compile Include="FundValuationTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/FundValuationTests.fs b/tests/FundLab.Api.Tests/FundValuationTests.fs
new file mode 100644
index 0000000..0cfee18
--- /dev/null
+++ b/tests/FundLab.Api.Tests/FundValuationTests.fs
@@ -0,0 +1,178 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Threading
+open Xunit
+open FundLab.Api
+
+module FundValuationSupport =
+ let stockQuote (code: string) (price: decimal) : StockQuote =
+ { Code = code; Name = Some "贵州茅台"; Price = Some price; Currency = "CNY" }
+
+ let bondQuote (code: string) (price: decimal) : BondQuote =
+ {
+ Code = code
+ SourceRevision = "akshare-test/hs-cov-spot"
+ Name = Some "南航转债"
+ Price = Some price
+ CleanPrice = Some price
+ AccruedInterest = None
+ Date = None
+ MaturityDate = None
+ }
+
+ let stockProbeReturning (prices: Map<string, decimal>) =
+ { new IStockQuoteProbe with
+ member _.GetQuote(code, _) =
+ match prices.TryFind code with
+ | Some price -> Ok(stockQuote code price)
+ | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) }
+
+ let bondProbeReturning (prices: Map<string, decimal>) =
+ { new IBondQuoteProbe with
+ member _.GetQuote(code, _) =
+ match prices.TryFind code with
+ | Some price -> Ok(bondQuote code price)
+ | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) }
+
+ let probes (stockPrices: Map<string, decimal>) (bondPrices: Map<string, decimal>) =
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = bondProbeReturning bondPrices
+ StockQuotes = stockProbeReturning stockPrices
+ StockDaily = NavDateProbeSupport.stubStockDailyProbe }
+
+[<Collection("postgres")>]
+type FundValuationTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "组合估值测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "valuation-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 buyStock fundId code quantity price =
+ let key = fixture.Key(sprintf "valuation-stock-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateStockTrade(
+ key,
+ fundId,
+ { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price }
+ )
+ with
+ | StockTradeWriteResult.StockTradeCreated _ -> ()
+ | other -> failwithf "unexpected stock trade result: %A" other
+
+ let buyBond fundId code quantity price =
+ let key = fixture.Key(sprintf "valuation-bond-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateBondTrade(
+ key,
+ fundId,
+ { InstrumentCode = code; BondName = Some "南航转债"; Quantity = quantity; Price = price }
+ )
+ with
+ | BondTradeWriteResult.BondTradeCreated _ -> ()
+ | other -> failwithf "unexpected bond trade result: %A" other
+
+ let app stockPrices bondPrices =
+ App.createApplicationWithProbes (repository ()) (FundValuationSupport.probes stockPrices bondPrices)
+
+ let getValuation stockPrices bondPrices fundId =
+ PersistenceTestHelpers.invoke
+ (app stockPrices bondPrices)
+ "GET"
+ (sprintf "/api/funds/%O/valuation" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``mixed valuation prices live positions and flags the unavailable one``() =
+ let fundId = createFund 10000.00m
+ buyStock fundId "600519" 100m 1000.00m
+ buyBond fundId "110075" 10m 100.00m
+
+ let status, body =
+ getValuation (Map.ofList [ "600519", 1252.57m ]) (Map.ofList [ "110075", 106.368m ]) fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"cash\":\"10000.00\"", body)
+ Assert.Contains("\"positionsMarketValue\":\"126320.68\"", body)
+ Assert.Contains("\"portfolioValue\":\"136320.68\"", body)
+ Assert.Contains("\"pricedPositions\":2", body)
+ Assert.Contains("\"unavailablePositions\":0", body)
+
+ [<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
+ buyBond fundId "110075" 10m 100.00m
+
+ let status, body = getValuation Map.empty Map.empty fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"cash\":\"5000.00\"", body)
+ Assert.Contains("\"positionsMarketValue\":\"0.00\"", body)
+ Assert.Contains("\"portfolioValue\":\"5000.00\"", body)
+ Assert.Contains("\"unavailablePositions\":2", body)
+ Assert.Contains("\"status\":\"unavailable\"", body)
+ Assert.Contains("\"quantity\":\"100.00000000\"", body)
+ Assert.Contains("\"quantity\":\"10.00000000\"", body)
+
+ [<Fact>]
+ member _.``partial failure values available positions and does not inflate on missing price``() =
+ let fundId = createFund 2000.00m
+ buyStock fundId "600519" 100m 1000.00m
+ buyBond fundId "110075" 10m 100.00m
+
+ let status, body = getValuation (Map.ofList [ "600519", 10.00m ]) Map.empty fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"positionsMarketValue\":\"1000.00\"", body)
+ Assert.Contains("\"portfolioValue\":\"3000.00\"", body)
+ Assert.Contains("\"pricedPositions\":1", body)
+ Assert.Contains("\"unavailablePositions\":1", body)
+
+ [<Fact>]
+ member _.``without probes every position is marked unavailable and excluded from market value``() =
+ let fundId = createFund 0.00m
+ buyStock fundId "600519" 100m 1000.00m
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (App.createApplication (repository ()))
+ "GET"
+ (sprintf "/api/funds/%O/valuation" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"positionsMarketValue\":\"0.00\"", body)
+ Assert.Contains("\"portfolioValue\":\"0.00\"", body)
+ Assert.Contains("\"status\":\"unavailable\"", body)
+ Assert.Contains("\"quantity\":\"100.00000000\"", body)
+ Assert.Contains("\"unavailablePositions\":1", body)
+
+ [<Fact>]
+ member _.``valuation of a missing fund answers 404``() =
+ let status, body = getValuation Map.empty Map.empty (Guid.NewGuid())
+ Assert.Equal(404, status)
+ Assert.Contains("FUND_NOT_FOUND", body)
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 3a9ec61..d8bb472 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -710,6 +710,75 @@ module ConfirmationBoundaryTests =
Assert.False(updated.bondPositionsInFlight)
[<Fact>]
+ let ``valuation completion stores totals and per-position marks`` () =
+ let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+
+ let model =
+ { init () with
+ createdFund = Some(FundBoundaryTests.sampleFund fundId)
+ valuationReadSeq = 1
+ valuationInFlight = true }
+
+ let raw : RawFundValuation =
+ {
+ fundId = fundId
+ currency = "CNY"
+ cash = "10000.00"
+ positionsMarketValue = "126320.68"
+ portfolioValue = "136320.68"
+ pricedPositions = 1
+ unavailablePositions = 1
+ positions =
+ [|
+ {
+ instrumentCode = "600519"
+ name = box "贵州茅台"
+ assetClass = "stock"
+ quantity = "100.00000000"
+ price = box "1252.57"
+ priceSource = box "live"
+ marketValue = box "125257.00"
+ status = "priced"
+ }
+ {
+ instrumentCode = "110075"
+ name = box "南航转债"
+ assetClass = "bond"
+ quantity = "10.00000000"
+ price = null
+ priceSource = null
+ marketValue = null
+ status = "unavailable"
+ }
+ |]
+ }
+
+ let updated, _ = update (ValuationReadCompleted(1, fundId, raw)) model
+
+ let valuation = updated.valuation.Value
+ Assert.Equal("136320.68", valuation.portfolioValue)
+ Assert.Equal(1, valuation.unavailablePositions)
+ Assert.Equal(2, valuation.positions.Length)
+ Assert.Equal("unavailable", valuation.positions[1].status)
+ Assert.False(updated.valuationInFlight)
+
+ [<Fact>]
+ let ``valuation failure clears totals and surfaces a message`` () =
+ let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+
+ let model =
+ { init () with
+ createdFund = Some(FundBoundaryTests.sampleFund fundId)
+ valuationReadSeq = 1
+ valuationInFlight = true }
+
+ let updated, _ = update (ValuationReadFailed(1, fundId, "collector exited with code 2")) model
+
+ Assert.Equal(None, updated.valuation)
+ Assert.Contains("组合估值不可用", updated.valuationMessage |> Option.defaultValue "")
+ Assert.False(updated.valuationInFlight)
+
+ [<Fact>]
let ``order decode keeps decimal strings and decodes option fields`` () =
let confirmed = Api.decodeOrder (sampleConfirmedRaw ())
let pending = Api.decodeOrder (samplePendingRaw ())