summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
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 /src/FundLab.Api
parent0597ac74757e223c17a6477ff49d69a559168279 (diff)
downloadfund-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.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
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