From 0597ac74757e223c17a6477ff49d69a559168279 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:19:09 +0800 Subject: Add real AKShare NAV-date probe slice (3d-12) --- src/FundLab.Api/App.fs | 39 +++++++++- src/FundLab.Api/FundLab.Api.fsproj | 5 +- src/FundLab.Api/NavDateProbe.fs | 57 +++++++++++++++ src/FundLab.Api/Persistence.fs | 20 ++++-- src/FundLab.Api/Program.fs | 3 +- src/FundLab.Web/App.fs | 143 +++++++++++++++++++++++++++++++++++-- src/FundLab.Web/src/api.js | 4 ++ src/FundLab.Web/src/styles.css | 21 ++++++ 8 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 src/FundLab.Api/NavDateProbe.fs (limited to 'src') 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 @@ - - + + + 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 + +/// 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(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 diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 8eb598e..44130de 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -373,6 +373,12 @@ type RawReturns = points: RawReturnsPoint array } +type RawMarketNavDates = + { + code: string + dates: string array + } + type CreateAttempt = { idempotencyKey: string @@ -709,6 +715,9 @@ module Api = [] let getReturns (token: string) (fundId: string) : JS.Promise = jsNative + [] + let getMarketNavDates (token: string) (code: string) : JS.Promise = jsNative + [] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise = jsNative @@ -1049,6 +1058,11 @@ type Model = returnsReadSeq: int returnsInFlight: bool returns: FundReturns option + navDatesReadSeq: int + navDatesInFlight: bool + navDates: string list + navDatesAvailable: bool + returnsSelectedDate: string option planReadSeq: int planInFlight: bool planRunSeq: int @@ -1142,6 +1156,10 @@ type Msg = | ReturnsReadRequested | ReturnsReadCompleted of requestId: int * returns: RawReturns | ReturnsReadFailed of requestId: int * message: string + | NavDatesReadRequested + | NavDatesReadCompleted of requestId: int * dates: RawMarketNavDates + | NavDatesReadFailed of requestId: int * message: string + | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array | InvestmentPlansReadFailed of requestId: int * message: string @@ -1252,6 +1270,11 @@ let init () = returnsReadSeq = 0 returnsInFlight = false returns = None + navDatesReadSeq = 0 + navDatesInFlight = false + navDates = [] + navDatesAvailable = false + returnsSelectedDate = None planReadSeq = 0 planInFlight = false planRunSeq = 0 @@ -1421,6 +1444,13 @@ let private readReturnsCommand token fundId requestId = (fun returns -> ReturnsReadCompleted(requestId, returns)) (fun error -> ReturnsReadFailed(requestId, errorText error)) +let private readNavDatesCommand token code requestId = + Cmd.OfPromise.either + (fun () -> Api.getMarketNavDates token code) + () + (fun dates -> NavDatesReadCompleted(requestId, dates)) + (fun error -> NavDatesReadFailed(requestId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -1512,6 +1542,11 @@ let update message model = returnsReadSeq = model.returnsReadSeq + 1 returnsInFlight = false returns = None + navDatesReadSeq = model.navDatesReadSeq + 1 + navDatesInFlight = false + navDates = [] + navDatesAvailable = false + returnsSelectedDate = None planReadSeq = model.planReadSeq + 1 planInFlight = false planRunSeq = model.planRunSeq + 1 @@ -1721,6 +1756,11 @@ let update message model = returnsReadSeq = model.returnsReadSeq + 1 returnsInFlight = false returns = None + navDatesReadSeq = model.navDatesReadSeq + 1 + navDatesInFlight = false + navDates = [] + navDatesAvailable = false + returnsSelectedDate = None planReadSeq = model.planReadSeq + 1 planInFlight = false planRunSeq = model.planRunSeq + 1 @@ -2499,7 +2539,7 @@ let update message model = returnsInFlight = true error = None }, - readReturnsCommand model.token fund.id requestId + Cmd.batch [ readReturnsCommand model.token fund.id requestId; Cmd.ofMsg NavDatesReadRequested ] | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | ReturnsReadCompleted (requestId, returns) -> @@ -2518,6 +2558,61 @@ let update message model = { model with returnsInFlight = false; error = Some message }, Cmd.none else model, Cmd.none + | NavDatesReadRequested -> + 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.navDatesReadSeq + 1 + + { + model with + navDatesReadSeq = requestId + navDatesInFlight = true + }, + readNavDatesCommand model.token code requestId + | None -> + { + model with + navDates = [] + navDatesAvailable = false + navDatesInFlight = false + }, + Cmd.none + | None -> model, Cmd.none + | NavDatesReadCompleted (requestId, dates) -> + if requestId = model.navDatesReadSeq then + let resolved = dates.dates |> Array.toList + + { + model with + navDates = resolved + navDatesAvailable = not (List.isEmpty resolved) + navDatesInFlight = false + }, + Cmd.none + else + model, Cmd.none + | NavDatesReadFailed (requestId, _) -> + if requestId = model.navDatesReadSeq then + { + model with + navDates = [] + navDatesAvailable = false + navDatesInFlight = false + }, + Cmd.none + else + model, Cmd.none + | ReturnsDateChanged value -> + { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> @@ -3889,9 +3984,15 @@ let private returnsChartView (points: ReturnsPoint list) = ] let private returnsPanel model dispatch = - let latest (selector: ReturnsPoint -> string option) = - model.returns - |> Option.bind (fun returns -> returns.points |> List.rev |> List.tryPick selector) + let visiblePoints = + match model.returns with + | None -> [] + | Some returns -> + match model.returnsSelectedDate with + | Some selected -> returns.points |> List.filter (fun point -> point.date <= selected) + | None -> returns.points + + let latest (selector: ReturnsPoint -> string option) = visiblePoints |> List.rev |> List.tryPick selector let totalAssets = latest (fun point -> point.totalAssets) let cumulativeReturn = latest (fun point -> point.cumulativeReturn) @@ -3910,6 +4011,38 @@ let private returnsPanel model dispatch = Html.span [ prop.className "section-note"; prop.text "Returns - cash-flow adjusted" ] ] ] + Html.div [ + prop.className "returns-date-picker" + prop.custom ( + "data-nav-dates-state", + (if model.navDatesAvailable then "available" else "empty") + ) + prop.children [ + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "真实净值日期" + Html.select [ + prop.className "text-input returns-date-input" + prop.disabled (not model.navDatesAvailable) + prop.value (defaultArg model.returnsSelectedDate "") + prop.onChange (fun value -> dispatch (ReturnsDateChanged value)) + prop.children [ + Html.option [ prop.value ""; prop.text "最新" ] + yield! + (model.navDates + |> List.map (fun date -> Html.option [ prop.value date; prop.text date ])) + ] + ] + ] + ] + Html.p [ + prop.className "returns-date-empty" + prop.hidden model.navDatesAvailable + prop.text "暂无真实净值数据" + ] + ] + ] match model.returns with | None -> Html.p [ prop.className "hint"; prop.text "创建基金后可读取收益曲线。" ] @@ -3945,7 +4078,7 @@ let private returnsPanel model dispatch = ] ] ] - returnsChartView returns.points + returnsChartView visiblePoints Html.p [ prop.className "returns-note" prop.text "蓝线 总资产 / 橙线 单位净值(各自归一化);缺少净值的数据点不连线,也不补零。" diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 79b20b0..80ded41 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -127,6 +127,10 @@ export function getReturns(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/returns`, token); } +export function getMarketNavDates(token, code) { + return requestJson(`/api/market/nav-dates?code=${encodeURIComponent(code)}`, 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 a3bdfba..4cdf165 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -545,6 +545,27 @@ h2 { font-size: 11px; } +.returns-date-picker { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-bottom: 10px; +} + +.returns-date-picker .field-label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: #475569; +} + +.returns-date-empty { + color: #b45309; + font-size: 12px; +} + .returns-pending { color: #b45309; font-size: 12px; -- cgit v1.2.3