summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 22:43:26 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 22:43:26 +0800
commit12c4d3625458c830a2746a1f277fb684e9c498cb (patch)
tree5e1d9f7751c54eda81e805fe9deef0fc7e749430 /src/FundLab.Web/App.fs
parent4c60174fc66927db5536bc2fe06574c753140055 (diff)
downloadfund-lab-12c4d3625458c830a2746a1f277fb684e9c498cb.tar.gz
Add returns and charts slice (3d-9b)
Diffstat (limited to 'src/FundLab.Web/App.fs')
-rw-r--r--src/FundLab.Web/App.fs350
1 files changed, 345 insertions, 5 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 6ce48f0..f1e6df6 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -29,6 +29,14 @@ type ChartPoint =
nav: string
}
+type SeriesPoint =
+ {
+ x: float
+ y: float
+ label: string
+ value: string
+ }
+
module Chart =
let private invariant = CultureInfo.InvariantCulture
@@ -69,6 +77,37 @@ module Chart =
nav = observation.nav
})
+ /// Normalizes one named series over a shared, already ordered date axis. Dates
+ /// without a value are omitted (a gap, never a zero). `x` is the position on the
+ /// full axis so two series stay aligned; `y` is normalized within the series.
+ let seriesPoints (dates: string list) (values: (string * string) list) : SeriesPoint list =
+ let parsed =
+ values
+ |> List.choose (fun (date, text) ->
+ match Decimal.TryParse(text, NumberStyles.Float, invariant) with
+ | true, value -> Some(date, value, text)
+ | false, _ -> None)
+
+ match parsed with
+ | [] -> []
+ | _ ->
+ let numbers = parsed |> List.map (fun (_, value, _) -> value)
+ let minimum = List.min numbers
+ let maximum = List.max numbers
+ let range = maximum - minimum
+ let count = List.length dates
+ let lookup = parsed |> List.map (fun (date, value, text) -> date, (value, text)) |> Map.ofList
+
+ dates
+ |> List.mapi (fun index date ->
+ match Map.tryFind date lookup with
+ | None -> None
+ | Some(value, text) ->
+ let x = if count <= 1 then 0.5 else float index / float (count - 1)
+ let y = if range = 0M then 0.5 else float ((value - minimum) / range)
+ Some { x = x; y = y; label = date; value = text })
+ |> List.choose id
+
type RawOptionalText =
{
case: string
@@ -239,6 +278,27 @@ type RawPositions =
positions: RawPosition array
}
+type RawReturnsPoint =
+ {
+ date: string
+ pending: bool
+ totalAssets: obj
+ unitNav: obj
+ cash: string
+ reservedCash: string
+ holdingsValue: obj
+ cumulativeReturn: obj
+ netExternalFlow: string
+ }
+
+type RawReturns =
+ {
+ fundId: string
+ pending: bool
+ dataUpdatedAt: obj
+ points: RawReturnsPoint array
+ }
+
type CreateAttempt =
{
idempotencyKey: string
@@ -435,6 +495,26 @@ type Positions =
positions: Position list
}
+type ReturnsPoint =
+ {
+ date: string
+ pending: bool
+ totalAssets: string option
+ unitNav: string option
+ cash: string
+ reservedCash: string
+ holdingsValue: string option
+ cumulativeReturn: string option
+ netExternalFlow: string
+ }
+
+type FundReturns =
+ {
+ pending: bool
+ dataUpdatedAt: string option
+ points: ReturnsPoint list
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -475,6 +555,9 @@ module Api =
[<Import("getDividends", "./src/api.js")>]
let getDividends (token: string) (fundId: string) : JS.Promise<RawDividend array> = jsNative
+ [<Import("getReturns", "./src/api.js")>]
+ let getReturns (token: string) (fundId: string) : JS.Promise<RawReturns> = jsNative
+
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
@@ -637,6 +720,26 @@ module Api =
createdAt = raw.createdAt
}
+ let decodeReturnsPoint (raw: RawReturnsPoint) : ReturnsPoint =
+ {
+ date = raw.date
+ pending = raw.pending
+ totalAssets = decodeOptionalText raw.totalAssets
+ unitNav = decodeOptionalText raw.unitNav
+ cash = raw.cash
+ reservedCash = raw.reservedCash
+ holdingsValue = decodeOptionalText raw.holdingsValue
+ cumulativeReturn = decodeOptionalText raw.cumulativeReturn
+ netExternalFlow = raw.netExternalFlow
+ }
+
+ let decodeReturns (raw: RawReturns) : FundReturns =
+ {
+ pending = raw.pending
+ dataUpdatedAt = decodeOptionalText raw.dataUpdatedAt
+ points = raw.points |> Array.map decodeReturnsPoint |> Array.toList
+ }
+
type Model =
{
token: string
@@ -706,6 +809,9 @@ type Model =
dividendReadSeq: int
dividendInFlight: bool
dividends: DividendRecord list
+ returnsReadSeq: int
+ returnsInFlight: bool
+ returns: FundReturns option
error: string option
}
@@ -784,6 +890,9 @@ type Msg =
| DividendsReadRequested
| DividendsReadCompleted of requestId: int * records: RawDividend array
| DividendsReadFailed of requestId: int * message: string
+ | ReturnsReadRequested
+ | ReturnsReadCompleted of requestId: int * returns: RawReturns
+ | ReturnsReadFailed of requestId: int * message: string
let defaultInitialUnitNav = "1.00000000"
@@ -882,6 +991,9 @@ let init () =
dividendReadSeq = 0
dividendInFlight = false
dividends = []
+ returnsReadSeq = 0
+ returnsInFlight = false
+ returns = None
error = None
}
@@ -1024,6 +1136,13 @@ let private readDividendsCommand token fundId requestId =
(fun records -> DividendsReadCompleted(requestId, records))
(fun error -> DividendsReadFailed(requestId, errorText error))
+let private readReturnsCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getReturns token fundId)
+ ()
+ (fun returns -> ReturnsReadCompleted(requestId, returns))
+ (fun error -> ReturnsReadFailed(requestId, errorText error))
+
let update message model =
match message with
| TokenChanged token ->
@@ -1095,6 +1214,9 @@ let update message model =
dividendReadSeq = model.dividendReadSeq + 1
dividendInFlight = false
dividends = []
+ returnsReadSeq = model.returnsReadSeq + 1
+ returnsInFlight = false
+ returns = None
error = None
},
Cmd.none
@@ -1292,9 +1414,12 @@ let update message model =
dividendReadSeq = model.dividendReadSeq + 1
dividendInFlight = false
dividends = []
+ returnsReadSeq = model.returnsReadSeq + 1
+ returnsInFlight = false
+ returns = None
error = None
},
- Cmd.ofMsg OrdersReadRequested
+ Cmd.batch [ Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg ReturnsReadRequested ]
else
model, Cmd.none
| FundCreateFailed (requestId, message) ->
@@ -1389,7 +1514,7 @@ let update message model =
lastOrderAttempt = None
error = None
},
- Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested ]
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg ReturnsReadRequested ]
else
model, Cmd.none
| OrderCreateFailed (requestId, fundId, message) ->
@@ -1451,7 +1576,7 @@ let update message model =
|> List.map (fun order -> if order.id = confirmed.id then confirmed else order)
{ model with confirmInFlight = false; lastConfirmAttempt = None; orders = orders; error = None },
- Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested ]
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested; Cmd.ofMsg ReturnsReadRequested ]
else
model, Cmd.none
| OrderConfirmFailed (requestId, orderId, message) ->
@@ -1622,7 +1747,7 @@ let update message model =
|> List.map (fun order -> if order.id = confirmed.id then confirmed else order)
{ model with redemptionConfirmInFlight = false; lastRedemptionConfirmAttempt = None; redemptions = redemptions; error = None },
- Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested ]
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested; Cmd.ofMsg ReturnsReadRequested ]
else
model, Cmd.none
| RedemptionConfirmFailed (requestId, orderId, message) ->
@@ -1678,7 +1803,7 @@ let update message model =
capitalNote = Some(sprintf "追加成功 + %s(现金余额已刷新)" amount)
error = None
},
- Cmd.ofMsg FundReadRequested
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg ReturnsReadRequested ]
else
model, Cmd.none
| CapitalDepositFailed (requestId, message) ->
@@ -1965,6 +2090,36 @@ let update message model =
{ model with dividendInFlight = false; error = Some message }, Cmd.none
else
model, Cmd.none
+ | ReturnsReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.returnsReadSeq + 1
+
+ {
+ model with
+ returnsReadSeq = requestId
+ returnsInFlight = true
+ error = None
+ },
+ readReturnsCommand model.token fund.id requestId
+ | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none
+ | None -> model, Cmd.none
+ | ReturnsReadCompleted (requestId, returns) ->
+ if requestId = model.returnsReadSeq then
+ {
+ model with
+ returns = Some(Api.decodeReturns returns)
+ returnsInFlight = false
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | ReturnsReadFailed (requestId, message) ->
+ if requestId = model.returnsReadSeq then
+ { model with returnsInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
let private navText (text: string) =
@@ -3050,6 +3205,190 @@ let private dividendPanel model dispatch =
]
]
+let private moneyText (text: string) =
+ match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
+ | true, value -> value.ToString("0.00", CultureInfo.InvariantCulture)
+ | false, _ -> text
+
+let private shortTimestamp (text: string) =
+ if String.IsNullOrWhiteSpace text then
+ "暂无"
+ else
+ let normalized = text.Replace("T", " ")
+ if normalized.Length >= 16 then normalized.Substring(0, 16) else normalized
+
+let private returnsChartView (points: ReturnsPoint list) =
+ let dates = points |> List.map (fun point -> point.date)
+
+ let totalSeries =
+ points
+ |> List.choose (fun point -> point.totalAssets |> Option.map (fun value -> point.date, value))
+
+ let navSeries =
+ points
+ |> List.choose (fun point -> point.unitNav |> Option.map (fun value -> point.date, value))
+
+ let totalPoints = Chart.seriesPoints dates totalSeries
+ let navPoints = Chart.seriesPoints dates navSeries
+
+ if List.isEmpty totalPoints && List.isEmpty navPoints then
+ Html.div [ prop.className "chart-empty"; prop.text "暂无收益数据" ]
+ else
+ let width = 960.0
+ let height = 320.0
+ let padding = 28.0
+ let chartWidth = width - padding * 2.0
+ let chartHeight = height - padding * 2.0
+
+ let coordinates (series: SeriesPoint list) =
+ series
+ |> List.map (fun point -> padding + point.x * chartWidth, padding + (1.0 - point.y) * chartHeight)
+
+ let gridLines =
+ [ 0 .. 4 ]
+ |> List.map (fun index ->
+ let y = padding + float index / 4.0 * chartHeight
+
+ Svg.line [
+ svg.x1 padding
+ svg.y1 y
+ svg.x2 (width - padding)
+ svg.y2 y
+ svg.stroke "#e2e8f0"
+ svg.strokeWidth 1
+ ])
+
+ let markers color (series: SeriesPoint list) =
+ coordinates series
+ |> List.map (fun (x, y) ->
+ Svg.circle [
+ svg.cx x
+ svg.cy y
+ svg.r 3.5
+ svg.fill color
+ ])
+
+ Html.div [
+ prop.className "chart-wrap returns-chart"
+ prop.children [
+ Svg.svg [
+ svg.className "nav-chart"
+ svg.viewBox (0, 0, 960, 320)
+ svg.children [
+ yield! gridLines
+ if List.length totalPoints >= 2 then
+ yield
+ Svg.polyline [
+ svg.points (coordinates totalPoints)
+ svg.fill "none"
+ svg.stroke "#1e40af"
+ svg.strokeWidth 3
+ ]
+ if List.length navPoints >= 2 then
+ yield
+ Svg.polyline [
+ svg.points (coordinates navPoints)
+ svg.fill "none"
+ svg.stroke "#f59e0b"
+ svg.strokeWidth 3
+ ]
+ yield! markers "#1e40af" totalPoints
+ yield! markers "#f59e0b" navPoints
+ ]
+ ]
+ Html.div [
+ prop.className "chart-axis"
+ prop.children [
+ Html.span [ prop.text (points |> List.head |> fun point -> point.date) ]
+ Html.span [ prop.text (points |> List.last |> fun point -> point.date) ]
+ ]
+ ]
+ ]
+ ]
+
+let private returnsPanel model dispatch =
+ let latest (selector: ReturnsPoint -> string option) =
+ model.returns
+ |> Option.bind (fun returns -> returns.points |> List.rev |> List.tryPick selector)
+
+ let totalAssets = latest (fun point -> point.totalAssets)
+ let cumulativeReturn = latest (fun point -> point.cumulativeReturn)
+ let updatedAt = model.returns |> Option.bind (fun returns -> returns.dataUpdatedAt)
+
+ Html.section [
+ prop.className "panel returns-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "10 / RETURNS" ]
+ Html.h2 "收益与资产曲线"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "Returns - cash-flow adjusted" ]
+ ]
+ ]
+ match model.returns with
+ | None ->
+ Html.p [ prop.className "hint"; prop.text "创建基金后可读取收益曲线。" ]
+ | Some returns when List.isEmpty returns.points ->
+ Html.p [ prop.className "hint"; prop.text "暂无收益数据" ]
+ | Some returns ->
+ Html.div [
+ prop.className "nav-content"
+ prop.children [
+ Html.div [
+ prop.className "metric-strip"
+ prop.children [
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "总资产"
+ Html.strong (totalAssets |> Option.map moneyText |> Option.defaultValue "暂无")
+ ]
+ ]
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "累计收益"
+ Html.strong (cumulativeReturn |> Option.map moneyText |> Option.defaultValue "暂无")
+ ]
+ ]
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "数据更新时间"
+ Html.strong (updatedAt |> Option.map shortTimestamp |> Option.defaultValue "暂无")
+ ]
+ ]
+ ]
+ ]
+ returnsChartView returns.points
+ Html.p [
+ prop.className "returns-note"
+ prop.text "蓝线 总资产 / 橙线 单位净值(各自归一化);缺少净值的数据点不连线,也不补零。"
+ ]
+ if returns.pending then
+ Html.p [
+ prop.className "returns-pending"
+ prop.text "部分持有基金缺少估值日净值,相关日期标记为待更新。"
+ ]
+ ]
+ ]
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action returns-refresh-action"
+ prop.disabled model.returnsInFlight
+ prop.onClick (fun _ -> dispatch ReturnsReadRequested)
+ prop.text ((if model.returnsInFlight then "读取中..." else "刷新收益曲线"): string)
+ ]
+ ]
+ ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -3111,6 +3450,7 @@ let view model dispatch =
sipPanel model dispatch
rebalancePanel model dispatch
dividendPanel model dispatch
+ returnsPanel model dispatch
Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ]
]
]