summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Api.Tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/FundValuationTests.fs178
2 files changed, 179 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)