summaryrefslogtreecommitdiff
path: root/src
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
parent0183b86f18d9897ff9487e6d864898954397991d (diff)
downloadfund-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.fs124
-rw-r--r--src/FundLab.Web/App.fs187
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css18
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;
}