summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 05:56:14 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 05:56:14 +0800
commit79600c15aef9bd411abe88dcf36286a102b5abea (patch)
treee5e3bfbff6f60a48a3c0ff8b78b48da98c8f238a /src/FundLab.Api/App.fs
parent6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (diff)
downloadfund-lab-79600c15aef9bd411abe88dcf36286a102b5abea.tar.gz
Add daily market refresh endpoint, snapshot-first valuation and UI refresh (3d-25)
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs283
1 files changed, 265 insertions, 18 deletions
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<decimal option, MarketDataFailure>)
+ =
+ 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<decimal option, MarketDataFailure>) =
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<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 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 [])