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
|
namespace FundLab.Api.Tests
open System
open System.Text.Json
open Xunit
open FundLab.Api
open FundLab.Domain
[<Collection("postgres")>]
type InvestmentPlanTests(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, "investment-plan-test-hash")
code
let createFund (initialCash: decimal) =
let command =
{
Name = "定投测试 FOF"
InitialCash = initialCash
InitialUnitNav = 1.00000000m
IsSynthetic = true
}
let key = fixture.Key(sprintf "investment-plan-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 "investment-plan-hash/%s" revision)
let createPlanViaApi fundId (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/investment-plans" fundId)
[
"Authorization", "Bearer test-token"
"Idempotency-Key", fixture.Key(sprintf "investment-plan-%s" (Guid.NewGuid().ToString("N")))
]
body
let createPlanWithKey fundId (code: string) (amount: string) (frequency: string) idempotencyKey =
let body =
sprintf "{\"instrumentCode\":\"%s\",\"amount\":\"%s\",\"frequency\":\"%s\"}" code amount frequency
PersistenceTestHelpers.invoke
(app ())
"POST"
(sprintf "/api/funds/%O/investment-plans" fundId)
[ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
body
let listPlansViaApi fundId =
PersistenceTestHelpers.invoke
(app ())
"GET"
(sprintf "/api/funds/%O/investment-plans" fundId)
[ "Authorization", "Bearer test-token" ]
""
let runPlansViaApi fundId =
PersistenceTestHelpers.invoke
(app ())
"POST"
(sprintf "/api/funds/%O/investment-plans/run" fundId)
[ "Authorization", "Bearer test-token" ]
""
let runPlansOnViaApi fundId (processingDate: DateOnly) =
PersistenceTestHelpers.invoke
(app ())
"POST"
(sprintf "/api/funds/%O/investment-plans/run" fundId)
[ "Authorization", "Bearer test-token" ]
(sprintf "{\"processingDate\":\"%s\"}" (processingDate.ToString("yyyy-MM-dd")))
let createPlanWithAnchor (fundId: Guid) (code: string) (amount: decimal) (frequency: InvestmentFrequency) (anchor: DateOnly) =
let key = fixture.Key(sprintf "investment-plan-anchor-%s" (Guid.NewGuid().ToString("N")))
match repository().CreateInvestmentPlan(key, fundId, { InstrumentCode = code; Amount = amount; Frequency = frequency }, anchor) with
| InvestmentPlanWriteResult.InvestmentPlanCreated plan -> plan.Id
| other -> failwithf "unexpected investment plan result: %A" other
let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
let availableCash fundId =
match repository().GetFund fundId with
| Some fund -> fund.AvailableCash
| None -> failwith "fund was not found"
let unitsFor fundId (code: string) =
repository().GetFundPositions fundId
|> List.tryFind (fun position -> position.InstrumentCode = code)
|> Option.map (fun position -> position.Units)
|> Option.defaultValue 0m
let planRuns (body: string) =
use document = JsonDocument.Parse(body)
document.RootElement.GetProperty("plans").EnumerateArray()
|> Seq.collect (fun plan -> plan.GetProperty("runs").EnumerateArray())
|> Seq.map (fun run -> run.Clone())
|> Seq.toList
[<Fact>]
member _.``create is idempotent, lists the plan and rejects an unknown instrument``() =
let fundId = createFund 10000.00m
let code = seedInstrument ()
let key = fixture.Key(sprintf "investment-plan-%s" (Guid.NewGuid().ToString("N")))
let firstStatus, firstBody = createPlanWithKey fundId code "100.00" "daily" key
Assert.Equal(201, firstStatus)
use firstDocument = JsonDocument.Parse(firstBody)
let planId = firstDocument.RootElement.GetProperty("id").GetString()
Assert.Equal(code, firstDocument.RootElement.GetProperty("instrumentCode").GetString())
Assert.Equal("100.00", firstDocument.RootElement.GetProperty("amount").GetString())
Assert.Equal("daily", firstDocument.RootElement.GetProperty("frequency").GetString())
Assert.Equal(today.ToString("yyyy-MM-dd"), firstDocument.RootElement.GetProperty("nextRunDate").GetString())
let replayStatus, replayBody = createPlanWithKey fundId code "100.00" "daily" key
Assert.Equal(200, replayStatus)
use replayDocument = JsonDocument.Parse(replayBody)
Assert.Equal(planId, replayDocument.RootElement.GetProperty("id").GetString())
let listStatus, listBody = listPlansViaApi fundId
Assert.Equal(200, listStatus)
Assert.Contains(planId, listBody)
let missingStatus, missingBody = createPlanViaApi fundId "999999" "100.00" "daily"
Assert.Equal(404, missingStatus)
Assert.Contains("INSTRUMENT_NOT_FOUND", missingBody)
[<Fact>]
member _.``create rejects a bad amount and a bad frequency``() =
let fundId = createFund 10000.00m
let code = seedInstrument ()
let badAmountStatus, badAmountBody = createPlanViaApi fundId code "0.00" "daily"
Assert.Equal(400, badAmountStatus)
Assert.Contains("INVALID_INVESTMENT_PLAN_REQUEST", badAmountBody)
let badFrequencyStatus, badFrequencyBody = createPlanViaApi fundId code "100.00" "hourly"
Assert.Equal(400, badFrequencyStatus)
Assert.Contains("frequency must be one of daily, weekly or monthly", badFrequencyBody)
[<Fact>]
member _.``run executes a due plan through the shared order pipeline``() =
let fundId = createFund 10000.00m
let code = seedInstrument ()
insertQuoteOnDate code 2.0m today
let _ = createPlanViaApi fundId code "100.00" "daily"
let status, body = runPlansViaApi fundId
Assert.Equal(200, status)
let runs = planRuns body
Assert.Single(runs) |> ignore
Assert.Equal("succeeded", runs.Head.GetProperty("status").GetString())
Assert.Equal(today.ToString("yyyy-MM-dd"), runs.Head.GetProperty("runDate").GetString())
Assert.Equal(50.00000000m, unitsFor fundId code)
Assert.Equal(9900.00m, availableCash fundId)
let returnsStatus, returnsBody =
PersistenceTestHelpers.invoke
(app ())
"GET"
(sprintf "/api/funds/%O/returns" fundId)
[ "Authorization", "Bearer test-token" ]
""
Assert.Equal(200, returnsStatus)
Assert.Contains("\"cumulativeReturn\":\"0.00\"", returnsBody)
[<Fact>]
member _.``run is idempotent across repeated drives``() =
let fundId = createFund 10000.00m
let code = seedInstrument ()
insertQuoteOnDate code 2.0m today
let _ = createPlanViaApi fundId code "100.00" "daily"
let firstStatus, _ = runPlansViaApi fundId
Assert.Equal(200, firstStatus)
let secondStatus, secondBody = runPlansViaApi fundId
Assert.Equal(200, secondStatus)
Assert.Single(planRuns secondBody) |> ignore
Assert.Equal(50.00000000m, unitsFor fundId code)
Assert.Equal(9900.00m, availableCash fundId)
[<Fact>]
member _.``run defers a plan when nav is missing and retries once it lands``() =
let fundId = createFund 10000.00m
let code = seedInstrument ()
let _ = createPlanViaApi fundId code "100.00" "daily"
let pendingStatus, pendingBody = runPlansViaApi fundId
Assert.Equal(200, pendingStatus)
let pendingRuns = planRuns pendingBody
Assert.Single(pendingRuns) |> ignore
Assert.Equal("pending_nav", pendingRuns.Head.GetProperty("status").GetString())
// the pending order freezes the scheduled amount without spending it
Assert.Equal(9900.00m, availableCash fundId)
Assert.Equal(0m, unitsFor fundId code)
insertQuoteOnDate code 2.0m today
let retryStatus, retryBody = runPlansViaApi fundId
Assert.Equal(200, retryStatus)
let retryRuns = planRuns retryBody
Assert.Single(retryRuns) |> ignore
Assert.Equal("succeeded", retryRuns.Head.GetProperty("status").GetString())
Assert.Equal(50.00000000m, unitsFor fundId code)
Assert.Equal(9900.00m, availableCash fundId)
[<Fact>]
member _.``run records insufficient cash without debiting twice``() =
let fundId = createFund 50.00m
let code = seedInstrument ()
insertQuoteOnDate code 2.0m today
let _ = createPlanViaApi fundId code "100.00" "daily"
let firstStatus, firstBody = runPlansViaApi fundId
Assert.Equal(200, firstStatus)
let firstRuns = planRuns firstBody
Assert.Single(firstRuns) |> ignore
Assert.Equal("insufficient_cash", firstRuns.Head.GetProperty("status").GetString())
Assert.Equal(50.00m, availableCash fundId)
Assert.Equal(0m, unitsFor fundId code)
let secondStatus, secondBody = runPlansViaApi fundId
Assert.Equal(200, secondStatus)
Assert.Single(planRuns secondBody) |> ignore
Assert.Equal(50.00m, availableCash fundId)
[<Fact>]
member _.``run rolls the plan pointer across multiple due dates``() =
let fundId = createFund 10000.00m
let code = seedInstrument ()
insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 15))
insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 16))
insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 17))
let _ = createPlanWithAnchor fundId code 100.00m InvestmentFrequency.Daily (DateOnly(2026, 9, 15))
let status, body = runPlansOnViaApi fundId (DateOnly(2026, 9, 17))
Assert.Equal(200, status)
let runs = planRuns body
Assert.Equal(3, runs.Length)
Assert.All(runs, fun run -> Assert.Equal("succeeded", run.GetProperty("status").GetString()))
Assert.Equal(150.00000000m, unitsFor fundId code)
Assert.Equal(9700.00m, availableCash fundId)
use document = JsonDocument.Parse(body)
let nextRunDate =
document.RootElement.GetProperty("plans").EnumerateArray()
|> Seq.head
|> fun plan -> plan.GetProperty("nextRunDate").GetString()
Assert.Equal("2026-09-18", nextRunDate)
[<Fact>]
member _.``run for an unknown fund is not found``() =
let status, body = runPlansViaApi (Guid.NewGuid())
Assert.Equal(404, status)
Assert.Contains("FUND_NOT_FOUND", body)
|