summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 04:59:12 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 04:59:12 +0800
commit545f2a507fd0138f7fd50ed7fb2631d0273db625 (patch)
treef9eedee8d82b632474408e774cb95e9e01a9750d /src/FundLab.Api/App.fs
parent0183b86f18d9897ff9487e6d864898954397991d (diff)
downloadfund-lab-545f2a507fd0138f7fd50ed7fb2631d0273db625.tar.gz
Integrate bond and stock positions into fund valuation (3d-23)
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs124
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 [])