diff options
Diffstat (limited to 'src/FundLab.Api')
| -rw-r--r-- | src/FundLab.Api/App.fs | 318 |
1 files changed, 167 insertions, 151 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 3d89dae..c14ab66 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -3421,6 +3421,168 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "fund valuation failed" next ctx + // Synchronous refresh work kept out of the resumable task below so the handler's + // state machine is statically compilable (avoids FS3511 under TreatWarningsAsErrors). + let private collectMarketRefresh + (repository: FundRepository) + (marketData: IMarketDataService option) + (probes: MarketProbes option) + (fundId: Guid) + (asOfDate: DateOnly) + (token: System.Threading.CancellationToken) + : MarketRefreshTargetResponse list * MarketRefreshFailureResponse list = + let refreshed = ResizeArray<MarketRefreshTargetResponse>() + let failures = ResizeArray<MarketRefreshFailureResponse>() + + 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 } + ) + + refreshed |> Seq.toList, failures |> Seq.toList + let private refreshFundMarketData (repository: FundRepository) (marketData: IMarketDataService option) @@ -3445,161 +3607,15 @@ module App = | _ -> ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow let token = ctx.RequestAborted - let refreshed = ResizeArray<MarketRefreshTargetResponse>() - let failures = ResizeArray<MarketRefreshFailureResponse>() - - 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 refreshed, failures = + collectMarketRefresh repository marketData probes fundId asOfDate token let response: MarketRefreshResponse = { fundId = fundId asOfDate = dateText asOfDate - refreshed = refreshed |> Seq.toList - failures = failures |> Seq.toList } + refreshed = refreshed + failures = failures } return! json response next ctx } |
