diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 22:43:26 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 22:43:26 +0800 |
| commit | 12c4d3625458c830a2746a1f277fb684e9c498cb (patch) | |
| tree | 5e1d9f7751c54eda81e805fe9deef0fc7e749430 /src | |
| parent | 4c60174fc66927db5536bc2fe06574c753140055 (diff) | |
| download | fund-lab-12c4d3625458c830a2746a1f277fb684e9c498cb.tar.gz | |
Add returns and charts slice (3d-9b)
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 56 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 229 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 350 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 14 |
5 files changed, 647 insertions, 6 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 38debc3..fc174e5 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -193,6 +193,27 @@ type CapitalDepositResponse = createdAt: string } +type FundReturnsPointResponse = + { + date: string + pending: bool + totalAssets: string option + unitNav: string option + cash: string + reservedCash: string + holdingsValue: string option + cumulativeReturn: string option + netExternalFlow: string + } + +type FundReturnsResponse = + { + fundId: Guid + pending: bool + dataUpdatedAt: string option + points: FundReturnsPointResponse list + } + type ApiErrorResponse = { error: string @@ -677,6 +698,40 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "dividend persistence failed" next ctx + let private returnsPointResponse (point: FundReturnsPoint) : FundReturnsPointResponse = + { + date = dateText point.Date + pending = point.Pending + totalAssets = point.TotalAssets |> Option.map cashText + unitNav = point.UnitNav |> Option.map unitNavText + cash = cashText point.Cash + reservedCash = cashText point.ReservedCash + holdingsValue = point.HoldingsValue |> Option.map cashText + cumulativeReturn = point.CumulativeReturn |> Option.map cashText + netExternalFlow = cashText point.NetExternalFlow + } + + let private getFundReturns (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx + | true, fundId -> + try + match repository.GetFundReturns fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some returns -> + json + ({ + fundId = returns.FundId + pending = returns.Pending + dataUpdatedAt = returns.DataUpdatedAt |> Option.map timestampText + points = returns.Points |> List.map returnsPointResponse + } : FundReturnsResponse) + next + ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "returns persistence failed" next ctx + @@ -1291,6 +1346,7 @@ module App = POST >=> routef "/funds/%s/rebalance/plans/%s/execute" (fun (fundId, planId) -> executeRebalancePlan repository fundId planId) POST >=> routef "/funds/%s/dividends" (createDividend repository) GET >=> routef "/funds/%s/dividends" (getDividends repository) + GET >=> routef "/funds/%s/returns" (getFundReturns repository) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index 973544f..a8abd94 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -142,6 +142,7 @@ type FundRecord = AvailableCash: decimal ReservedCash: decimal Status: string + CreatedAt: DateTimeOffset } type FundWriteResult = @@ -433,6 +434,30 @@ type CapitalDepositWriteResult = | CapitalDepositInvalid of string | CapitalDepositFundNotFound +/// One reconstructed end-of-day fund valuation. `TotalAssets`/`UnitNav`/ +/// `HoldingsValue`/`CumulativeReturn` are `None` when a held instrument has no +/// NAV observation dated on or before `Date`: the value is unknown, never zero. +type FundReturnsPoint = + { + Date: DateOnly + Pending: bool + TotalAssets: decimal option + UnitNav: decimal option + Cash: decimal + ReservedCash: decimal + HoldingsValue: decimal option + CumulativeReturn: decimal option + NetExternalFlow: decimal + } + +type FundReturns = + { + FundId: Guid + Pending: bool + DataUpdatedAt: DateTimeOffset option + Points: FundReturnsPoint list + } + type FundRepository(connectionString: string) = let cashMaximum = 999999999999999999.99m let unitNavMaximum = 99999999999999999999.99999999m @@ -731,6 +756,7 @@ type FundRepository(connectionString: string) = AvailableCash = fund.AvailableCash ReservedCash = fund.FrozenCash Status = statusText fund.Status + CreatedAt = DateTimeOffset.UtcNow } let recordFromReader (reader: DbDataReader) = @@ -744,6 +770,7 @@ type FundRepository(connectionString: string) = AvailableCash = reader.GetDecimal(6) ReservedCash = reader.GetDecimal(7) Status = reader.GetString(8) + CreatedAt = reader.GetFieldValue<DateTimeOffset>(9) } let dateTimeOffsetFromReader (reader: DbDataReader) index = @@ -811,7 +838,7 @@ type FundRepository(connectionString: string) = transaction """ SELECT id, name, currency, initial_cash, initial_unit_nav, - is_synthetic, available_cash, reserved_cash, status + is_synthetic, available_cash, reserved_cash, status, created_at FROM funds WHERE id = @fund_id """ @@ -4043,3 +4070,203 @@ type FundRepository(connectionString: string) = ) records |> Seq.toList + + /// Reconstructs the fund's asset and unit-NAV history from confirmed ledger + /// events plus persisted NAV observations. Unit NAV is rebuilt by issuing fund + /// units for external capital deposits at the previously known unit NAV, so a + /// deposit never moves the unit NAV. Values that cannot be known at a date stay + /// `None`; nothing is filled with zero. + member this.GetFundReturns(fundId: Guid) : FundReturns option = + match this.GetFund fundId with + | None -> None + | Some fund -> + let today = ConfirmationPolicy.shanghaiDate DateTimeOffset.UtcNow + let deposits = this.GetCapitalDeposits fundId + let orders = this.GetSubscriptionOrders fundId + let redemptions = this.GetRedemptionOrders fundId + let dividends = this.GetDividendRecords fundId + + let heldCodes = + [ yield! orders |> List.map (fun order -> order.FundCode) + yield! redemptions |> List.map (fun order -> order.InstrumentCode) ] + |> List.distinct + + let navByCode = + heldCodes + |> List.map (fun code -> code, this.GetNav(code, None, Some today)) + |> Map.ofList + + let firstHoldingDate (code: string) = + orders + |> List.filter (fun order -> order.FundCode = code && order.Status = "confirmed") + |> List.map (fun order -> order.TradeDate) + |> List.sort + |> List.tryHead + + let navDates = + heldCodes + |> List.collect (fun code -> + match firstHoldingDate code with + | None -> [] + | Some startDate -> + navByCode.[code] + |> List.filter (fun observation -> + observation.NavDate >= startDate && observation.NavDate <= today) + |> List.map (fun observation -> observation.NavDate)) + |> Set.ofList + + let creationDate = ConfirmationPolicy.shanghaiDate fund.CreatedAt + + let dividendCredited = + dividends + |> List.filter (fun record -> record.Status = "cash_credited" || record.Status = "succeeded") + + let hasActivity = + not (List.isEmpty deposits) + || not (List.isEmpty orders) + || not (List.isEmpty redemptions) + || not (List.isEmpty dividendCredited) + || not (Set.isEmpty navDates) + + if not hasActivity then + Some + { + FundId = fundId + Pending = false + DataUpdatedAt = None + Points = [] + } + else + let eventDates = + [ yield creationDate + yield! deposits |> List.map (fun deposit -> ConfirmationPolicy.shanghaiDate deposit.CreatedAt) + yield! orders |> List.map (fun order -> order.TradeDate) + yield! redemptions |> List.map (fun order -> order.TradeDate) + yield! dividendCredited |> List.map (fun record -> record.NavDate) ] + |> Set.ofList + + let dates = + Set.union eventDates navDates + |> Set.toList + |> List.sort + + let dataUpdatedAt = + navByCode + |> Map.toList + |> List.collect snd + |> List.map (fun observation -> observation.LastSeenAt) + |> List.sortDescending + |> List.tryHead + + let latestNavOnOrBefore (code: string) (date: DateOnly) = + match Map.tryFind code navByCode with + | None -> None + | Some observations -> + observations + |> List.filter (fun observation -> observation.NavDate <= date && observation.Nav > 0m) + |> List.sortByDescending (fun observation -> observation.NavDate, observation.SourceCollectedAt) + |> List.tryHead + + let mutable availableCash = fund.InitialCash + let mutable reservedCash = 0m + let mutable fundUnits = if fund.InitialUnitNav > 0m then fund.InitialCash / fund.InitialUnitNav else 0m + let mutable lastKnownNav = if fund.InitialUnitNav > 0m then Some fund.InitialUnitNav else None + let positions = System.Collections.Generic.Dictionary<string, decimal>() + let mutable cumulativeDeposits = 0m + let points = ResizeArray<FundReturnsPoint>() + + for date in dates do + for deposit in deposits do + if ConfirmationPolicy.shanghaiDate deposit.CreatedAt = date then + availableCash <- availableCash + deposit.Amount + cumulativeDeposits <- cumulativeDeposits + deposit.Amount + + match lastKnownNav with + | Some nav when nav > 0m -> fundUnits <- fundUnits + deposit.Amount / nav + | _ -> () + + for order in orders do + if order.TradeDate = date then + if order.Status = "confirmed" then + let residual = order.ConfirmedResidualCash |> Option.defaultValue 0m + availableCash <- availableCash + residual - order.ReservedTotal + + let units = order.ConfirmedUnits |> Option.defaultValue 0m + let current = + match positions.TryGetValue order.FundCode with + | true, value -> value + | _ -> 0m + + positions.[order.FundCode] <- current + units + else + availableCash <- availableCash - order.ReservedTotal + reservedCash <- reservedCash + order.ReservedTotal + + for order in redemptions do + if order.TradeDate = date && order.Status = "confirmed" then + let current = + match positions.TryGetValue order.InstrumentCode with + | true, value -> value + | _ -> 0m + + positions.[order.InstrumentCode] <- current - order.Units + availableCash <- availableCash + (order.ConfirmedProceeds |> Option.defaultValue 0m) + + for record in dividendCredited do + if record.NavDate = date then + availableCash <- availableCash + (record.GrossCash |> Option.defaultValue 0m) + + let mutable pending = false + let mutable holdingsValue = 0m + + for KeyValue(code, units) in positions do + if units > 0m then + match latestNavOnOrBefore code date with + | Some observation -> holdingsValue <- holdingsValue + units * observation.Nav + | None -> pending <- true + + let netExternalFlow = fund.InitialCash + cumulativeDeposits + + if pending then + points.Add( + { + Date = date + Pending = true + TotalAssets = None + UnitNav = None + Cash = availableCash + ReservedCash = reservedCash + HoldingsValue = None + CumulativeReturn = None + NetExternalFlow = netExternalFlow + } + ) + else + let totalAssets = availableCash + reservedCash + holdingsValue + let unitNav = if fundUnits > 0m then Some(totalAssets / fundUnits) else None + + match unitNav with + | Some nav -> lastKnownNav <- Some nav + | None -> () + + points.Add( + { + Date = date + Pending = false + TotalAssets = Some totalAssets + UnitNav = unitNav + Cash = availableCash + ReservedCash = reservedCash + HoldingsValue = Some holdingsValue + CumulativeReturn = Some(totalAssets - netExternalFlow) + NetExternalFlow = netExternalFlow + } + ) + + Some + { + FundId = fundId + Pending = points |> Seq.exists (fun point -> point.Pending) + DataUpdatedAt = dataUpdatedAt + Points = points |> Seq.toList + } 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" ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index f3821f9..7606e02 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -123,6 +123,10 @@ export function getDividends(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/dividends`, token); } +export function getReturns(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/returns`, 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 d3f0e33..016178d 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -540,6 +540,20 @@ h2 { background: #f8fafc; } +.returns-note { + color: #64748b; + font-size: 11px; +} + +.returns-pending { + color: #b45309; + font-size: 12px; +} + +.returns-chart .nav-chart { + height: 300px; +} + .footer-note { padding: 16px 0 34px; font-family: "SFMono-Regular", Consolas, monospace; |
