summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 00:56:35 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 00:56:35 +0800
commit70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (patch)
treec4b8ce56b1867d8b4c744422fef3e965eb2aadda
parent0597ac74757e223c17a6477ff49d69a559168279 (diff)
downloadfund-lab-70e1ad4fc8ba248f835d415faade8a91ac3dc1f8.tar.gz
Add real NAV series multi-day returns (3d-13)
-rw-r--r--qa/driver/browser-test.js62
-rw-r--r--src/FundLab.Api/App.fs75
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj1
-rw-r--r--src/FundLab.Api/NavSeriesProbe.fs76
-rw-r--r--src/FundLab.Api/Program.fs7
-rw-r--r--src/FundLab.Web/App.fs205
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css12
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/NavDateProbeTests.fs6
-rw-r--r--tests/FundLab.Api.Tests/NavSeriesProbeTests.fs237
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs40
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 ())