summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/ReturnsTests.fs233
-rw-r--r--tests/FundLab.Web.Tests/ChartTests.fs47
3 files changed, 281 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 552a9bd..0024deb 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -26,6 +26,7 @@
<Compile Include="OrderTests.fs" />
<Compile Include="SipAdvanceTests.fs" />
<Compile Include="DividendTests.fs" />
+ <Compile Include="ReturnsTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/ReturnsTests.fs b/tests/FundLab.Api.Tests/ReturnsTests.fs
new file mode 100644
index 0000000..01a5e10
--- /dev/null
+++ b/tests/FundLab.Api.Tests/ReturnsTests.fs
@@ -0,0 +1,233 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Text.Json
+open Npgsql
+open Xunit
+open FundLab.Api
+
+[<Collection("postgres")>]
+type ReturnsTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let seedInstrument () =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+
+ let payload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "收益测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "returns-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "收益测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "returns-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 () = App.createApplication (repository ())
+
+ let truncateMicroseconds (moment: DateTimeOffset) =
+ let utc = moment.ToUniversalTime()
+ DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero)
+
+ let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) =
+ let revision = sprintf "akshare-test/%O" (Guid.NewGuid())
+
+ let payload: MarketDataNavPayload =
+ {
+ Source = "akshare"
+ SourceRevision = revision
+ CollectedAt = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = navDate
+ PublishedAt = None
+ Nav = nav
+ AccumulatedNav = Some nav
+ DailyReturn = Some 0.0m
+ }
+ ]
+ }
+
+ repository().UpsertNavObservations(payload, sprintf "returns-hash/%s" revision)
+
+ let confirmedHolding fundId (code: string) (amount: decimal) (tradeDate: DateOnly) =
+ insertQuoteOnDate code 2.5m tradeDate
+
+ let orderKey = fixture.Key(sprintf "returns-hold-%s" (Guid.NewGuid().ToString("N")))
+
+ let order =
+ match
+ repository().CreateSubscriptionOrder(
+ orderKey,
+ fundId,
+ { FundCode = code; Amount = amount; FeeAmount = 0m },
+ tradeDate
+ )
+ with
+ | SubscriptionOrderWriteResult.OrderCreated order
+ | SubscriptionOrderWriteResult.OrderReplayed order -> order
+ | other -> failwithf "unexpected holding order result: %A" other
+
+ match
+ repository().ConfirmSubscriptionOrder(
+ fixture.Key(sprintf "returns-confirm-%s" (Guid.NewGuid().ToString("N"))),
+ fundId,
+ order.Id
+ )
+ with
+ | SubscriptionConfirmResult.OrderConfirmed _ -> ()
+ | confirmResult -> failwithf "unexpected holding confirm: %A" confirmResult
+
+ order
+
+ let insertConfirmedOrderWithoutNav fundId (code: string) (tradeDate: DateOnly) (units: decimal) (amount: decimal) =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+
+ command.CommandText <-
+ """
+ INSERT INTO subscription_orders
+ (id, fund_id, fund_code, amount, fee_amount, reserved_total, status, is_synthetic,
+ submitted_at, trade_date, confirmed_at, confirmed_units, confirmed_invested_cash,
+ confirmed_residual_cash)
+ VALUES
+ (@id, @fund_id, @code, @amount, 0.00, @amount, 'confirmed', false,
+ now(), @trade_date, now(), @units, @amount, 0.00)
+ """
+
+ let idParameter = command.Parameters.Add("id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ idParameter.Value <- box (Guid.NewGuid())
+ let fundParameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ fundParameter.Value <- box fundId
+ let codeParameter = command.Parameters.Add("code", NpgsqlTypes.NpgsqlDbType.Text)
+ codeParameter.Value <- box code
+ let amountParameter = command.Parameters.Add("amount", NpgsqlTypes.NpgsqlDbType.Numeric)
+ amountParameter.Value <- box amount
+ let unitsParameter = command.Parameters.Add("units", NpgsqlTypes.NpgsqlDbType.Numeric)
+ unitsParameter.Value <- box units
+ let dateParameter = command.Parameters.Add("trade_date", NpgsqlTypes.NpgsqlDbType.Date)
+ dateParameter.Value <- box tradeDate
+ command.ExecuteNonQuery() |> ignore
+
+ let getReturns (fundId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/returns" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let pointOn (date: string) (body: string) =
+ use document = JsonDocument.Parse(body)
+
+ document.RootElement.GetProperty("points").EnumerateArray()
+ |> Seq.find (fun point -> point.GetProperty("date").GetString() = date)
+ |> fun point -> point.Clone()
+
+ [<Fact>]
+ member _.``returns series is empty for a fund without activity``() =
+ let fundId = createFund 10000.00m
+ let status, body = getReturns fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"points\":[]", body)
+ Assert.Contains("\"pending\":false", body)
+ Assert.Contains("\"dataUpdatedAt\":null", body)
+
+ [<Fact>]
+ member _.``external deposits issue units without moving the unit nav or creating profit``() =
+ let fundId = createFund 10000.00m
+
+ match
+ repository().CreateCapitalDeposit(
+ fixture.Key(sprintf "returns-deposit-%s" (Guid.NewGuid().ToString("N"))),
+ fundId,
+ { Amount = 5000.00m; Note = None }
+ )
+ with
+ | CapitalDepositWriteResult.CapitalDepositCreated _ -> ()
+ | other -> failwithf "unexpected deposit result: %A" other
+
+ let status, body = getReturns fundId
+ Assert.Equal(200, status)
+
+ let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+ let point = pointOn (today.ToString("yyyy-MM-dd")) body
+
+ Assert.False(point.GetProperty("pending").GetBoolean())
+ Assert.Equal("15000.00", point.GetProperty("totalAssets").GetString())
+ Assert.Equal("15000.00", point.GetProperty("netExternalFlow").GetString())
+ Assert.Equal("0.00", point.GetProperty("cumulativeReturn").GetString())
+ Assert.Equal("1.00000000", point.GetProperty("unitNav").GetString())
+
+ [<Fact>]
+ member _.``returns series revalues holdings on later nav dates``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let tradeDate = DateOnly(2026, 9, 15)
+ let _ = confirmedHolding fundId code 250.00m tradeDate
+ insertQuoteOnDate code 3.0m (DateOnly(2026, 9, 16))
+
+ let status, body = getReturns fundId
+ Assert.Equal(200, status)
+
+ let first = pointOn "2026-09-15" body
+ Assert.Equal("10000.00", first.GetProperty("totalAssets").GetString())
+ Assert.Equal("0.00", first.GetProperty("cumulativeReturn").GetString())
+ Assert.Equal("1.00000000", first.GetProperty("unitNav").GetString())
+
+ let second = pointOn "2026-09-16" body
+ Assert.False(second.GetProperty("pending").GetBoolean())
+ Assert.Equal("10050.00", second.GetProperty("totalAssets").GetString())
+ Assert.Equal("50.00", second.GetProperty("cumulativeReturn").GetString())
+ Assert.Equal("1.00500000", second.GetProperty("unitNav").GetString())
+
+ [<Fact>]
+ member _.``held instrument without a valuation nav is pending and never zero filled``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let tradeDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+ insertConfirmedOrderWithoutNav fundId code tradeDate 100.00000000m 250.00m
+
+ let status, body = getReturns fundId
+ Assert.Equal(200, status)
+ Assert.Contains("\"pending\":true", body)
+ Assert.Contains("\"totalAssets\":null", body)
+ Assert.Contains("\"holdingsValue\":null", body)
+ Assert.Contains("\"cumulativeReturn\":null", body)
+
+ let point = pointOn (tradeDate.ToString("yyyy-MM-dd")) body
+ Assert.True(point.GetProperty("pending").GetBoolean())
+ Assert.Equal(JsonValueKind.Null, point.GetProperty("totalAssets").ValueKind)
+ Assert.Equal("9750.00", point.GetProperty("cash").GetString())
+
+ [<Fact>]
+ member _.``returns request for an unknown fund is not found``() =
+ let status, body = getReturns (Guid.NewGuid())
+ Assert.Equal(404, status)
+ Assert.Contains("FUND_NOT_FOUND", body)
diff --git a/tests/FundLab.Web.Tests/ChartTests.fs b/tests/FundLab.Web.Tests/ChartTests.fs
index cede2d2..0382b0a 100644
--- a/tests/FundLab.Web.Tests/ChartTests.fs
+++ b/tests/FundLab.Web.Tests/ChartTests.fs
@@ -21,3 +21,50 @@ module ChartTests =
Assert.Equal(1.0, points[1].y)
Assert.Equal(0.4, points[2].y)
Assert.Equal("2026-01-03", points[2].navDate)
+
+ [<Fact>]
+ let ``series points keep the shared date axis and normalize each series`` () =
+ let dates = [ "2026-01-01"; "2026-01-02"; "2026-01-03" ]
+
+ let values =
+ [
+ "2026-01-01", "100.00"
+ "2026-01-02", "150.00"
+ "2026-01-03", "125.00"
+ ]
+
+ let points = Chart.seriesPoints dates values
+
+ Assert.Equal(3, points.Length)
+ Assert.Equal(0.0, points[0].x)
+ Assert.Equal(0.5, points[1].x)
+ Assert.Equal(1.0, points[2].x)
+ Assert.Equal(0.0, points[0].y)
+ Assert.Equal(1.0, points[1].y)
+ Assert.Equal(0.5, points[2].y)
+ Assert.Equal("150.00", points[1].value)
+
+ [<Fact>]
+ let ``series points skip missing dates instead of filling zero`` () =
+ let dates = [ "2026-01-01"; "2026-01-02"; "2026-01-03" ]
+
+ let values =
+ [
+ "2026-01-01", "100.00"
+ "2026-01-03", "125.00"
+ ]
+
+ let points = Chart.seriesPoints dates values
+
+ Assert.Equal(2, points.Length)
+ Assert.Equal(0.0, points[0].x)
+ Assert.Equal(1.0, points[1].x)
+ Assert.Equal("2026-01-03", points[1].label)
+
+ [<Fact>]
+ let ``a single point is centered and keeps no line`` () =
+ let points = Chart.seriesPoints [ "2026-01-01" ] [ "2026-01-01", "1.00000000" ]
+
+ Assert.Equal(1, points.Length)
+ Assert.Equal(0.5, points[0].x)
+ Assert.Equal(0.5, points[0].y)