summaryrefslogtreecommitdiff
path: root/src
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
parent4c60174fc66927db5536bc2fe06574c753140055 (diff)
downloadfund-lab-12c4d3625458c830a2746a1f277fb684e9c498cb.tar.gz
Add returns and charts slice (3d-9b)
Diffstat (limited to 'src')
-rw-r--r--src/FundLab.Api/App.fs56
-rw-r--r--src/FundLab.Api/Persistence.fs229
-rw-r--r--src/FundLab.Web/App.fs350
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css14
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;