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/FundLab.Api | |
| parent | 0597ac74757e223c17a6477ff49d69a559168279 (diff) | |
| download | fund-lab-70e1ad4fc8ba248f835d415faade8a91ac3dc1f8.tar.gz | |
Add real NAV series multi-day returns (3d-13)
Diffstat (limited to 'src/FundLab.Api')
| -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 |
4 files changed, 149 insertions, 10 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 |
