summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 11:44:12 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 11:44:12 +0800
commit4ff91444dc9f29acf6f8459572a6cbf7db676b59 (patch)
treee9b50ffcc57925ef9392d5420455aae6d579b562 /src/FundLab.Api
parent9818daebaf6fb99124a3196f880b3b8a7998d3a6 (diff)
downloadfund-lab-4ff91444dc9f29acf6f8459572a6cbf7db676b59.tar.gz
leader fix continuation: FS3511 release publish regression (3d-31 continuation)
Diffstat (limited to 'src/FundLab.Api')
-rw-r--r--src/FundLab.Api/App.fs318
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
}