summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 00:19:09 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 00:19:09 +0800
commit0597ac74757e223c17a6477ff49d69a559168279 (patch)
tree3ab8934481bf9aaef89163fd216f15248fe118ac /src/FundLab.Api
parent36ee23a0c406c16e975276238a861299899ec79a (diff)
downloadfund-lab-0597ac74757e223c17a6477ff49d69a559168279.tar.gz
Add real AKShare NAV-date probe slice (3d-12)
Diffstat (limited to 'src/FundLab.Api')
-rw-r--r--src/FundLab.Api/App.fs39
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj5
-rw-r--r--src/FundLab.Api/NavDateProbe.fs57
-rw-r--r--src/FundLab.Api/Persistence.fs20
-rw-r--r--src/FundLab.Api/Program.fs3
5 files changed, 114 insertions, 10 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 34b4f2d..370f503 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -331,6 +331,12 @@ type MarketDataNavApiResponse =
observations: MarketDataObservationApiResponse list
}
+type MarketNavDatesApiResponse =
+ {
+ code: string
+ dates: string list
+ }
+
module App =
let addOptionFriendlyJson (services: IServiceCollection) =
let settings =
@@ -1634,7 +1640,23 @@ module App =
GET >=> routef "/instruments/%s/nav" (getNav marketData)
]
- let private createApplicationInternal (repository: FundRepository) (marketData: IMarketDataService option) : HttpHandler =
+ let private getMarketNavDates (probe: INavDateProbe) : HttpHandler =
+ fun next ctx ->
+ let code = ctx.Request.Query["code"].ToString()
+
+ match probe.RecentNavDates(code, 5, ctx.RequestAborted) with
+ | Ok dates ->
+ 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 createApplicationInternal
+ (repository: FundRepository)
+ (marketData: IMarketDataService option)
+ (navDateProbe: INavDateProbe option)
+ : HttpHandler =
let apiRoutes =
[
GET >=> route "/portfolio/summary" >=> emptyPortfolio
@@ -1665,6 +1687,7 @@ module App =
GET >=> routef "/funds/%s" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])
+ @ (navDateProbe |> Option.map navDateProbeRoutes |> Option.defaultValue [])
choose [
GET >=> route "/health" >=> health
@@ -1676,7 +1699,17 @@ module App =
]
let createApplicationWithMarketData (repository: FundRepository) (marketData: IMarketDataService) : HttpHandler =
- createApplicationInternal repository (Some marketData)
+ createApplicationInternal repository (Some marketData) None
+
+ let createApplicationWithNavDateProbe (repository: FundRepository) (probe: INavDateProbe) : HttpHandler =
+ createApplicationInternal repository None (Some probe)
+
+ let createApplicationWithMarketDataAndProbe
+ (repository: FundRepository)
+ (marketData: IMarketDataService)
+ (probe: INavDateProbe)
+ : HttpHandler =
+ createApplicationInternal repository (Some marketData) (Some probe)
let createApplication (repository: FundRepository) : HttpHandler =
- createApplicationInternal repository None
+ createApplicationInternal repository None None
diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj
index 04724c0..ed2c637 100644
--- a/src/FundLab.Api/FundLab.Api.fsproj
+++ b/src/FundLab.Api/FundLab.Api.fsproj
@@ -17,8 +17,9 @@
<Compile Include="Health.fs" />
<Compile Include="MarketData.fs" />
<Compile Include="Persistence.fs" />
- <Compile Include="MarketDataService.fs" />
- <Compile Include="App.fs" />
+ <Compile Include="MarketDataService.fs" />
+ <Compile Include="NavDateProbe.fs" />
+ <Compile Include="App.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
diff --git a/src/FundLab.Api/NavDateProbe.fs b/src/FundLab.Api/NavDateProbe.fs
new file mode 100644
index 0000000..0437fd8
--- /dev/null
+++ b/src/FundLab.Api/NavDateProbe.fs
@@ -0,0 +1,57 @@
+namespace FundLab.Api
+
+open System
+open System.Collections.Concurrent
+open System.Threading
+
+/// Read-only probe that returns the most recent NAV dates for a fund code.
+///
+/// This is the "real data first" seam for the market NAV-date endpoint: the only
+/// implementation talks to the AKShare collector, and there is deliberately no
+/// synthetic fallback. A failed probe surfaces as a MarketDataFailure so the API
+/// can answer 503 with the underlying reason instead of inventing dates.
+type INavDateProbe =
+ abstract RecentNavDates: code: string * limit: int * CancellationToken -> Result<DateOnly list, MarketDataFailure>
+
+/// AKShare-backed probe. Successful lookups are memoised per fund code
+/// (the F# equivalent of functools.lru_cache) so repeated UI reads during one
+/// process do not re-spawn the collector; failures are never cached.
+type AkshareNavDateProbe(collector: IMarketDataCollector) =
+ let cache = ConcurrentDictionary<string, DateOnly 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 > 60 then 60
+ else limit
+
+ member _.Clear() = cache.Clear()
+
+ interface INavDateProbe with
+ member _.RecentNavDates(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
+ match cache.TryGetValue normalized with
+ | true, cached -> Ok(cached |> List.truncate (clip limit))
+ | _ ->
+ 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 dates =
+ payload.Observations
+ |> List.map (fun observation -> observation.NavDate)
+ |> List.distinct
+ |> List.sortDescending
+
+ cache.[normalized] <- dates
+ Ok(dates |> List.truncate (clip limit))
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index 7a7ab32..6028b7c 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -59,6 +59,18 @@ module ConfirmationPolicy =
let candidate = if local.TimeOfDay >= cutoffTimeOfDay then date.AddDays 1 else date
rollToWeekday candidate
+ /// Calendar date used to bucket events (cash flows, holdings) on the returns
+ /// timeline. Shares the FUND_LAB_TEST_TRADE_DATE anchor with tradeDateFor so a
+ /// pinned suite does not drift when the host crosses midnight; production (no
+ /// pin) is exactly the Shanghai calendar date of the moment.
+ let eventDateFor (moment: DateTimeOffset) : DateOnly =
+ match Environment.GetEnvironmentVariable("FUND_LAB_TEST_TRADE_DATE") with
+ | value when not (String.IsNullOrWhiteSpace value) ->
+ match DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with
+ | true, anchored -> anchored
+ | _ -> shanghaiDate moment
+ | _ -> shanghaiDate moment
+
type NavQuote =
{ NavDate: DateOnly
Nav: decimal
@@ -4867,7 +4879,7 @@ type FundRepository(connectionString: string) =
match this.GetFund fundId with
| None -> None
| Some fund ->
- let today = ConfirmationPolicy.shanghaiDate DateTimeOffset.UtcNow
+ let today = ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow
let deposits = this.GetCapitalDeposits fundId
let orders = this.GetSubscriptionOrders fundId
let redemptions = this.GetRedemptionOrders fundId
@@ -4902,7 +4914,7 @@ type FundRepository(connectionString: string) =
|> List.map (fun observation -> observation.NavDate))
|> Set.ofList
- let creationDate = ConfirmationPolicy.shanghaiDate fund.CreatedAt
+ let creationDate = ConfirmationPolicy.eventDateFor fund.CreatedAt
let dividendCredited =
dividends
@@ -4926,7 +4938,7 @@ type FundRepository(connectionString: string) =
else
let eventDates =
[ yield creationDate
- yield! deposits |> List.map (fun deposit -> ConfirmationPolicy.shanghaiDate deposit.CreatedAt)
+ yield! deposits |> List.map (fun deposit -> ConfirmationPolicy.eventDateFor deposit.CreatedAt)
yield! orders |> List.map (fun order -> order.TradeDate)
yield! redemptions |> List.map (fun order -> order.TradeDate)
yield! dividendCredited |> List.map (fun record -> record.NavDate) ]
@@ -4964,7 +4976,7 @@ type FundRepository(connectionString: string) =
for date in dates do
for deposit in deposits do
- if ConfirmationPolicy.shanghaiDate deposit.CreatedAt = date then
+ if ConfirmationPolicy.eventDateFor deposit.CreatedAt = date then
availableCash <- availableCash + deposit.Amount
cumulativeDeposits <- cumulativeDeposits + deposit.Amount
diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs
index b7d0ef6..ba5936e 100644
--- a/src/FundLab.Api/Program.fs
+++ b/src/FundLab.Api/Program.fs
@@ -19,8 +19,9 @@ let main argv =
repository.EnsureSchema()
let collector = ProcessMarketDataCollector.FromEnvironment() :> IMarketDataCollector
let marketData = MarketDataService(repository, collector) :> IMarketDataService
+ let navDateProbe = AkshareNavDateProbe(collector) :> INavDateProbe
let app = builder.Build()
- app.UseGiraffe(App.createApplicationWithMarketData repository marketData)
+ app.UseGiraffe(App.createApplicationWithMarketDataAndProbe repository marketData navDateProbe)
app.Run()
0