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 | |
| parent | 4c60174fc66927db5536bc2fe06574c753140055 (diff) | |
| download | fund-lab-12c4d3625458c830a2746a1f277fb684e9c498cb.tar.gz | |
Add returns and charts slice (3d-9b)
| -rw-r--r-- | qa/driver/browser-test.js | 54 | ||||
| -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 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/ReturnsTests.fs | 233 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/ChartTests.fs | 47 |
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) |
