summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--qa/driver/browser-test.js54
-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
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/ReturnsTests.fs233
-rw-r--r--tests/FundLab.Web.Tests/ChartTests.fs47
9 files changed, 981 insertions, 7 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
index 4a4b799..6403dc5 100644
--- a/qa/driver/browser-test.js
+++ b/qa/driver/browser-test.js
@@ -25,6 +25,7 @@ let createdFundResponse = null;
let lastOrdersResponse = null;
let lastConfirmResponse = null;
let lastRedemptionConfirmResponse = null;
+let lastReturnsResponse = null;
function check(name, ok, detail) {
results.push({ name, ok, detail: detail || "" });
@@ -103,7 +104,7 @@ async function summaryLabelExists(page, label, name) {
if (!createdFundResponse) createdFundResponse = await r.json();
} catch {}
}
- if (r.url().includes("/api/funds/") && !r.url().includes("/orders") && r.request().method() === "GET" && r.status() < 400) fundGets++;
+ if (/\/api\/funds\/[0-9a-f-]{36}$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) fundGets++;
if (r.url().includes("/api/funds/") && r.url().includes("/orders") && r.request().method() === "GET" && r.status() < 400) {
orderGets++;
try {
@@ -129,6 +130,11 @@ async function summaryLabelExists(page, label, name) {
lastPositionsResponse = await r.json();
} catch {}
}
+ if (r.url().includes("/returns") && r.request().method() === "GET" && r.status() < 400) {
+ try {
+ lastReturnsResponse = await r.json();
+ } catch {}
+ }
if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) {
consoleErrors.push("resource " + r.status() + ": " + r.url());
}
@@ -175,6 +181,7 @@ async function summaryLabelExists(page, label, name) {
await redeemScenario(page);
await capitalScenario(page);
await dividendScenario(page);
+ await returnsScenario(page);
} finally {
check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
await browser.close();
@@ -411,6 +418,51 @@ async function dividendScenario(page) {
await page.screenshot({ path: SHOTS + "/15-dividend-history.png" });
}
+async function returnsScenario(page) {
+ // R 系列: 收益与资产曲线(接 L 场景:现金 5442.99,持仓 50.00000000 @1.1902 = 59.51)
+ await page.waitForSelector(".returns-panel", { timeout: 10000 });
+ const eyebrow = ((await page.textContent(".returns-panel .eyebrow")) || "").trim();
+ check("R1 收益面板 eyebrow 为 10 / RETURNS", eyebrow === "10 / RETURNS", eyebrow);
+
+ lastReturnsResponse = null;
+ await page.click(".returns-refresh-action");
+ for (let i = 0; i < 200 && lastReturnsResponse === null; i++) {
+ await page.waitForTimeout(100);
+ }
+ const returns = lastReturnsResponse || {};
+ const points = Array.isArray(returns.points) ? returns.points : [];
+ const last = points.length > 0 ? points[points.length - 1] : {};
+ check(
+ "R2 收益接口返回现金流调整后的总资产与累计收益",
+ returns.pending === false &&
+ last.date === "2026-09-21" &&
+ last.totalAssets === "5502.50" &&
+ last.cumulativeReturn === "2.50" &&
+ last.cash === "5442.99" &&
+ last.holdingsValue === "59.51" &&
+ typeof last.unitNav === "string" &&
+ last.unitNav.length > 0,
+ JSON.stringify(returns).slice(0, 280)
+ );
+
+ await page.waitForSelector(".returns-panel .metric-strip", { timeout: 15000 });
+ const metricText = ((await page.textContent(".returns-panel .metric-strip")) || "");
+ const noteText = ((await page.textContent(".returns-panel .returns-note")) || "");
+ const markers = await page.locator(".returns-chart circle").count();
+ check(
+ "R3 收益卡片与 API 一致、图表有点且不补零",
+ metricText.includes("总资产") &&
+ metricText.includes("5502.50") &&
+ metricText.includes("累计收益") &&
+ metricText.includes("2.50") &&
+ metricText.includes("数据更新时间") &&
+ markers >= 1 &&
+ noteText.includes("不补零"),
+ `metrics=${metricText.slice(0, 160)} markers=${markers}`
+ );
+ await page.screenshot({ path: SHOTS + "/16-returns-curve.png" });
+}
+
const failed = results.filter((r) => !r.ok);
console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`);
process.exit(failed.length > 0 ? 1 : 0);
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;
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 552a9bd..0024deb 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -26,6 +26,7 @@
<Compile Include="OrderTests.fs" />
<Compile Include="SipAdvanceTests.fs" />
<Compile Include="DividendTests.fs" />
+ <Compile Include="ReturnsTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/ReturnsTests.fs b/tests/FundLab.Api.Tests/ReturnsTests.fs
new file mode 100644
index 0000000..01a5e10
--- /dev/null
+++ b/tests/FundLab.Api.Tests/ReturnsTests.fs
@@ -0,0 +1,233 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Text.Json
+open Npgsql
+open Xunit
+open FundLab.Api
+
+[<Collection("postgres")>]
+type ReturnsTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let seedInstrument () =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+
+ let payload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "收益测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "returns-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "收益测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "returns-fund-%s" (Guid.NewGuid().ToString("N")))
+
+ match repository().CreateFund(key, command) with
+ | FundWriteResult.Created fund -> fund.Id
+ | other -> failwithf "unexpected fund creation result: %A" other
+
+ let app () = App.createApplication (repository ())
+
+ let truncateMicroseconds (moment: DateTimeOffset) =
+ let utc = moment.ToUniversalTime()
+ DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero)
+
+ let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) =
+ let revision = sprintf "akshare-test/%O" (Guid.NewGuid())
+
+ let payload: MarketDataNavPayload =
+ {
+ Source = "akshare"
+ SourceRevision = revision
+ CollectedAt = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = navDate
+ PublishedAt = None
+ Nav = nav
+ AccumulatedNav = Some nav
+ DailyReturn = Some 0.0m
+ }
+ ]
+ }
+
+ repository().UpsertNavObservations(payload, sprintf "returns-hash/%s" revision)
+
+ let confirmedHolding fundId (code: string) (amount: decimal) (tradeDate: DateOnly) =
+ insertQuoteOnDate code 2.5m tradeDate
+
+ let orderKey = fixture.Key(sprintf "returns-hold-%s" (Guid.NewGuid().ToString("N")))
+
+ let order =
+ match
+ repository().CreateSubscriptionOrder(
+ orderKey,
+ fundId,
+ { FundCode = code; Amount = amount; FeeAmount = 0m },
+ tradeDate
+ )
+ with
+ | SubscriptionOrderWriteResult.OrderCreated order
+ | SubscriptionOrderWriteResult.OrderReplayed order -> order
+ | other -> failwithf "unexpected holding order result: %A" other
+
+ match
+ repository().ConfirmSubscriptionOrder(
+ fixture.Key(sprintf "returns-confirm-%s" (Guid.NewGuid().ToString("N"))),
+ fundId,
+ order.Id
+ )
+ with
+ | SubscriptionConfirmResult.OrderConfirmed _ -> ()
+ | confirmResult -> failwithf "unexpected holding confirm: %A" confirmResult
+
+ order
+
+ let insertConfirmedOrderWithoutNav fundId (code: string) (tradeDate: DateOnly) (units: decimal) (amount: decimal) =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+
+ command.CommandText <-
+ """
+ INSERT INTO subscription_orders
+ (id, fund_id, fund_code, amount, fee_amount, reserved_total, status, is_synthetic,
+ submitted_at, trade_date, confirmed_at, confirmed_units, confirmed_invested_cash,
+ confirmed_residual_cash)
+ VALUES
+ (@id, @fund_id, @code, @amount, 0.00, @amount, 'confirmed', false,
+ now(), @trade_date, now(), @units, @amount, 0.00)
+ """
+
+ let idParameter = command.Parameters.Add("id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ idParameter.Value <- box (Guid.NewGuid())
+ let fundParameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ fundParameter.Value <- box fundId
+ let codeParameter = command.Parameters.Add("code", NpgsqlTypes.NpgsqlDbType.Text)
+ codeParameter.Value <- box code
+ let amountParameter = command.Parameters.Add("amount", NpgsqlTypes.NpgsqlDbType.Numeric)
+ amountParameter.Value <- box amount
+ let unitsParameter = command.Parameters.Add("units", NpgsqlTypes.NpgsqlDbType.Numeric)
+ unitsParameter.Value <- box units
+ let dateParameter = command.Parameters.Add("trade_date", NpgsqlTypes.NpgsqlDbType.Date)
+ dateParameter.Value <- box tradeDate
+ command.ExecuteNonQuery() |> ignore
+
+ let getReturns (fundId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/returns" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let pointOn (date: string) (body: string) =
+ use document = JsonDocument.Parse(body)
+
+ document.RootElement.GetProperty("points").EnumerateArray()
+ |> Seq.find (fun point -> point.GetProperty("date").GetString() = date)
+ |> fun point -> point.Clone()
+
+ [<Fact>]
+ member _.``returns series is empty for a fund without activity``() =
+ let fundId = createFund 10000.00m
+ let status, body = getReturns fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"points\":[]", body)
+ Assert.Contains("\"pending\":false", body)
+ Assert.Contains("\"dataUpdatedAt\":null", body)
+
+ [<Fact>]
+ member _.``external deposits issue units without moving the unit nav or creating profit``() =
+ let fundId = createFund 10000.00m
+
+ match
+ repository().CreateCapitalDeposit(
+ fixture.Key(sprintf "returns-deposit-%s" (Guid.NewGuid().ToString("N"))),
+ fundId,
+ { Amount = 5000.00m; Note = None }
+ )
+ with
+ | CapitalDepositWriteResult.CapitalDepositCreated _ -> ()
+ | other -> failwithf "unexpected deposit result: %A" other
+
+ let status, body = getReturns fundId
+ Assert.Equal(200, status)
+
+ let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+ let point = pointOn (today.ToString("yyyy-MM-dd")) body
+
+ Assert.False(point.GetProperty("pending").GetBoolean())
+ Assert.Equal("15000.00", point.GetProperty("totalAssets").GetString())
+ Assert.Equal("15000.00", point.GetProperty("netExternalFlow").GetString())
+ Assert.Equal("0.00", point.GetProperty("cumulativeReturn").GetString())
+ Assert.Equal("1.00000000", point.GetProperty("unitNav").GetString())
+
+ [<Fact>]
+ member _.``returns series revalues holdings on later nav dates``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let tradeDate = DateOnly(2026, 9, 15)
+ let _ = confirmedHolding fundId code 250.00m tradeDate
+ insertQuoteOnDate code 3.0m (DateOnly(2026, 9, 16))
+
+ let status, body = getReturns fundId
+ Assert.Equal(200, status)
+
+ let first = pointOn "2026-09-15" body
+ Assert.Equal("10000.00", first.GetProperty("totalAssets").GetString())
+ Assert.Equal("0.00", first.GetProperty("cumulativeReturn").GetString())
+ Assert.Equal("1.00000000", first.GetProperty("unitNav").GetString())
+
+ let second = pointOn "2026-09-16" body
+ Assert.False(second.GetProperty("pending").GetBoolean())
+ Assert.Equal("10050.00", second.GetProperty("totalAssets").GetString())
+ Assert.Equal("50.00", second.GetProperty("cumulativeReturn").GetString())
+ Assert.Equal("1.00500000", second.GetProperty("unitNav").GetString())
+
+ [<Fact>]
+ member _.``held instrument without a valuation nav is pending and never zero filled``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let tradeDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+ insertConfirmedOrderWithoutNav fundId code tradeDate 100.00000000m 250.00m
+
+ let status, body = getReturns fundId
+ Assert.Equal(200, status)
+ Assert.Contains("\"pending\":true", body)
+ Assert.Contains("\"totalAssets\":null", body)
+ Assert.Contains("\"holdingsValue\":null", body)
+ Assert.Contains("\"cumulativeReturn\":null", body)
+
+ let point = pointOn (tradeDate.ToString("yyyy-MM-dd")) body
+ Assert.True(point.GetProperty("pending").GetBoolean())
+ Assert.Equal(JsonValueKind.Null, point.GetProperty("totalAssets").ValueKind)
+ Assert.Equal("9750.00", point.GetProperty("cash").GetString())
+
+ [<Fact>]
+ member _.``returns request for an unknown fund is not found``() =
+ let status, body = getReturns (Guid.NewGuid())
+ Assert.Equal(404, status)
+ Assert.Contains("FUND_NOT_FOUND", body)
diff --git a/tests/FundLab.Web.Tests/ChartTests.fs b/tests/FundLab.Web.Tests/ChartTests.fs
index cede2d2..0382b0a 100644
--- a/tests/FundLab.Web.Tests/ChartTests.fs
+++ b/tests/FundLab.Web.Tests/ChartTests.fs
@@ -21,3 +21,50 @@ module ChartTests =
Assert.Equal(1.0, points[1].y)
Assert.Equal(0.4, points[2].y)
Assert.Equal("2026-01-03", points[2].navDate)
+
+ [<Fact>]
+ let ``series points keep the shared date axis and normalize each series`` () =
+ let dates = [ "2026-01-01"; "2026-01-02"; "2026-01-03" ]
+
+ let values =
+ [
+ "2026-01-01", "100.00"
+ "2026-01-02", "150.00"
+ "2026-01-03", "125.00"
+ ]
+
+ let points = Chart.seriesPoints dates values
+
+ Assert.Equal(3, points.Length)
+ Assert.Equal(0.0, points[0].x)
+ Assert.Equal(0.5, points[1].x)
+ Assert.Equal(1.0, points[2].x)
+ Assert.Equal(0.0, points[0].y)
+ Assert.Equal(1.0, points[1].y)
+ Assert.Equal(0.5, points[2].y)
+ Assert.Equal("150.00", points[1].value)
+
+ [<Fact>]
+ let ``series points skip missing dates instead of filling zero`` () =
+ let dates = [ "2026-01-01"; "2026-01-02"; "2026-01-03" ]
+
+ let values =
+ [
+ "2026-01-01", "100.00"
+ "2026-01-03", "125.00"
+ ]
+
+ let points = Chart.seriesPoints dates values
+
+ Assert.Equal(2, points.Length)
+ Assert.Equal(0.0, points[0].x)
+ Assert.Equal(1.0, points[1].x)
+ Assert.Equal("2026-01-03", points[1].label)
+
+ [<Fact>]
+ let ``a single point is centered and keeps no line`` () =
+ let points = Chart.seriesPoints [ "2026-01-01" ] [ "2026-01-01", "1.00000000" ]
+
+ Assert.Equal(1, points.Length)
+ Assert.Equal(0.5, points[0].x)
+ Assert.Equal(0.5, points[0].y)