From 0aad89fc76aa77524625964ebad896a6bc0d3660 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 08:36:20 +0800 Subject: Bind subscription confirmation to append-only nav observation evidence --- tests/FundLab.Api.Tests/OrderTests.fs | 165 ++++++++++++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 5 deletions(-) (limited to 'tests/FundLab.Api.Tests/OrderTests.fs') diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs index d975462..0c0d71a 100644 --- a/tests/FundLab.Api.Tests/OrderTests.fs +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -693,10 +693,11 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero) let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) (collectedAt: DateTimeOffset) (publishedAt: DateTimeOffset option) = + let revision = sprintf "akshare-test/%O" (Guid.NewGuid()) let payload: MarketDataNavPayload = { Source = "akshare" - SourceRevision = sprintf "akshare-test/%O" (Guid.NewGuid()) + SourceRevision = revision CollectedAt = collectedAt Code = code Observations = @@ -711,7 +712,7 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = ] } - repository().UpsertNavObservations(payload, "confirm-quote-hash") + repository().UpsertNavObservations(payload, sprintf "confirm-quote-hash/%s" revision) let insertQuote (code: string) (nav: decimal) (collectedAt: DateTimeOffset) (publishedAt: DateTimeOffset option) = insertQuoteOnDate code nav (ConfirmationPolicy.tradeDateFor DateTimeOffset.Now) collectedAt publishedAt @@ -752,6 +753,19 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = command.ExecuteScalar() :?> string + let scalarTimestamp (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- sql + + for name, value, dbType in parameters do + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + + let value = command.ExecuteScalar() :?> DateTime + DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc)) + let orderFundCode orderId = scalarText "SELECT fund_code FROM subscription_orders WHERE id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ] @@ -827,14 +841,26 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = insertQuote code 2.5m collected None let key = fixture.Key("confirm-replay-key") - match confirmKey fundId orderId key with - | OrderConfirmed _ -> () - | other -> failwithf "unexpected first confirm result: %A" other + let originalHash, originalFirstSeen = + match confirmKey fundId orderId key with + | OrderConfirmed confirmed -> + match confirmed.ConfirmedQuote with + | Some quote -> quote.PayloadHash, quote.FirstSeenAt + | None -> failwith "confirmed quote evidence missing" + | other -> failwithf "unexpected first confirm result: %A" other let cashAfterFirst = availableCash fundId insertQuote code 9.9m (truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-8.0))) None + let supersededHash = + scalarText + "SELECT source_payload_hash FROM fund_nav_observations WHERE instrument_code = @code AND nav_date = @nav_date" + [ "code", box code, NpgsqlTypes.NpgsqlDbType.Text + "nav_date", box (ConfirmationPolicy.tradeDateFor DateTimeOffset.Now), NpgsqlTypes.NpgsqlDbType.Date ] + + Assert.NotEqual(originalHash, supersededHash) + match confirmKey fundId orderId key with | ConfirmReplayed replayed -> Assert.Equal("confirmed", replayed.Status) @@ -844,6 +870,8 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = Assert.Equal(2.5m, quote.Nav) Assert.Equal(collected, quote.CollectedAt) Assert.Equal(None, quote.PublishedAt) + Assert.Equal(originalHash, quote.PayloadHash) + Assert.Equal(originalFirstSeen, quote.FirstSeenAt) | None -> failwith "replayed quote evidence missing" Assert.Equal(400.00000000m, replayed.ConfirmedUnits |> Option.get) @@ -954,6 +982,133 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = | None -> failwith "late confirmed quote evidence missing" | other -> failwithf "unexpected late confirm result: %A" other + [] + member _.``a revision first observed after the confirmation attempt stays pending without moving cash or holdings``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-evidence-1" "1000.00" "0.00" + let code = orderFundCode orderId + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected None + + execute + "UPDATE fund_nav_observation_evidence SET first_seen_at = @future WHERE instrument_code = @code AND nav_date = @nav_date" + [ "future", box (truncateMicroseconds (DateTimeOffset.Now.AddSeconds(5.0))), NpgsqlTypes.NpgsqlDbType.TimestampTz + "code", box code, NpgsqlTypes.NpgsqlDbType.Text + "nav_date", box (ConfirmationPolicy.tradeDateFor DateTimeOffset.Now), NpgsqlTypes.NpgsqlDbType.Date ] + + match confirmKey fundId orderId (fixture.Key("confirm-evidence-key")) with + | ConfirmPendingNav pending -> + Assert.Equal("pending_nav", pending.Status) + Assert.True(pending.PendingReason.IsSome) + Assert.True(pending.PendingReason.Value.Contains("first observed after")) + Assert.Equal(None, pending.ConfirmIdempotencyKey) + Assert.Equal(None, pending.ConfirmedAt) + Assert.Equal(None, pending.ConfirmedQuote) + | other -> failwithf "unexpected evidence-deferral result: %A" other + + Assert.Equal(9000.00m, availableCash fundId) + Assert.Equal(1000.00m, reservedCash fundId) + Assert.Equal(0, (repository().GetFundPositions(fundId)).Length) + Assert.Equal(0L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM subscription_order_events WHERE order_id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]) + Assert.Equal(0L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM subscription_confirm_idempotencies WHERE order_id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]) + + [] + member _.``reobserving the same revision keeps the earliest first seen and a changed revision is not backdated``() = + let code = seedInstrument () + let navDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.Now + let observationRow = + "SELECT first_seen_at FROM fund_nav_observations WHERE instrument_code = @code AND nav_date = @nav_date" + let rowParameters = + [ "code", box code, NpgsqlTypes.NpgsqlDbType.Text + "nav_date", box navDate, NpgsqlTypes.NpgsqlDbType.Date ] + + let buildPayload (revision: string) (collectedAt: DateTimeOffset) (nav: decimal) : MarketDataNavPayload = + { + Source = "akshare" + SourceRevision = revision + CollectedAt = collectedAt + Code = code + Observations = + [ + { + NavDate = navDate + PublishedAt = None + Nav = nav + AccumulatedNav = Some nav + DailyReturn = Some 0.0m + } + ] + } + + repository().UpsertNavObservations(buildPayload "akshare-test/first-seen-r1" (truncateMicroseconds (DateTimeOffset.Now.AddHours(-6.0))) 2.5m, "confirm-first-seen-hash-r1") + + let earliest = truncateMicroseconds (DateTimeOffset.Now.AddHours(-2.0)) + + execute + "UPDATE fund_nav_observations SET first_seen_at = @first_seen WHERE instrument_code = @code AND nav_date = @nav_date" + [ "first_seen", box earliest, NpgsqlTypes.NpgsqlDbType.TimestampTz + "code", box code, NpgsqlTypes.NpgsqlDbType.Text + "nav_date", box navDate, NpgsqlTypes.NpgsqlDbType.Date ] + + repository().UpsertNavObservations(buildPayload "akshare-test/first-seen-r1" (truncateMicroseconds (DateTimeOffset.Now.AddHours(-3.0))) 2.5m, "confirm-first-seen-hash-r1") + Assert.Equal(earliest, scalarTimestamp observationRow rowParameters) + + let marker = truncateMicroseconds DateTimeOffset.UtcNow + let r2Collected = truncateMicroseconds (DateTimeOffset.Now.AddHours(-5.0)) + repository().UpsertNavObservations(buildPayload "akshare-test/first-seen-r2" r2Collected 3.3m, "confirm-first-seen-hash-r2") + + Assert.Equal("confirm-first-seen-hash-r2", scalarText "SELECT source_payload_hash FROM fund_nav_observations WHERE instrument_code = @code AND nav_date = @nav_date" rowParameters) + Assert.Equal(3.3m, scalarDecimal "SELECT nav FROM fund_nav_observations WHERE instrument_code = @code AND nav_date = @nav_date" rowParameters) + Assert.Equal(r2Collected, scalarTimestamp "SELECT source_collected_at FROM fund_nav_observations WHERE instrument_code = @code AND nav_date = @nav_date" rowParameters) + + let firstSeenAfterChange = scalarTimestamp observationRow rowParameters + Assert.True( + firstSeenAfterChange >= marker.AddSeconds(-1.0), + sprintf "expected non-backdated first_seen_at after revision change, got %O (marker %O, earliest %O)" firstSeenAfterChange marker earliest + ) + + [] + member _.``re-observing a superseded revision preserves its earliest first seen evidence``() = + let code = seedInstrument () + let navDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.Now + let rowParameters = + [ "code", box code, NpgsqlTypes.NpgsqlDbType.Text + "nav_date", box navDate, NpgsqlTypes.NpgsqlDbType.Date ] + + let buildPayload (revision: string) (collectedAt: DateTimeOffset) (nav: decimal) : MarketDataNavPayload = + { + Source = "akshare" + SourceRevision = revision + CollectedAt = collectedAt + Code = code + Observations = + [ + { + NavDate = navDate + PublishedAt = None + Nav = nav + AccumulatedNav = Some nav + DailyReturn = Some 0.0m + } + ] + } + + repository().UpsertNavObservations(buildPayload "akshare-test/aba-r1" (truncateMicroseconds (DateTimeOffset.Now.AddHours(-6.0))) 2.5m, "confirm-aba-hash-r1") + + let evidenceAFirst = + scalarTimestamp + "SELECT first_seen_at FROM fund_nav_observation_evidence WHERE instrument_code = @code AND nav_date = @nav_date AND source_payload_hash = @hash" + (rowParameters @ [ "hash", box "confirm-aba-hash-r1", NpgsqlTypes.NpgsqlDbType.Text ]) + + repository().UpsertNavObservations(buildPayload "akshare-test/aba-r2" (truncateMicroseconds (DateTimeOffset.Now.AddHours(-3.0))) 3.3m, "confirm-aba-hash-r2") + Assert.Equal(3.3m, scalarDecimal "SELECT nav FROM fund_nav_observations WHERE instrument_code = @code AND nav_date = @nav_date" rowParameters) + + repository().UpsertNavObservations(buildPayload "akshare-test/aba-r1" (truncateMicroseconds (DateTimeOffset.Now.AddHours(-6.0))) 2.5m, "confirm-aba-hash-r1") + + Assert.Equal(evidenceAFirst, scalarTimestamp "SELECT first_seen_at FROM fund_nav_observation_evidence WHERE instrument_code = @code AND nav_date = @nav_date AND source_payload_hash = @hash" (rowParameters @ [ "hash", box "confirm-aba-hash-r1", NpgsqlTypes.NpgsqlDbType.Text ])) + Assert.Equal("confirm-aba-hash-r1", scalarText "SELECT source_payload_hash FROM fund_nav_observations WHERE instrument_code = @code AND nav_date = @nav_date" rowParameters) + Assert.Equal(2.5m, scalarDecimal "SELECT nav FROM fund_nav_observations WHERE instrument_code = @code AND nav_date = @nav_date" rowParameters) + [] member _.``retrying after the cutoff keeps the original order trade date``() = let fundId = createFund 10000.00m -- cgit v1.2.3