From 79600c15aef9bd411abe88dcf36286a102b5abea Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 05:56:14 +0800 Subject: Add daily market refresh endpoint, snapshot-first valuation and UI refresh (3d-25) --- src/FundLab.Api/App.fs | 283 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 265 insertions(+), 18 deletions(-) (limited to 'src/FundLab.Api/App.fs') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 5ff7fdd..44a9afe 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -204,6 +204,7 @@ type FundValuationResponse = { fundId: Guid currency: string + asOfDate: string cash: string positionsMarketValue: string portfolioValue: string @@ -212,6 +213,29 @@ type FundValuationResponse = positions: ValuationPositionResponse list } +type MarketRefreshTargetResponse = + { + instrumentCode: string + assetClass: string + snapshotDate: string + price: string + } + +type MarketRefreshFailureResponse = + { + instrumentCode: string + assetClass: string + reason: string + } + +type MarketRefreshResponse = + { + fundId: Guid + asOfDate: string + refreshed: MarketRefreshTargetResponse list + failures: MarketRefreshFailureResponse list + } + type SipPlanResponse = { id: Guid @@ -1850,6 +1874,13 @@ module App = errorResponse status error message + let private marketDataErrorText (failure: MarketDataFailure) = + match failure with + | InvalidMarketDataRequest message -> message + | MarketDataCollectorUnavailable message -> message + | InvalidMarketDataPayload message -> message + | MarketDataPersistenceFailure message -> message + let private marketDataInstrumentResponse (instrument: MarketDataInstrument) = { code = instrument.Code @@ -2199,21 +2230,21 @@ module App = (code: string) (fallbackName: string option) (quantity: decimal) - (livePrice: decimal option) + (resolvedPrice: (string * decimal) option) = let resolvedName = match fallbackName with | Some name when not (String.IsNullOrWhiteSpace name) -> Some name | _ -> None - match livePrice with - | Some price -> + match resolvedPrice with + | Some(source, price) -> { instrumentCode = code name = resolvedName assetClass = assetClass quantity = decimalText quantity price = Some(decimalText price) - priceSource = Some "live" + priceSource = Some source marketValue = Some(cashText (Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero))) status = "priced" } | None -> @@ -2226,44 +2257,75 @@ module App = marketValue = None status = "unavailable" } + /// Price precedence for a valuation row: a persisted daily snapshot on or + /// before the valuation date wins (it is the reproducible daily close), and + /// a live quote is only a fallback for instruments not yet refreshed. + let private resolveValuationPrice + (snapshotPrice: decimal option) + (livePrice: unit -> Result) + = + match snapshotPrice with + | Some price -> Some("snapshot", price) + | None -> + match livePrice () with + | Ok(Some price) -> Some("live", price) + | _ -> None + let private getFundValuation (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler = fun next ctx -> match Guid.TryParse fundIdText with | false, _ -> errorResponse 400 "INVALID_VALUATION_REQUEST" "fund id must be a UUID" next ctx | true, fundId -> + let asOfDate = + let raw = ctx.Request.Query["asOfDate"].ToString() + if String.IsNullOrWhiteSpace raw then + ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + else + match DateOnly.TryParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date -> date + | _ -> ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + try match repository.GetFund fundId with | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx | Some fund -> let token = ctx.RequestAborted + let stockSnapshots = repository.GetLatestSnapshots(fundId, "stock", asOfDate) + let bondSnapshots = repository.GetLatestSnapshots(fundId, "bond", asOfDate) let priceOf (probe: unit -> Result) = if probes.IsNone then - None + Error(MarketDataCollectorUnavailable "market probes are not configured") else - match probe () with - | Ok(Some price) -> Some price - | _ -> None + probe () let stockRows = repository.GetStockPositions fundId |> List.map (fun position -> - let live = - priceOf (fun () -> - probes.Value.StockQuotes.GetQuote(position.InstrumentCode, token) - |> Result.map (fun quote -> quote.Price)) + let snapshotPrice = + stockSnapshots |> Map.tryFind position.InstrumentCode |> Option.map (fun snap -> snap.Price) + + let resolved = + resolveValuationPrice snapshotPrice (fun () -> + priceOf (fun () -> + probes.Value.StockQuotes.GetQuote(position.InstrumentCode, token) + |> Result.map (fun quote -> quote.Price))) - valuationPositionResponse "stock" position.InstrumentCode position.StockName position.Quantity live) + valuationPositionResponse "stock" position.InstrumentCode position.StockName position.Quantity resolved) let bondRows = repository.GetBondPositions fundId |> List.map (fun position -> - let live = - priceOf (fun () -> - probes.Value.BondQuotes.GetQuote(position.InstrumentCode, token) - |> Result.map (fun quote -> quote.Price)) + let snapshotPrice = + bondSnapshots |> Map.tryFind position.InstrumentCode |> Option.map (fun snap -> snap.Price) + + let resolved = + resolveValuationPrice snapshotPrice (fun () -> + priceOf (fun () -> + probes.Value.BondQuotes.GetQuote(position.InstrumentCode, token) + |> Result.map (fun quote -> quote.Price))) - valuationPositionResponse "bond" position.InstrumentCode position.BondName position.Quantity live) + valuationPositionResponse "bond" position.InstrumentCode position.BondName position.Quantity resolved) let positions = stockRows @ bondRows @@ -2281,6 +2343,7 @@ module App = { fundId = fund.Id currency = fund.Currency + asOfDate = dateText asOfDate cash = cashText fund.AvailableCash positionsMarketValue = cashText positionsMarketValue portfolioValue = cashText (fund.AvailableCash + positionsMarketValue) @@ -2293,6 +2356,189 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "fund valuation failed" next ctx + let private refreshFundMarketData + (repository: FundRepository) + (marketData: IMarketDataService option) + (probes: MarketProbes option) + (fundIdText: string) + : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_MARKET_REFRESH_REQUEST" "fund id must be a UUID" next ctx + | true, fundId -> + task { + match repository.GetFund fundId with + | None -> return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + | Some _ -> + let asOfDate = + let raw = ctx.Request.Query["asOfDate"].ToString() + if String.IsNullOrWhiteSpace raw then + ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + else + match DateOnly.TryParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date -> date + | _ -> ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + + let token = ctx.RequestAborted + let refreshed = ResizeArray() + let failures = ResizeArray() + + let snapshotDateOf (raw: string option) = + match raw with + | Some text -> + match DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date when date <= asOfDate -> date + | _ -> asOfDate + | None -> asOfDate + + // Stocks: persist the latest daily close on or before the refresh date. + for position in repository.GetStockPositions fundId do + match probes with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "stock" + reason = "market probes are not configured" } + ) + | Some probeSet -> + match probeSet.StockDaily.RecentDaily(position.InstrumentCode, 30, token) with + | Error failure -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "stock" + reason = marketDataErrorText failure } + ) + | Ok observations -> + let latest = + observations + |> List.filter (fun observation -> observation.BarDate <= asOfDate) + |> List.sortByDescending (fun observation -> observation.BarDate) + |> List.tryHead + + match latest with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "stock" + reason = "no daily bar on or before the refresh date" } + ) + | Some bar -> + let snapshot : InstrumentSnapshotRecord = + { InstrumentCode = position.InstrumentCode + AssetClass = "stock" + SnapshotDate = bar.BarDate + Price = bar.Close + Source = "akshare" + SourceRevision = "stock-daily" + SourceCollectedAt = DateTimeOffset.UtcNow + SourcePayloadHash = sprintf "stock-daily:%s:%s" position.InstrumentCode (bar.BarDate.ToString("yyyy-MM-dd")) } + + repository.UpsertInstrumentSnapshots [ snapshot ] + + refreshed.Add( + { instrumentCode = position.InstrumentCode + assetClass = "stock" + snapshotDate = dateText bar.BarDate + price = decimalText bar.Close } + ) + + // Bonds: persist the latest valuation price. + for position in repository.GetBondPositions fundId do + match probes with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "bond" + reason = "market probes are not configured" } + ) + | Some probeSet -> + match probeSet.BondQuotes.GetQuote(position.InstrumentCode, token) with + | Error failure -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "bond" + reason = marketDataErrorText failure } + ) + | Ok quote -> + match (quote.Price |> Option.orElse quote.CleanPrice) with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "bond" + reason = "bond quote has no valuation price" } + ) + | Some price -> + let snapshotDate = + quote.Date |> Option.map (fun date -> date.ToString("yyyy-MM-dd")) |> snapshotDateOf + + let snapshot : InstrumentSnapshotRecord = + { InstrumentCode = position.InstrumentCode + AssetClass = "bond" + SnapshotDate = snapshotDate + Price = price + Source = "akshare" + SourceRevision = "bond-quote" + SourceCollectedAt = DateTimeOffset.UtcNow + SourcePayloadHash = sprintf "bond-quote:%s:%s" position.InstrumentCode (snapshotDate.ToString("yyyy-MM-dd")) } + + repository.UpsertInstrumentSnapshots [ snapshot ] + + refreshed.Add( + { instrumentCode = position.InstrumentCode + assetClass = "bond" + snapshotDate = dateText snapshotDate + price = decimalText price } + ) + + // Held funds: refresh their published NAV history so the + // fund-level NAV advances with the same date. + for position in repository.GetFundPositions fundId do + match marketData with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "fund" + reason = "market data service is not configured" } + ) + | Some service -> + match service.RefreshNav(position.InstrumentCode, token) with + | Error failure -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "fund" + reason = marketDataErrorText failure } + ) + | Ok observations -> + let latest = + observations + |> List.filter (fun observation -> observation.NavDate <= asOfDate) + |> List.sortByDescending (fun observation -> observation.NavDate) + |> List.tryHead + + match latest with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "fund" + reason = "no nav observation on or before the refresh date" } + ) + | Some observation -> + refreshed.Add( + { instrumentCode = position.InstrumentCode + assetClass = "fund" + snapshotDate = dateText observation.NavDate + price = decimalText observation.Nav } + ) + + let response: MarketRefreshResponse = + { fundId = fundId + asOfDate = dateText asOfDate + refreshed = refreshed |> Seq.toList + failures = failures |> Seq.toList } + + return! json response next ctx + } + let private marketProbeRoutes (probes: MarketProbes) = [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates @@ -2342,6 +2588,7 @@ module App = POST >=> routef "/funds/%s/bond-trades" (createBondTrade repository probes) GET >=> routef "/funds/%s/bond-positions" (getBondPositions repository) GET >=> routef "/funds/%s/valuation" (getFundValuation repository probes) + POST >=> routef "/funds/%s/market-data/refresh" (fun fundId -> refreshFundMarketData repository marketData probes fundId) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) -- cgit v1.2.3