From 12c4d3625458c830a2746a1f277fb684e9c498cb Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 22:43:26 +0800 Subject: Add returns and charts slice (3d-9b) --- src/FundLab.Web/App.fs | 350 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 345 insertions(+), 5 deletions(-) (limited to 'src/FundLab.Web/App.fs') 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 = [] let searchInstruments (token: string) (query: string) : JS.Promise = jsNative @@ -475,6 +555,9 @@ module Api = [] let getDividends (token: string) (fundId: string) : JS.Promise = jsNative + [] + let getReturns (token: string) (fundId: string) : JS.Promise = jsNative + [] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise = 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" ] ] ] -- cgit v1.2.3