diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:59:12 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:59:12 +0800 |
| commit | 545f2a507fd0138f7fd50ed7fb2631d0273db625 (patch) | |
| tree | f9eedee8d82b632474408e774cb95e9e01a9750d /src/FundLab.Api | |
| parent | 0183b86f18d9897ff9487e6d864898954397991d (diff) | |
| download | fund-lab-545f2a507fd0138f7fd50ed7fb2631d0273db625.tar.gz | |
Integrate bond and stock positions into fund valuation (3d-23)
Diffstat (limited to 'src/FundLab.Api')
| -rw-r--r-- | src/FundLab.Api/App.fs | 124 |
1 files changed, 124 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 36a8565..138230f 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -188,6 +188,30 @@ type BondPositionsResponse = positions: BondPositionResponse list } +type ValuationPositionResponse = + { + instrumentCode: string + name: string option + assetClass: string + quantity: string + price: string option + priceSource: string option + marketValue: string option + status: string + } + +type FundValuationResponse = + { + fundId: Guid + currency: string + cash: string + positionsMarketValue: string + portfolioValue: string + pricedPositions: int + unavailablePositions: int + positions: ValuationPositionResponse list + } + type SipPlanResponse = { id: Guid @@ -2122,6 +2146,105 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "bond position persistence failed" next ctx + let private valuationPositionResponse + (assetClass: string) + (code: string) + (fallbackName: string option) + (quantity: decimal) + (livePrice: decimal option) + = + let resolvedName = + match fallbackName with + | Some name when not (String.IsNullOrWhiteSpace name) -> Some name + | _ -> None + + match livePrice with + | Some price -> + { instrumentCode = code + name = resolvedName + assetClass = assetClass + quantity = decimalText quantity + price = Some(decimalText price) + priceSource = Some "live" + marketValue = Some(cashText (Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero))) + status = "priced" } + | None -> + { instrumentCode = code + name = resolvedName + assetClass = assetClass + quantity = decimalText quantity + price = None + priceSource = None + marketValue = None + status = "unavailable" } + + 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 -> + 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 priceOf (probe: unit -> Result<decimal option, MarketDataFailure>) = + if probes.IsNone then + None + else + match probe () with + | Ok(Some price) -> Some price + | _ -> None + + 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)) + + valuationPositionResponse "stock" position.InstrumentCode position.StockName position.Quantity live) + + 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)) + + valuationPositionResponse "bond" position.InstrumentCode position.BondName position.Quantity live) + + let positions = stockRows @ bondRows + + let positionsMarketValue = + positions + |> List.sumBy (fun position -> + match position.marketValue with + | Some text -> Decimal.Parse(text, invariant) + | None -> 0m) + + let unavailable = + positions |> List.filter (fun position -> position.status = "unavailable") |> List.length + + let response: FundValuationResponse = + { + fundId = fund.Id + currency = fund.Currency + cash = cashText fund.AvailableCash + positionsMarketValue = cashText positionsMarketValue + portfolioValue = cashText (fund.AvailableCash + positionsMarketValue) + pricedPositions = positions.Length - unavailable + unavailablePositions = unavailable + positions = positions + } + + json response next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "fund valuation failed" next ctx + let private marketProbeRoutes (probes: MarketProbes) = [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates @@ -2167,6 +2290,7 @@ module App = GET >=> routef "/funds/%s/stock-positions" (getStockPositions repository) 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) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) |
