diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 00:56:35 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 00:56:35 +0800 |
| commit | 70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (patch) | |
| tree | c4b8ce56b1867d8b4c744422fef3e965eb2aadda /src | |
| parent | 0597ac74757e223c17a6477ff49d69a559168279 (diff) | |
| download | fund-lab-70e1ad4fc8ba248f835d415faade8a91ac3dc1f8.tar.gz | |
Add real NAV series multi-day returns (3d-13)
Diffstat (limited to 'src')
| -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 |
7 files changed, 369 insertions, 11 deletions
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; |
