summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/SipAdvanceTests.fs
blob: 7ac305d71b84f66ac9c413aed2355b20338d7ef0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
namespace FundLab.Api.Tests

open System
open Npgsql
open Xunit
open FundLab.Api
open FundLab.Domain

[<Collection("postgres")>]
type SipAdvanceTests(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, "sip-advance-test-hash")
        code

    let createFund (initialCash: decimal) =
        let command =
            {
                Name = "定投执行测试 FOF"
                InitialCash = initialCash
                InitialUnitNav = 1.00000000m
                IsSynthetic = true
            }

        let key = fixture.Key(sprintf "sip-adv-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 "sip-advance-hash/%s" revision)

    let postPlan (fundId: Guid) (code: string) (amount: string) (frequency: string) =
        let body =
            sprintf "{\"instrumentCode\":\"%s\",\"amount\":\"%s\",\"frequency\":\"%s\"}" code amount frequency

        PersistenceTestHelpers.invoke
            (app ())
            "POST"
            (sprintf "/api/funds/%O/sip/plans" fundId)
            [
                "Authorization", "Bearer test-token"
                "Idempotency-Key", fixture.Key(sprintf "sip-plan-%s" (Guid.NewGuid().ToString("N")))
            ]
            body
        |> fun (status, response) ->
            if status <> 201 then failwithf "unexpected plan status %d: %s" status response
            PersistenceTestHelpers.responseId response

    // plans anchored in the past so due periods fall on dates whose NAV the tests can seed
    let createPlanWithAnchor (fundId: Guid) (code: string) (amount: decimal) (frequency: SipFrequency) =
        let key = fixture.Key(sprintf "sip-plan-%s" (Guid.NewGuid().ToString("N")))

        match repository().CreateSipPlan(key, fundId, { InstrumentCode = code; Amount = amount; Frequency = frequency }, DateOnly(2026, 9, 7)) with
        | SipPlanWriteResult.SipPlanCreated plan -> plan.Id
        | other -> failwithf "unexpected sip plan result: %A" other

    let postManualOrder (fundId: Guid) (code: string) (amount: string) =
        let body =
            sprintf "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"0.00\"}" code amount

        PersistenceTestHelpers.invoke
            (app ())
            "POST"
            (sprintf "/api/funds/%O/orders" fundId)
            [
                "Authorization", "Bearer test-token"
                "Idempotency-Key", fixture.Key(sprintf "manual-%s" (Guid.NewGuid().ToString("N")))
            ]
            body
        |> fun (status, response) ->
            if status <> 201 then failwithf "unexpected manual order status %d: %s" status response
            PersistenceTestHelpers.responseId response

    let confirmManualOrder (fundId: Guid) (orderId: Guid) =
        PersistenceTestHelpers.invoke
            (app ())
            "POST"
            (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId)
            [
                "Authorization", "Bearer test-token"
                "Idempotency-Key", fixture.Key(sprintf "manual-confirm-%s" (Guid.NewGuid().ToString("N")))
            ]
            ""
        |> fun (status, _) ->
            if status <> 200 then failwithf "unexpected manual confirm status %d" status

    let advance (fundId: Guid) (body: string) =
        PersistenceTestHelpers.invoke
            (app ())
            "POST"
            (sprintf "/api/funds/%O/sip/advance" fundId)
            [ "Authorization", "Bearer test-token" ]
            body

    let scalarDecimal (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

        command.ExecuteScalar() :?> decimal

    let availableCash fundId =
        scalarDecimal "SELECT available_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]

    let executionCount fundId =
        scalarDecimal
            "SELECT count(*)::numeric FROM sip_executions e JOIN sip_plans p ON p.id = e.plan_id WHERE p.fund_id = @fund_id"
            [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]
        |> int64

    let confirmedOrderCount fundId =
        scalarDecimal
            "SELECT count(*)::numeric FROM subscription_orders WHERE fund_id = @fund_id AND status = 'confirmed'"
            [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]
        |> int64

    let sipOrderSnapshot fundId =
        use connection = new NpgsqlConnection(fixture.ConnectionString)
        connection.Open()
        use command = connection.CreateCommand()
        command.CommandText <-
            "SELECT o.confirmed_units, o.confirmed_invested_cash, o.confirmed_residual_cash FROM subscription_orders o JOIN sip_executions e ON e.order_id = o.id WHERE o.fund_id = @fund_id ORDER BY o.submitted_at, o.id"
        let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
        parameter.Value <- box fundId
        use reader = command.ExecuteReader()
        let rows = ResizeArray<decimal * decimal * decimal>()

        while reader.Read() do
            rows.Add(reader.GetDecimal(0), reader.GetDecimal(1), reader.GetDecimal(2))

        rows |> Seq.toList

    [<Fact>]
    member _.``sip advance executes orders through the shared confirmation pipeline``() =
        let fundId = createFund 10000.00m
        let code = seedInstrument ()
        let planId = createPlanWithAnchor fundId code 100.00m Weekly

        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14))
        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 21))

        let status, response = advance fundId "{\"endDate\":\"2026-09-21\"}"

        Assert.Equal(200, status)
        Assert.Contains("\"status\":\"succeeded\"", response)
        Assert.Contains("\"tradeDate\":\"2026-09-14\"", response)
        Assert.Contains("\"tradeDate\":\"2026-09-21\"", response)
        Assert.Contains("\"nextTradeDate\":\"2026-09-28\"", response)

        Assert.Equal(9800.00m, availableCash fundId)
        Assert.Equal(2L, executionCount fundId)
        Assert.Equal(2L, confirmedOrderCount fundId)

        Assert.All(sipOrderSnapshot fundId, fun (units, invested, residual) ->
            Assert.Equal(40.00000000m, units)
            Assert.Equal(100.00m, invested)
            Assert.Equal(0.00m, residual))

    [<Fact>]
    member _.``sip orders match a manual order with the same parameters exactly``() =
        let fundId = createFund 10000.00m
        let code = seedInstrument ()
        let planId = createPlanWithAnchor fundId code 100.00m Weekly
        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14))
        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 21))

        let _, _ = advance fundId "{\"endDate\":\"2026-09-14\"}"

        let manualOrderId = postManualOrder fundId code "100.00"
        confirmManualOrder fundId manualOrderId

        use connection = new NpgsqlConnection(fixture.ConnectionString)
        connection.Open()
        use command = connection.CreateCommand()
        command.CommandText <-
            "SELECT confirmed_units, confirmed_invested_cash, confirmed_residual_cash FROM subscription_orders WHERE id = @order_id"
        let parameter = command.Parameters.Add("order_id", NpgsqlTypes.NpgsqlDbType.Uuid)
        parameter.Value <- box manualOrderId
        use reader = command.ExecuteReader()
        reader.Read() |> ignore
        let manualUnits = reader.GetDecimal(0)
        let manualInvested = reader.GetDecimal(1)
        let manualResidual = reader.GetDecimal(2)

        let sipSnapshots = sipOrderSnapshot fundId

        Assert.Equal(1, sipSnapshots.Length)

        let sipUnits, sipInvested, sipResidual = sipSnapshots[0]

        Assert.Equal(manualUnits, sipUnits)
        Assert.Equal(manualInvested, sipInvested)
        Assert.Equal(manualResidual, sipResidual)

    [<Fact>]
    member _.``sip advance reruns are idempotent for the same trade dates``() =
        let fundId = createFund 10000.00m
        let code = seedInstrument ()
        let planId = createPlanWithAnchor fundId code 100.00m Weekly
        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14))
        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 21))

        let firstStatus, firstResponse = advance fundId "{\"endDate\":\"2026-09-21\"}"
        let firstCash = availableCash fundId
        let firstCount = executionCount fundId
        let firstOrders = confirmedOrderCount fundId

        let secondStatus, secondResponse = advance fundId "{\"endDate\":\"2026-09-21\"}"

        Assert.Equal(200, firstStatus)
        Assert.Equal(200, secondStatus)
        Assert.Equal(firstResponse, secondResponse)
        Assert.Equal(firstCash, availableCash fundId)
        Assert.Equal(firstCount, executionCount fundId)
        Assert.Equal(firstOrders, confirmedOrderCount fundId)

    [<Fact>]
    member _.``insufficient cash marks the period without placing any order``() =
        let fundId = createFund 1.00m
        let code = seedInstrument ()
        let planId = createPlanWithAnchor fundId code 100.00m Weekly
        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14))

        let status, response = advance fundId "{\"endDate\":\"2026-09-14\"}"

        Assert.Equal(200, status)
        Assert.Contains("\"status\":\"insufficient_cash\"", response)
        Assert.Contains("\"orderId\":null", response)
        Assert.Equal(1L, executionCount fundId)
        Assert.Equal(0L, confirmedOrderCount fundId)
        Assert.Equal(1.00m, availableCash fundId)

        // rerun keeps the failed marker and never debits cash
        let rerunStatus, rerunResponse = advance fundId "{\"endDate\":\"2026-09-14\"}"
        Assert.Equal(200, rerunStatus)
        Assert.Equal(response, rerunResponse)
        Assert.Equal(1.00m, availableCash fundId)

    [<Fact>]
    member _.``pending nav executions are retried once the nav lands``() =
        let fundId = createFund 10000.00m
        let code = seedInstrument ()
        let planId = createPlanWithAnchor fundId code 100.00m Weekly

        let status, response = advance fundId "{\"endDate\":\"2026-09-14\"}"

        Assert.Equal(200, status)
        Assert.Contains("\"status\":\"pending_nav\"", response)

        // frozen cash is held by the submitted order until confirmation
        let frozen = availableCash fundId
        Assert.Equal(9900.00m, frozen)

        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14))

        let retryStatus, retryResponse = advance fundId "{\"endDate\":\"2026-09-14\"}"

        Assert.Equal(200, retryStatus)
        Assert.Contains("\"status\":\"succeeded\"", retryResponse)
        // freeze happened on first drive; confirmation converts frozen cash to cost, no extra flow
        Assert.Equal(9900.00m, availableCash fundId)
        Assert.Equal(1L, executionCount fundId)

    [<Fact>]
    member _.``redemption frozen shares do not bypass the cash check``() =
        let fundId = createFund 10000.00m
        let code = seedInstrument ()
        let planId = createPlanWithAnchor fundId code 100.00m Weekly

        // drain available cash through a subscription freeze, mirroring held positions
        let body = sprintf "{\"fundCode\":\"%s\",\"amount\":\"10000.00\",\"feeAmount\":\"0.00\"}" code

        PersistenceTestHelpers.invoke
            (app ())
            "POST"
            (sprintf "/api/funds/%O/orders" fundId)
            [
                "Authorization", "Bearer test-token"
                "Idempotency-Key", fixture.Key(sprintf "hold-%s" (Guid.NewGuid().ToString("N")))
            ]
            body
        |> fun (status, _) -> Assert.Equal(201, status)

        insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14))

        let status, response = advance fundId "{\"endDate\":\"2026-09-14\"}"

        Assert.Equal(200, status)
        Assert.Contains("\"status\":\"insufficient_cash\"", response)
        Assert.Equal(0L, confirmedOrderCount fundId)
        Assert.Equal(0.00m, availableCash fundId)