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 | |
| parent | 0183b86f18d9897ff9487e6d864898954397991d (diff) | |
| download | fund-lab-545f2a507fd0138f7fd50ed7fb2631d0273db625.tar.gz | |
Integrate bond and stock positions into fund valuation (3d-23)
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 124 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 187 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 18 |
4 files changed, 333 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 []) diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 8b8f3b9..62bb20e 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -481,6 +481,30 @@ type RawBondPositions = positions: RawBondPosition array } +type RawValuationPosition = + { + instrumentCode: string + name: obj + assetClass: string + quantity: string + price: obj + priceSource: obj + marketValue: obj + status: string + } + +type RawFundValuation = + { + fundId: string + currency: string + cash: string + positionsMarketValue: string + portfolioValue: string + pricedPositions: int + unavailablePositions: int + positions: RawValuationPosition array + } + type CreateAttempt = { idempotencyKey: string @@ -869,6 +893,29 @@ type BondPositionView = lastTradedAt: string } +type ValuationPositionView = + { + instrumentCode: string + name: string option + assetClass: string + quantity: string + price: string option + priceSource: string option + marketValue: string option + status: string + } + +type FundValuationView = + { + currency: string + cash: string + positionsMarketValue: string + portfolioValue: string + pricedPositions: int + unavailablePositions: int + positions: ValuationPositionView list + } + type BondTradeAttempt = { idempotencyKey: string @@ -946,6 +993,9 @@ module Api = [<Import("getBondPositions", "./src/api.js")>] let getBondPositions (token: string) (fundId: string) : JS.Promise<RawBondPositions> = jsNative + [<Import("getFundValuation", "./src/api.js")>] + let getFundValuation (token: string) (fundId: string) : JS.Promise<RawFundValuation> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -1285,6 +1335,29 @@ module Api = lastTradedAt = raw.lastTradedAt } + let decodeValuationPosition (raw: RawValuationPosition) : ValuationPositionView = + { + instrumentCode = raw.instrumentCode + name = decodeOptionalText raw.name + assetClass = raw.assetClass + quantity = raw.quantity + price = decodeOptionalText raw.price + priceSource = decodeOptionalText raw.priceSource + marketValue = decodeOptionalText raw.marketValue + status = raw.status + } + + let decodeFundValuation (raw: RawFundValuation) : FundValuationView = + { + currency = raw.currency + cash = raw.cash + positionsMarketValue = raw.positionsMarketValue + portfolioValue = raw.portfolioValue + pricedPositions = raw.pricedPositions + unavailablePositions = raw.unavailablePositions + positions = raw.positions |> Array.toList |> List.map decodeValuationPosition + } + type Model = { token: string @@ -1402,6 +1475,10 @@ type Model = bondPositionsInFlight: bool bondPositions: BondPositionView list bondPositionsMessage: string option + valuationReadSeq: int + valuationInFlight: bool + valuation: FundValuationView option + valuationMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1527,6 +1604,9 @@ type Msg = | BondPositionsReadRequested | BondPositionsReadCompleted of requestId: int * fundId: string * positions: RawBondPositions | BondPositionsReadFailed of requestId: int * fundId: string * message: string + | ValuationReadRequested + | ValuationReadCompleted of requestId: int * fundId: string * valuation: RawFundValuation + | ValuationReadFailed of requestId: int * fundId: string * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1689,6 +1769,10 @@ let init () = bondPositionsInFlight = false bondPositions = [] bondPositionsMessage = None + valuationReadSeq = 0 + valuationInFlight = false + valuation = None + valuationMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -1922,6 +2006,13 @@ let private readBondPositionsCommand token fundId requestId = (fun positions -> BondPositionsReadCompleted(requestId, fundId, positions)) (fun error -> BondPositionsReadFailed(requestId, fundId, errorText error)) +let private readValuationCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getFundValuation token fundId) + () + (fun valuation -> ValuationReadCompleted(requestId, fundId, valuation)) + (fun error -> ValuationReadFailed(requestId, fundId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -3476,6 +3567,43 @@ let update message model = Cmd.none else model, Cmd.none + | ValuationReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.valuationReadSeq + 1 + + { + model with + valuationReadSeq = requestId + valuationInFlight = true + valuationMessage = None + }, + readValuationCommand model.token fund.id requestId + | _ -> model, Cmd.none + | ValuationReadCompleted (requestId, fundId, valuation) -> + if requestId = model.valuationReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + valuation = Some(Api.decodeFundValuation valuation) + valuationInFlight = false + valuationMessage = None + }, + Cmd.none + else + model, Cmd.none + | ValuationReadFailed (requestId, fundId, message) -> + if requestId = model.valuationReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + valuation = None + valuationInFlight = false + valuationMessage = Some(sprintf "组合估值不可用 — %s" message) + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -5462,6 +5590,65 @@ let private stocksPanel model dispatch = prop.hidden (model.stockPositionsMessage.IsNone) prop.text (model.stockPositionsMessage |> Option.defaultValue "") ] + Html.div [ + prop.className "fund-valuation" + prop.children [ + Html.p [ prop.className "returns-subheading"; prop.text "组合估值" ] + Html.div [ + prop.className "search-row" + prop.children [ + Html.button [ + prop.className "secondary-action valuation-refresh-action" + prop.disabled model.valuationInFlight + prop.onClick (fun _ -> dispatch ValuationReadRequested) + prop.text ((if model.valuationInFlight then "估值中..." else "刷新估值"): string) + ] + ] + ] + match model.valuation with + | Some valuation -> + Html.div [ + prop.className "valuation-content" + prop.children ( + [ Html.div [ + prop.className "metric-strip valuation-metrics" + prop.children [ + bondQuoteMetric "现金" (Some valuation.cash) + bondQuoteMetric "持仓市值" (Some valuation.positionsMarketValue) + bondQuoteMetric "组合合计" (Some valuation.portfolioValue) + bondQuoteMetric "已定价/缺失" (Some(sprintf "%d / %d" valuation.pricedPositions valuation.unavailablePositions)) + ] + ] ] + @ (valuation.positions + |> List.map (fun position -> + Html.div [ + prop.className ( + if position.status = "unavailable" then + "valuation-row valuation-position-row valuation-unavailable" + else + "valuation-row valuation-position-row" + ) + prop.children [ + Html.span [ prop.className "fund-detail-label"; prop.text position.instrumentCode ] + Html.span [ prop.className "fund-detail-value"; prop.text (position.name |> Option.defaultValue "—") ] + Html.span [ prop.className "fund-detail-value"; prop.text position.quantity ] + Html.span [ + prop.className "fund-detail-value" + prop.text (position.marketValue |> Option.defaultValue "缺失") + ] + Html.span [ prop.className "fund-detail-value"; prop.text (position.priceSource |> Option.defaultValue "—") ] + ] + ])) + ) + ] + | None -> Html.p [ prop.className "hint"; prop.text "刷新后显示现金/持仓市值/组合合计;缺失行情标的不补零,标记为缺失。" ] + Html.p [ + prop.className "valuation-unavailable" + prop.hidden (model.valuationMessage.IsNone) + prop.text (model.valuationMessage |> Option.defaultValue "") + ] + ] + ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 026464a..031ff74 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -179,6 +179,10 @@ export function getBondPositions(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-positions`, token); } +export function getFundValuation(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/valuation`, token); +} + export function createSipPlan(token, fundId, payload) { const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, { diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index 08829c2..dbd53ed 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -624,6 +624,24 @@ h2 { border-bottom: 1px solid rgba(148, 163, 184, 0.2); } +.valuation-unavailable { + color: #b45309; + font-size: 12px; +} + +.valuation-row { + display: grid; + grid-template-columns: 1fr 1.4fr 1fr 1fr 0.8fr; + gap: 8px; + align-items: center; + padding: 4px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +.valuation-row.valuation-unavailable .fund-detail-value { + color: #b45309; +} + .stock-positions-header .fund-detail-label { font-weight: 600; } |
