From 70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:56:35 +0800 Subject: Add real NAV series multi-day returns (3d-13) --- src/FundLab.Web/App.fs | 205 ++++++++++++++++++++++++++++++++++++++++- src/FundLab.Web/src/api.js | 4 + src/FundLab.Web/src/styles.css | 12 +++ 3 files changed, 220 insertions(+), 1 deletion(-) (limited to 'src/FundLab.Web') 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 = [] let searchInstruments (token: string) (query: string) : JS.Promise = jsNative @@ -718,6 +738,9 @@ module Api = [] let getMarketNavDates (token: string) (code: string) : JS.Promise = jsNative + [] + let getMarketNavSeries (token: string) (code: string) : JS.Promise = jsNative + [] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise = 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; -- cgit v1.2.3