diff options
| -rw-r--r-- | qa/driver/browser-test.js | 62 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 75 | ||||
| -rw-r--r-- | src/FundLab.Api/FundLab.Api.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Api/NavSeriesProbe.fs | 76 | ||||
| -rw-r--r-- | src/FundLab.Api/Program.fs | 7 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 205 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 12 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/NavDateProbeTests.fs | 6 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/NavSeriesProbeTests.fs | 237 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 40 |
12 files changed, 714 insertions, 12 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 76fa69b..a52fa03 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -33,6 +33,7 @@ let lastRebalancePreviewResponse = null; let lastRebalanceExecuteResponse = null; let lastRebalanceExecutionsResponse = null; let lastNavDatesResponse = null; +let lastNavSeriesResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -177,6 +178,11 @@ async function summaryLabelExists(page, label, name) { lastNavDatesResponse = await r.json(); } catch {} } + if (r.url().includes("/market/nav-series") && r.request().method() === "GET" && r.status() < 400) { + try { + lastNavSeriesResponse = await r.json(); + } catch {} + } if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } @@ -225,6 +231,7 @@ async function summaryLabelExists(page, label, name) { await dividendScenario(page); await returnsScenario(page); await navDatesScenario(page); + await navSeriesScenario(page); await plansScenario(page); await rebalanceScenario(page); } finally { @@ -562,6 +569,61 @@ async function navDatesScenario(page) { await page.screenshot({ path: SHOTS + "/19-nav-dates.png" }); } +async function navSeriesScenario(page) { + // R2: 真实 AKShare 多日净值序列曲线(多日点、不补零;不可用 503 + 空态)。 + await page.waitForSelector(".returns-panel .returns-nav-series", { timeout: 10000 }); + + const token = process.env.QA_TOKEN || "qa-token"; + + for (let i = 0; i < 200 && lastNavSeriesResponse === null; i++) { + await page.waitForTimeout(100); + } + + const series = lastNavSeriesResponse || {}; + const points = Array.isArray(series.points) ? series.points : []; + const positive = points.length > 0 && points.every((point) => Number.parseFloat(point.nav) > 0); + const markers = await page.locator(".returns-nav-series .nav-series-marker").count(); + const declaredPoints = await page.getAttribute(".returns-nav-series", "data-nav-series-points"); + const emptyExists = (await page.locator(".returns-nav-series-empty").count()) === 1; + const emptyText = ((await page.textContent(".returns-nav-series-empty")) || "").trim(); + + check( + "R2a 序列可用时曲线显示多日净值且不补零", + series.code === "000001" && + points.length >= 2 && + positive && + points[0].navDate === "2026-09-21" && + Number.parseInt(declaredPoints, 10) >= 2 && + markers >= 2 && + emptyExists && + emptyText === "暂无真实净值数据", + `code=${series.code} points=${JSON.stringify(points).slice(0, 200)} declared=${declaredPoints} markers=${markers}` + ); + + const failure = await page.evaluate(async (token) => { + const response = await fetch("/api/market/nav-series?code=999999", { + headers: { Authorization: `Bearer ${token}` }, + }); + let body = null; + try { + body = await response.json(); + } catch {} + return { status: response.status, body }; + }, token); + + check( + "R2b 序列不可用时返回 503 且保留空态", + failure.status === 503 && + failure.body && + failure.body.error === "MARKET_DATA_UNAVAILABLE" && + emptyExists && + emptyText === "暂无真实净值数据", + `status=${failure.status} body=${JSON.stringify(failure.body).slice(0, 160)}` + ); + + await page.screenshot({ path: SHOTS + "/20-nav-series.png" }); +} + async function postInvestmentPlanViaApi(page, fundId, token) { return page.evaluate( async ({ fundId, token }) => { diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 370f503..e359af9 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -337,6 +337,27 @@ type MarketNavDatesApiResponse = dates: string list } +type MarketNavSeriesPointApiResponse = + { + navDate: string + nav: string + accumulatedNav: string option + } + +type MarketNavSeriesApiResponse = + { + code: string + points: MarketNavSeriesPointApiResponse list + } + +/// The AKShare-backed probes the market endpoints depend on. Injected together +/// so the app stays repository-only unless a caller really wants market routes. +type MarketProbes = + { + NavDates: INavDateProbe + NavSeries: INavSeriesProbe + } + module App = let addOptionFriendlyJson (services: IServiceCollection) = let settings = @@ -1649,13 +1670,49 @@ module App = json ({ code = code.Trim(); dates = dates |> List.map dateText } : MarketNavDatesApiResponse) next ctx | Error failure -> marketDataError failure next ctx - let private navDateProbeRoutes (probe: INavDateProbe) = - [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probe ] + let private marketNavSeriesPointResponse (point: NavSeriesPoint) : MarketNavSeriesPointApiResponse = + { + navDate = dateText point.NavDate + nav = decimalText point.Nav + accumulatedNav = point.AccumulatedNav |> Option.map decimalText + } + + let private getMarketNavSeries (probe: INavSeriesProbe) : HttpHandler = + fun next ctx -> + let code = ctx.Request.Query["code"].ToString() + let limitText = ctx.Request.Query["limit"].ToString() + + let limit = + if String.IsNullOrWhiteSpace limitText then + Ok 30 + else + match Int32.TryParse(limitText, NumberStyles.Integer, invariant) with + | true, value when value >= 1 && value <= 250 -> Ok value + | _ -> Error "limit must be an integer between 1 and 250" + + match limit with + | Error message -> marketDataError (InvalidMarketDataRequest message) next ctx + | Ok value -> + match probe.RecentNavSeries(code, value, ctx.RequestAborted) with + | Ok points -> + json + ({ code = code.Trim() + points = points |> List.map marketNavSeriesPointResponse } + : MarketNavSeriesApiResponse) + next + ctx + | Error failure -> marketDataError failure next ctx + + let private marketProbeRoutes (probes: MarketProbes) = + [ + GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates + GET >=> route "/market/nav-series" >=> getMarketNavSeries probes.NavSeries + ] let private createApplicationInternal (repository: FundRepository) (marketData: IMarketDataService option) - (navDateProbe: INavDateProbe option) + (probes: MarketProbes option) : HttpHandler = let apiRoutes = [ @@ -1687,7 +1744,7 @@ module App = GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) - @ (navDateProbe |> Option.map navDateProbeRoutes |> Option.defaultValue []) + @ (probes |> Option.map marketProbeRoutes |> Option.defaultValue []) choose [ GET >=> route "/health" >=> health @@ -1701,15 +1758,15 @@ module App = let createApplicationWithMarketData (repository: FundRepository) (marketData: IMarketDataService) : HttpHandler = createApplicationInternal repository (Some marketData) None - let createApplicationWithNavDateProbe (repository: FundRepository) (probe: INavDateProbe) : HttpHandler = - createApplicationInternal repository None (Some probe) + let createApplicationWithProbes (repository: FundRepository) (probes: MarketProbes) : HttpHandler = + createApplicationInternal repository None (Some probes) - let createApplicationWithMarketDataAndProbe + let createApplicationWithMarketDataAndProbes (repository: FundRepository) (marketData: IMarketDataService) - (probe: INavDateProbe) + (probes: MarketProbes) : HttpHandler = - createApplicationInternal repository (Some marketData) (Some probe) + createApplicationInternal repository (Some marketData) (Some probes) let createApplication (repository: FundRepository) : HttpHandler = createApplicationInternal repository None None diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj index ed2c637..11df882 100644 --- a/src/FundLab.Api/FundLab.Api.fsproj +++ b/src/FundLab.Api/FundLab.Api.fsproj @@ -19,6 +19,7 @@ <Compile Include="Persistence.fs" /> <Compile Include="MarketDataService.fs" /> <Compile Include="NavDateProbe.fs" /> + <Compile Include="NavSeriesProbe.fs" /> <Compile Include="App.fs" /> <Compile Include="Program.fs" /> </ItemGroup> diff --git a/src/FundLab.Api/NavSeriesProbe.fs b/src/FundLab.Api/NavSeriesProbe.fs new file mode 100644 index 0000000..7c7a437 --- /dev/null +++ b/src/FundLab.Api/NavSeriesProbe.fs @@ -0,0 +1,76 @@ +namespace FundLab.Api + +open System +open System.Collections.Concurrent +open System.Globalization +open System.Threading + +/// One point of a fund's real net-value series. +type NavSeriesPoint = + { + NavDate: DateOnly + Nav: decimal + AccumulatedNav: decimal option + } + +/// Read-only probe that returns the most recent NAV points for a fund code. +/// Like INavDateProbe there is no synthetic fallback: a failure is surfaced as a +/// MarketDataFailure so the API answers 503 with the underlying reason. +type INavSeriesProbe = + abstract RecentNavSeries: code: string * limit: int * CancellationToken -> Result<NavSeriesPoint list, MarketDataFailure> + +/// AKShare-backed series probe. Successful lookups are memoised per code and +/// calendar day (the F# equivalent of functools cache keyed on code + today), so +/// the UI can re-read the curve during one day without re-spawning the collector. +/// Failures are never cached. +type AkshareNavSeriesProbe(collector: IMarketDataCollector) = + let cache = ConcurrentDictionary<string, NavSeriesPoint list>(StringComparer.Ordinal) + + let isFundCode (value: string) = + not (String.IsNullOrWhiteSpace value) + && value.Length = 6 + && value |> Seq.forall Char.IsDigit + + let clip limit = + if limit < 1 then 1 + elif limit > 250 then 250 + else limit + + let todayText () = + (ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + + member _.Clear() = cache.Clear() + + interface INavSeriesProbe with + member _.RecentNavSeries(code, limit, token) = + let normalized = if isNull code then "" else code.Trim() + + if not (isFundCode normalized) then + Error(InvalidMarketDataRequest "fund code must contain exactly six digits") + else + let bounded = clip limit + let cacheKey = sprintf "%s|%s" normalized (todayText ()) + + match cache.TryGetValue cacheKey with + | true, cached -> Ok(cached |> List.truncate bounded) + | _ -> + match collector.FetchNav(normalized, token) with + | Error message -> Error(MarketDataCollectorUnavailable message) + | Ok json -> + match MarketData.parseNavPayload json with + | Error message -> Error(InvalidMarketDataPayload message) + | Ok payload -> + let points = + payload.Observations + |> List.filter (fun observation -> observation.Nav > 0m) + |> List.map (fun observation -> + { + NavDate = observation.NavDate + Nav = observation.Nav + AccumulatedNav = observation.AccumulatedNav + }) + |> List.distinctBy (fun point -> point.NavDate) + |> List.sortByDescending (fun point -> point.NavDate) + + cache.[cacheKey] <- points + Ok(points |> List.truncate bounded) diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs index ba5936e..fa58219 100644 --- a/src/FundLab.Api/Program.fs +++ b/src/FundLab.Api/Program.fs @@ -20,8 +20,13 @@ let main argv = let collector = ProcessMarketDataCollector.FromEnvironment() :> IMarketDataCollector let marketData = MarketDataService(repository, collector) :> IMarketDataService let navDateProbe = AkshareNavDateProbe(collector) :> INavDateProbe + let navSeriesProbe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe + + let probes = + { NavDates = navDateProbe + NavSeries = navSeriesProbe } let app = builder.Build() - app.UseGiraffe(App.createApplicationWithMarketDataAndProbe repository marketData navDateProbe) + app.UseGiraffe(App.createApplicationWithMarketDataAndProbes repository marketData probes) app.Run() 0 diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 44130de..1552e15 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -379,6 +379,19 @@ type RawMarketNavDates = dates: string array } +type RawMarketNavSeriesPoint = + { + navDate: string + nav: string + accumulatedNav: obj + } + +type RawMarketNavSeries = + { + code: string + points: RawMarketNavSeriesPoint array + } + type CreateAttempt = { idempotencyKey: string @@ -672,6 +685,13 @@ type FundReturns = points: ReturnsPoint list } +type MarketNavSeriesPoint = + { + navDate: string + nav: string + accumulatedNav: string option + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -718,6 +738,9 @@ module Api = [<Import("getMarketNavDates", "./src/api.js")>] let getMarketNavDates (token: string) (code: string) : JS.Promise<RawMarketNavDates> = jsNative + [<Import("getMarketNavSeries", "./src/api.js")>] + let getMarketNavSeries (token: string) (code: string) : JS.Promise<RawMarketNavSeries> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -983,6 +1006,13 @@ module Api = points = raw.points |> Array.map decodeReturnsPoint |> Array.toList } + let decodeNavSeriesPoint (raw: RawMarketNavSeriesPoint) : MarketNavSeriesPoint = + { + navDate = raw.navDate + nav = raw.nav + accumulatedNav = decodeOptionalText raw.accumulatedNav + } + type Model = { token: string @@ -1062,6 +1092,10 @@ type Model = navDatesInFlight: bool navDates: string list navDatesAvailable: bool + navSeriesReadSeq: int + navSeriesInFlight: bool + navSeries: MarketNavSeriesPoint list + navSeriesAvailable: bool returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1159,6 +1193,9 @@ type Msg = | NavDatesReadRequested | NavDatesReadCompleted of requestId: int * dates: RawMarketNavDates | NavDatesReadFailed of requestId: int * message: string + | NavSeriesReadRequested + | NavSeriesReadCompleted of requestId: int * series: RawMarketNavSeries + | NavSeriesReadFailed of requestId: int * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1274,6 +1311,10 @@ let init () = navDatesInFlight = false navDates = [] navDatesAvailable = false + navSeriesReadSeq = 0 + navSeriesInFlight = false + navSeries = [] + navSeriesAvailable = false returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -1451,6 +1492,13 @@ let private readNavDatesCommand token code requestId = (fun dates -> NavDatesReadCompleted(requestId, dates)) (fun error -> NavDatesReadFailed(requestId, errorText error)) +let private readNavSeriesCommand token code requestId = + Cmd.OfPromise.either + (fun () -> Api.getMarketNavSeries token code) + () + (fun series -> NavSeriesReadCompleted(requestId, series)) + (fun error -> NavSeriesReadFailed(requestId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -1546,6 +1594,10 @@ let update message model = navDatesInFlight = false navDates = [] navDatesAvailable = false + navSeriesReadSeq = model.navSeriesReadSeq + 1 + navSeriesInFlight = false + navSeries = [] + navSeriesAvailable = false returnsSelectedDate = None planReadSeq = model.planReadSeq + 1 planInFlight = false @@ -1760,6 +1812,10 @@ let update message model = navDatesInFlight = false navDates = [] navDatesAvailable = false + navSeriesReadSeq = model.navSeriesReadSeq + 1 + navSeriesInFlight = false + navSeries = [] + navSeriesAvailable = false returnsSelectedDate = None planReadSeq = model.planReadSeq + 1 planInFlight = false @@ -2539,7 +2595,12 @@ let update message model = returnsInFlight = true error = None }, - Cmd.batch [ readReturnsCommand model.token fund.id requestId; Cmd.ofMsg NavDatesReadRequested ] + Cmd.batch + [ + readReturnsCommand model.token fund.id requestId + Cmd.ofMsg NavDatesReadRequested + Cmd.ofMsg NavSeriesReadRequested + ] | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | ReturnsReadCompleted (requestId, returns) -> @@ -2611,6 +2672,59 @@ let update message model = Cmd.none else model, Cmd.none + | NavSeriesReadRequested -> + match model.createdFund with + | Some _ when String.IsNullOrWhiteSpace model.token -> + { model with error = Some "请输入 API token" }, Cmd.none + | Some _ -> + let heldCode = + model.positions + |> Option.bind (fun positions -> positions.positions |> List.tryHead) + |> Option.map (fun position -> position.instrumentCode) + + match heldCode with + | Some code -> + let requestId = model.navSeriesReadSeq + 1 + + { + model with + navSeriesReadSeq = requestId + navSeriesInFlight = true + }, + readNavSeriesCommand model.token code requestId + | None -> + { + model with + navSeries = [] + navSeriesAvailable = false + navSeriesInFlight = false + }, + Cmd.none + | None -> model, Cmd.none + | NavSeriesReadCompleted (requestId, series) -> + if requestId = model.navSeriesReadSeq then + let resolved = series.points |> Array.toList |> List.map Api.decodeNavSeriesPoint + + { + model with + navSeries = resolved + navSeriesAvailable = not (List.isEmpty resolved) + navSeriesInFlight = false + }, + Cmd.none + else + model, Cmd.none + | NavSeriesReadFailed (requestId, _) -> + if requestId = model.navSeriesReadSeq then + { + model with + navSeries = [] + navSeriesAvailable = false + navSeriesInFlight = false + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -3983,6 +4097,90 @@ let private returnsChartView (points: ReturnsPoint list) = ] ] +let private marketNavSeriesView (points: MarketNavSeriesPoint list) = + let ordered = points |> List.sortBy (fun point -> point.navDate) + let dates = ordered |> List.map (fun point -> point.navDate) + let navSeries = ordered |> List.map (fun point -> point.navDate, point.nav) + let navPoints = Chart.seriesPoints dates navSeries + + let empty = + Html.div [ + prop.className "chart-empty returns-nav-series-empty" + prop.hidden (not (List.isEmpty navPoints)) + prop.text "暂无真实净值数据" + ] + + let chart = + if List.isEmpty navPoints then + Html.none + else + let width = 960.0 + let height = 320.0 + let padding = 28.0 + let chartWidth = width - padding * 2.0 + let chartHeight = height - padding * 2.0 + + let coordinates (series: SeriesPoint list) = + series + |> List.map (fun point -> padding + point.x * chartWidth, padding + (1.0 - point.y) * chartHeight) + + let gridLines = + [ 0 .. 4 ] + |> List.map (fun index -> + let y = padding + float index / 4.0 * chartHeight + + Svg.line [ + svg.x1 padding + svg.y1 y + svg.x2 (width - padding) + svg.y2 y + svg.stroke "#e2e8f0" + svg.strokeWidth 1 + ]) + + Html.div [ + prop.className "chart-wrap returns-nav-series-chart" + prop.children [ + Svg.svg [ + svg.className "nav-chart" + svg.viewBox (0, 0, 960, 320) + svg.children [ + yield! gridLines + yield + Svg.polyline [ + svg.points (coordinates navPoints) + svg.fill "none" + svg.stroke "#f59e0b" + svg.strokeWidth 3 + ] + yield! + (coordinates navPoints + |> List.map (fun (x, y) -> + Svg.circle [ + svg.className "nav-series-marker" + svg.cx x + svg.cy y + svg.r 3.5 + svg.fill "#f59e0b" + ])) + ] + ] + Html.div [ + prop.className "chart-axis" + prop.children [ + Html.span [ prop.text (ordered |> List.head |> fun point -> point.navDate) ] + Html.span [ prop.text (ordered |> List.last |> fun point -> point.navDate) ] + ] + ] + ] + ] + + Html.div [ + prop.className "returns-nav-series" + prop.custom ("data-nav-series-points", string (List.length navPoints)) + prop.children [ empty; chart ] + ] + let private returnsPanel model dispatch = let visiblePoints = match model.returns with @@ -4083,6 +4281,11 @@ let private returnsPanel model dispatch = prop.className "returns-note" prop.text "蓝线 总资产 / 橙线 单位净值(各自归一化);缺少净值的数据点不连线,也不补零。" ] + Html.p [ + prop.className "returns-subheading" + prop.text "持仓基金真实净值序列(AKShare 多日)" + ] + marketNavSeriesView model.navSeries if returns.pending then Html.p [ prop.className "returns-pending" diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 80ded41..c94a9e0 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -131,6 +131,10 @@ export function getMarketNavDates(token, code) { return requestJson(`/api/market/nav-dates?code=${encodeURIComponent(code)}`, token); } +export function getMarketNavSeries(token, code) { + return requestJson(`/api/market/nav-series?code=${encodeURIComponent(code)}&limit=30`, token); +} + export function createSipPlan(token, fundId, payload) { const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, { diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index 4cdf165..b994a80 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -566,6 +566,18 @@ h2 { font-size: 12px; } +.returns-subheading { + margin: 14px 0 6px; + font-size: 12px; + font-weight: 600; + color: #334155; +} + +.returns-nav-series-empty { + color: #b45309; + font-size: 12px; +} + .returns-pending { color: #b45309; font-size: 12px; diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index a77e503..07db95f 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -30,6 +30,7 @@ <Compile Include="InvestmentPlanTests.fs" /> <Compile Include="RebalanceTests.fs" /> <Compile Include="NavDateProbeTests.fs" /> + <Compile Include="NavSeriesProbeTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs index 0f7e506..6197950 100644 --- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs +++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs @@ -71,8 +71,12 @@ type RealAkshareFactAttribute() = type NavDateProbeTests(fixture: PostgresFixture) = let repository () = FundRepository(fixture.ConnectionString) + let stubSeriesProbe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = Ok [] } + let appWithProbe (probe: INavDateProbe) = - App.createApplicationWithNavDateProbe (repository ()) probe + App.createApplicationWithProbes (repository ()) { NavDates = probe; NavSeries = stubSeriesProbe } [<Fact>] member _.``probe parses, dedupes, sorts descending and truncates``() = diff --git a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs new file mode 100644 index 0000000..14fd8e8 --- /dev/null +++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs @@ -0,0 +1,237 @@ +namespace FundLab.Api.Tests + +open System +open System.Threading +open Xunit +open FundLab.Api + +module NavSeriesProbeSupport = + let payload (code: string) (observations: (string * string * string option) list) = + let body = + observations + |> List.map (fun (date, nav, accumulated) -> + let accumulatedText = + match accumulated with + | Some value -> sprintf "\"%s\"" value + | None -> "null" + + sprintf + "{\"nav_date\":\"%s\",\"published_at\":null,\"nav\":\"%s\",\"accumulated_nav\":%s,\"daily_return\":null}" + date + nav + accumulatedText) + |> String.concat "," + + sprintf + "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"nav\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"observations\":[%s]}" + code + body + +[<Collection("postgres")>] +type NavSeriesProbeTests(fixture: PostgresFixture) = + let repository () = FundRepository(fixture.ConnectionString) + + let stubDateProbe = + { new INavDateProbe with + member _.RecentNavDates(_, _, _) = Ok [] } + + let appWithSeries (probe: INavSeriesProbe) = + App.createApplicationWithProbes (repository ()) { NavDates = stubDateProbe; NavSeries = probe } + + [<Fact>] + member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() = + let json = + NavSeriesProbeSupport.payload + "000001" + [ + "2026-09-16", "1.1205", Some "2.5000" + "2026-09-21", "1.1902", None + "2026-09-16", "1.1205", Some "2.5000" + "2026-09-18", "1.1500", Some "2.5300" + "2026-09-17", "0.0000", None + "2026-09-15", "1.1000", Some "2.4800" + ] + + let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok json)) :> INavSeriesProbe + + match probe.RecentNavSeries("000001", 4, CancellationToken.None) with + | Ok points -> + Assert.Equal<DateOnly list>( + [ DateOnly(2026, 9, 21); DateOnly(2026, 9, 18); DateOnly(2026, 9, 16); DateOnly(2026, 9, 15) ], + points |> List.map (fun point -> point.NavDate) + ) + + Assert.Equal(4, List.length points) + Assert.Equal<decimal option>(Some 2.53m, (points |> List.find (fun point -> point.NavDate = DateOnly(2026, 9, 18))).AccumulatedNav) + Assert.Equal<decimal option>(None, (points |> List.head).AccumulatedNav) + | Error failure -> failwithf "expected NAV series, got %A" failure + + [<Fact>] + member _.``series memoises successful lookups per code and day``() = + let mutable calls = 0 + + let collector = + NavDateProbeSupport.collectorReturning (fun () -> + calls <- calls + 1 + Ok(NavSeriesProbeSupport.payload "000001" [ "2026-09-21", "1.1902", None ])) + + let probe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe + let first = probe.RecentNavSeries("000001", 30, CancellationToken.None) + let second = probe.RecentNavSeries("000001", 30, CancellationToken.None) + + Assert.Equal(1, calls) + Assert.Equal(first |> Result.map (List.map (fun point -> point.NavDate)), second |> Result.map (List.map (fun point -> point.NavDate))) + + [<Fact>] + member _.``series returns an empty list when no usable nav remains``() = + let json = NavSeriesProbeSupport.payload "000001" [ "2026-09-21", "0.0000", None; "2026-09-18", "0.0000", None ] + let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok json)) :> INavSeriesProbe + + match probe.RecentNavSeries("000001", 30, CancellationToken.None) with + | Ok points -> Assert.Empty points + | Error failure -> failwithf "expected empty NAV series, got %A" failure + + [<Fact>] + member _.``series rejects a malformed fund code without touching the collector``() = + let probe = + AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) + :> INavSeriesProbe + + match probe.RecentNavSeries("abc", 30, CancellationToken.None) with + | Error (InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) + | other -> failwithf "expected InvalidMarketDataRequest, got %A" other + + [<Fact>] + member _.``series maps collector failures to an unavailable reason``() = + let probe = + AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Error "collector exited with code 2")) + :> INavSeriesProbe + + match probe.RecentNavSeries("000001", 30, CancellationToken.None) with + | Error (MarketDataCollectorUnavailable message) -> Assert.Contains("code 2", message) + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + [<Fact>] + member _.``series maps malformed payloads to an invalid payload reason``() = + let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> INavSeriesProbe + + match probe.RecentNavSeries("000001", 30, CancellationToken.None) with + | Error (InvalidMarketDataPayload _) -> () + | other -> failwithf "expected InvalidMarketDataPayload, got %A" other + + [<Fact>] + member _.``market nav-series endpoint returns the probe points``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(code, limit, _) = + Assert.Equal("000001", code) + Assert.Equal(30, limit) + + Ok + [ + { NavDate = DateOnly(2026, 9, 21); Nav = 1.1902m; AccumulatedNav = None } + { NavDate = DateOnly(2026, 9, 18); Nav = 1.15m; AccumulatedNav = Some 2.53m } + ] } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"code\":\"000001\"", body) + Assert.Contains("\"navDate\":\"2026-09-21\"", body) + Assert.Contains("1.1902", body) + Assert.Contains("\"navDate\":\"2026-09-18\"", body) + Assert.Contains("2.53", body) + + [<Fact>] + member _.``market nav-series endpoint passes the requested limit``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, limit, _) = + Assert.Equal(3, limit) + Ok [ { NavDate = DateOnly(2026, 9, 21); Nav = 1.1902m; AccumulatedNav = None } ] } + + let status, _ = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001&limit=3" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + + [<Fact>] + member _.``market nav-series endpoint returns an empty list for no usable points``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = Ok [] } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"points\":[]", body) + + [<Fact>] + member _.``market nav-series endpoint answers 503 when the probe fails``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = Error(MarketDataCollectorUnavailable "collector exited with code 2") } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + Assert.Contains("code 2", body) + + [<Fact>] + member _.``market nav-series endpoint rejects a malformed code with 400``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = + Error(InvalidMarketDataRequest "fund code must contain exactly six digits") } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=abc" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) + + [<Fact>] + member _.``market nav-series endpoint rejects a malformed limit with 400``() = + let probe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = failwith "probe should not be called for an invalid limit" } + + let status, body = + PersistenceTestHelpers.invoke + (appWithSeries probe) + "GET" + "/api/market/nav-series?code=000001&limit=0" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("limit must be an integer between 1 and 250", body) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 2435f32..6b4eef8 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -419,6 +419,46 @@ module ConfirmationBoundaryTests = Assert.Equal(None, Api.decodeOptionalText (box { case = "None"; fields = [||] })) [<Fact>] + let ``nav series decodes points with optional accumulated nav`` () = + let raw : RawMarketNavSeriesPoint array = + [| + { navDate = "2026-09-21"; nav = "1.19020000"; accumulatedNav = null } + { navDate = "2026-09-18"; nav = "1.15000000"; accumulatedNav = someText "2.53000000" } + |] + + let decoded = raw |> Array.map Api.decodeNavSeriesPoint |> Array.toList + + Assert.Equal(2, decoded.Length) + Assert.Equal("2026-09-21", decoded[0].navDate) + Assert.Equal("1.19020000", decoded[0].nav) + Assert.Equal(None, decoded[0].accumulatedNav) + Assert.Equal(Some "2.53000000", decoded[1].accumulatedNav) + + [<Fact>] + let ``nav series read completion stores points and flags availability`` () = + let model = init () + let raw = { code = "000001"; points = [| { navDate = "2026-09-21"; nav = "1.1902"; accumulatedNav = null } |] } + let updated, _ = update (NavSeriesReadCompleted(model.navSeriesReadSeq, raw)) model + + Assert.Equal(1, updated.navSeries.Length) + Assert.Equal("1.1902", updated.navSeries[0].nav) + Assert.True(updated.navSeriesAvailable) + + [<Fact>] + let ``nav series read failure clears the chart without a zero fill`` () = + let model = + { init () with + navSeries = [ { navDate = "2026-09-21"; nav = "1.1902"; accumulatedNav = None } ] + navSeriesAvailable = true + navSeriesInFlight = true } + + let updated, _ = update (NavSeriesReadFailed(model.navSeriesReadSeq, "collector exited with code 2")) model + + Assert.Empty updated.navSeries + Assert.False(updated.navSeriesAvailable) + Assert.False(updated.navSeriesInFlight) + + [<Fact>] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) let pending = Api.decodeOrder (samplePendingRaw ()) |
