summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/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.Web/App.fs
parent0183b86f18d9897ff9487e6d864898954397991d (diff)
downloadfund-lab-545f2a507fd0138f7fd50ed7fb2631d0273db625.tar.gz
Integrate bond and stock positions into fund valuation (3d-23)
Diffstat (limited to 'src/FundLab.Web/App.fs')
-rw-r--r--src/FundLab.Web/App.fs187
1 files changed, 187 insertions, 0 deletions
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 "")
+ ]
+ ]
+ ]
]
]