diff options
| -rw-r--r-- | qa/driver/browser-test.js | 79 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 283 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 130 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 89 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 7 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundValuationTests.fs | 127 |
6 files changed, 697 insertions, 18 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index dd49263..b271fbb 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -42,6 +42,7 @@ let lastStockPositionsResponse = null; let lastBondTradeResponse = null; let lastBondPositionsResponse = null; let lastValuationResponse = null; +let lastMarketRefreshResponse = null; let lastSipStatusResponse = null; let lastSipAdvanceResponse = null; @@ -233,6 +234,11 @@ async function summaryLabelExists(page, label, name) { lastValuationResponse = await r.json(); } catch {} } + if (/\/market-data\/refresh$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) { + try { + lastMarketRefreshResponse = await r.json(); + } catch {} + } if (/\/sip\/plans\/[0-9a-f-]+\/(pause|resume)$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) { try { lastSipStatusResponse = await r.json(); @@ -300,6 +306,7 @@ async function summaryLabelExists(page, label, name) { await stockTradeScenario(page); await bondTradeScenario(page); await valuationScenario(page); + await marketRefreshScenario(page); await sipStatusScenario(page); await rebalanceArchiveScenario(page); } finally { @@ -1106,6 +1113,10 @@ async function valuationScenario(page) { { timeout: 10000 } ); + for (let i = 0; i < 200 && lastValuationResponse === null; i++) { + await page.waitForTimeout(100); + } + const valuation = lastValuationResponse || {}; const positions = Array.isArray(valuation.positions) ? valuation.positions : []; const stock = positions.find((item) => item.instrumentCode === "600519") || {}; @@ -1136,6 +1147,74 @@ async function valuationScenario(page) { await page.screenshot({ path: SHOTS + "/26-valuation.png" }); } +async function marketRefreshScenario(page) { + // 3d-25: 刷新行情按钮 -> POST refresh 持久化最新行情 -> 估值改用快照定价。 + await page.waitForSelector(".market-refresh-action", { timeout: 10000 }); + + const buttonText = ((await page.textContent(".market-refresh-action")) || "").trim(); + check("F1 估值面板显示刷新行情按钮", buttonText === "刷新行情", buttonText); + + lastMarketRefreshResponse = null; + lastValuationResponse = null; + await page.click(".market-refresh-action"); + + for (let i = 0; i < 200 && lastMarketRefreshResponse === null; i++) { + await page.waitForTimeout(100); + } + + const refresh = lastMarketRefreshResponse || {}; + const refreshed = Array.isArray(refresh.refreshed) ? refresh.refreshed : []; + const failures = Array.isArray(refresh.failures) ? refresh.failures : []; + const stock = refreshed.find((item) => item.instrumentCode === "600519") || {}; + const bond = refreshed.find((item) => item.instrumentCode === "110075") || {}; + const fund = refreshed.find((item) => item.instrumentCode === "000001") || {}; + + check( + "F2 刷新行情持久化股票/债券/基金最新行情且无失败", + refresh.asOfDate === "2026-09-21" && + failures.length === 0 && + stock.assetClass === "stock" && + stock.snapshotDate === "2026-09-21" && + stock.price === "1252.57000000" && + bond.assetClass === "bond" && + bond.price === "106.36800000" && + fund.assetClass === "fund" && + fund.snapshotDate === "2026-09-21" && + fund.price === "1.19020000", + `refresh=${JSON.stringify(refresh).slice(0, 320)}` + ); + + for (let i = 0; i < 200 && lastValuationResponse === null; i++) { + await page.waitForTimeout(100); + } + + const valuation = lastValuationResponse || {}; + const positions = Array.isArray(valuation.positions) ? valuation.positions : []; + const stockRow = positions.find((item) => item.instrumentCode === "600519") || {}; + const bondRow = positions.find((item) => item.instrumentCode === "110075") || {}; + + check( + "F3 刷新后估值改用持久化快照定价且市值不变", + valuation.asOfDate === "2026-09-21" && + stockRow.status === "priced" && + stockRow.priceSource === "snapshot" && + stockRow.marketValue === "125257.00" && + bondRow.priceSource === "snapshot" && + bondRow.marketValue === "1063.68" && + valuation.pricedPositions === 2, + `valuation=${JSON.stringify(valuation).slice(0, 320)}` + ); + + const messageText = ((await page.textContent(".market-refresh-message")) || "").trim(); + check( + "F4 页面显示刷新结果摘要", + messageText.includes("行情刷新完成") && messageText.includes("2026-09-21"), + messageText.slice(0, 180) + ); + + await page.screenshot({ path: SHOTS + "/29-market-refresh.png" }); +} + async function postInvestmentPlanViaApi(page, fundId, token) { return page.evaluate( async ({ fundId, token }) => { diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 5ff7fdd..44a9afe 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -204,6 +204,7 @@ type FundValuationResponse = { fundId: Guid currency: string + asOfDate: string cash: string positionsMarketValue: string portfolioValue: string @@ -212,6 +213,29 @@ type FundValuationResponse = positions: ValuationPositionResponse list } +type MarketRefreshTargetResponse = + { + instrumentCode: string + assetClass: string + snapshotDate: string + price: string + } + +type MarketRefreshFailureResponse = + { + instrumentCode: string + assetClass: string + reason: string + } + +type MarketRefreshResponse = + { + fundId: Guid + asOfDate: string + refreshed: MarketRefreshTargetResponse list + failures: MarketRefreshFailureResponse list + } + type SipPlanResponse = { id: Guid @@ -1850,6 +1874,13 @@ module App = errorResponse status error message + let private marketDataErrorText (failure: MarketDataFailure) = + match failure with + | InvalidMarketDataRequest message -> message + | MarketDataCollectorUnavailable message -> message + | InvalidMarketDataPayload message -> message + | MarketDataPersistenceFailure message -> message + let private marketDataInstrumentResponse (instrument: MarketDataInstrument) = { code = instrument.Code @@ -2199,21 +2230,21 @@ module App = (code: string) (fallbackName: string option) (quantity: decimal) - (livePrice: decimal option) + (resolvedPrice: (string * decimal) option) = let resolvedName = match fallbackName with | Some name when not (String.IsNullOrWhiteSpace name) -> Some name | _ -> None - match livePrice with - | Some price -> + match resolvedPrice with + | Some(source, price) -> { instrumentCode = code name = resolvedName assetClass = assetClass quantity = decimalText quantity price = Some(decimalText price) - priceSource = Some "live" + priceSource = Some source marketValue = Some(cashText (Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero))) status = "priced" } | None -> @@ -2226,44 +2257,75 @@ module App = marketValue = None status = "unavailable" } + /// Price precedence for a valuation row: a persisted daily snapshot on or + /// before the valuation date wins (it is the reproducible daily close), and + /// a live quote is only a fallback for instruments not yet refreshed. + let private resolveValuationPrice + (snapshotPrice: decimal option) + (livePrice: unit -> Result<decimal option, MarketDataFailure>) + = + match snapshotPrice with + | Some price -> Some("snapshot", price) + | None -> + match livePrice () with + | Ok(Some price) -> Some("live", price) + | _ -> None + let private getFundValuation (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler = fun next ctx -> match Guid.TryParse fundIdText with | false, _ -> errorResponse 400 "INVALID_VALUATION_REQUEST" "fund id must be a UUID" next ctx | true, fundId -> + let asOfDate = + let raw = ctx.Request.Query["asOfDate"].ToString() + if String.IsNullOrWhiteSpace raw then + ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + else + match DateOnly.TryParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date -> date + | _ -> ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + try match repository.GetFund fundId with | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx | Some fund -> let token = ctx.RequestAborted + let stockSnapshots = repository.GetLatestSnapshots(fundId, "stock", asOfDate) + let bondSnapshots = repository.GetLatestSnapshots(fundId, "bond", asOfDate) let priceOf (probe: unit -> Result<decimal option, MarketDataFailure>) = if probes.IsNone then - None + Error(MarketDataCollectorUnavailable "market probes are not configured") else - match probe () with - | Ok(Some price) -> Some price - | _ -> None + probe () let stockRows = repository.GetStockPositions fundId |> List.map (fun position -> - let live = - priceOf (fun () -> - probes.Value.StockQuotes.GetQuote(position.InstrumentCode, token) - |> Result.map (fun quote -> quote.Price)) + let snapshotPrice = + stockSnapshots |> Map.tryFind position.InstrumentCode |> Option.map (fun snap -> snap.Price) + + let resolved = + resolveValuationPrice snapshotPrice (fun () -> + priceOf (fun () -> + probes.Value.StockQuotes.GetQuote(position.InstrumentCode, token) + |> Result.map (fun quote -> quote.Price))) - valuationPositionResponse "stock" position.InstrumentCode position.StockName position.Quantity live) + valuationPositionResponse "stock" position.InstrumentCode position.StockName position.Quantity resolved) let bondRows = repository.GetBondPositions fundId |> List.map (fun position -> - let live = - priceOf (fun () -> - probes.Value.BondQuotes.GetQuote(position.InstrumentCode, token) - |> Result.map (fun quote -> quote.Price)) + let snapshotPrice = + bondSnapshots |> Map.tryFind position.InstrumentCode |> Option.map (fun snap -> snap.Price) + + let resolved = + resolveValuationPrice snapshotPrice (fun () -> + priceOf (fun () -> + probes.Value.BondQuotes.GetQuote(position.InstrumentCode, token) + |> Result.map (fun quote -> quote.Price))) - valuationPositionResponse "bond" position.InstrumentCode position.BondName position.Quantity live) + valuationPositionResponse "bond" position.InstrumentCode position.BondName position.Quantity resolved) let positions = stockRows @ bondRows @@ -2281,6 +2343,7 @@ module App = { fundId = fund.Id currency = fund.Currency + asOfDate = dateText asOfDate cash = cashText fund.AvailableCash positionsMarketValue = cashText positionsMarketValue portfolioValue = cashText (fund.AvailableCash + positionsMarketValue) @@ -2293,6 +2356,189 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "fund valuation failed" next ctx + let private refreshFundMarketData + (repository: FundRepository) + (marketData: IMarketDataService option) + (probes: MarketProbes option) + (fundIdText: string) + : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_MARKET_REFRESH_REQUEST" "fund id must be a UUID" next ctx + | true, fundId -> + task { + match repository.GetFund fundId with + | None -> return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + | Some _ -> + let asOfDate = + let raw = ctx.Request.Query["asOfDate"].ToString() + if String.IsNullOrWhiteSpace raw then + ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + else + match DateOnly.TryParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date -> date + | _ -> ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + + let token = ctx.RequestAborted + let refreshed = ResizeArray<MarketRefreshTargetResponse>() + let failures = ResizeArray<MarketRefreshFailureResponse>() + + let snapshotDateOf (raw: string option) = + match raw with + | Some text -> + match DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date when date <= asOfDate -> date + | _ -> asOfDate + | None -> asOfDate + + // Stocks: persist the latest daily close on or before the refresh date. + for position in repository.GetStockPositions fundId do + match probes with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "stock" + reason = "market probes are not configured" } + ) + | Some probeSet -> + match probeSet.StockDaily.RecentDaily(position.InstrumentCode, 30, token) with + | Error failure -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "stock" + reason = marketDataErrorText failure } + ) + | Ok observations -> + let latest = + observations + |> List.filter (fun observation -> observation.BarDate <= asOfDate) + |> List.sortByDescending (fun observation -> observation.BarDate) + |> List.tryHead + + match latest with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "stock" + reason = "no daily bar on or before the refresh date" } + ) + | Some bar -> + let snapshot : InstrumentSnapshotRecord = + { InstrumentCode = position.InstrumentCode + AssetClass = "stock" + SnapshotDate = bar.BarDate + Price = bar.Close + Source = "akshare" + SourceRevision = "stock-daily" + SourceCollectedAt = DateTimeOffset.UtcNow + SourcePayloadHash = sprintf "stock-daily:%s:%s" position.InstrumentCode (bar.BarDate.ToString("yyyy-MM-dd")) } + + repository.UpsertInstrumentSnapshots [ snapshot ] + + refreshed.Add( + { instrumentCode = position.InstrumentCode + assetClass = "stock" + snapshotDate = dateText bar.BarDate + price = decimalText bar.Close } + ) + + // Bonds: persist the latest valuation price. + for position in repository.GetBondPositions fundId do + match probes with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "bond" + reason = "market probes are not configured" } + ) + | Some probeSet -> + match probeSet.BondQuotes.GetQuote(position.InstrumentCode, token) with + | Error failure -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "bond" + reason = marketDataErrorText failure } + ) + | Ok quote -> + match (quote.Price |> Option.orElse quote.CleanPrice) with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "bond" + reason = "bond quote has no valuation price" } + ) + | Some price -> + let snapshotDate = + quote.Date |> Option.map (fun date -> date.ToString("yyyy-MM-dd")) |> snapshotDateOf + + let snapshot : InstrumentSnapshotRecord = + { InstrumentCode = position.InstrumentCode + AssetClass = "bond" + SnapshotDate = snapshotDate + Price = price + Source = "akshare" + SourceRevision = "bond-quote" + SourceCollectedAt = DateTimeOffset.UtcNow + SourcePayloadHash = sprintf "bond-quote:%s:%s" position.InstrumentCode (snapshotDate.ToString("yyyy-MM-dd")) } + + repository.UpsertInstrumentSnapshots [ snapshot ] + + refreshed.Add( + { instrumentCode = position.InstrumentCode + assetClass = "bond" + snapshotDate = dateText snapshotDate + price = decimalText price } + ) + + // Held funds: refresh their published NAV history so the + // fund-level NAV advances with the same date. + for position in repository.GetFundPositions fundId do + match marketData with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "fund" + reason = "market data service is not configured" } + ) + | Some service -> + match service.RefreshNav(position.InstrumentCode, token) with + | Error failure -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "fund" + reason = marketDataErrorText failure } + ) + | Ok observations -> + let latest = + observations + |> List.filter (fun observation -> observation.NavDate <= asOfDate) + |> List.sortByDescending (fun observation -> observation.NavDate) + |> List.tryHead + + match latest with + | None -> + failures.Add( + { instrumentCode = position.InstrumentCode + assetClass = "fund" + reason = "no nav observation on or before the refresh date" } + ) + | Some observation -> + refreshed.Add( + { instrumentCode = position.InstrumentCode + assetClass = "fund" + snapshotDate = dateText observation.NavDate + price = decimalText observation.Nav } + ) + + let response: MarketRefreshResponse = + { fundId = fundId + asOfDate = dateText asOfDate + refreshed = refreshed |> Seq.toList + failures = failures |> Seq.toList } + + return! json response next ctx + } + let private marketProbeRoutes (probes: MarketProbes) = [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates @@ -2342,6 +2588,7 @@ module App = POST >=> routef "/funds/%s/bond-trades" (createBondTrade repository probes) GET >=> routef "/funds/%s/bond-positions" (getBondPositions repository) GET >=> routef "/funds/%s/valuation" (getFundValuation repository probes) + POST >=> routef "/funds/%s/market-data/refresh" (fun fundId -> refreshFundMarketData repository marketData probes fundId) 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 379c10d..09e3e0e 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -358,6 +358,18 @@ type BondPositionRecord = LastTradedAt: DateTimeOffset } +type InstrumentSnapshotRecord = + { + InstrumentCode: string + AssetClass: string + SnapshotDate: DateOnly + Price: decimal + Source: string + SourceRevision: string + SourceCollectedAt: DateTimeOffset + SourcePayloadHash: string + } + type BondTradeWriteResult = | BondTradeCreated of BondTradeRecord | BondTradeReplayed of BondTradeRecord @@ -973,6 +985,23 @@ type FundRepository(connectionString: string) = PRIMARY KEY (fund_id, instrument_code) ); + CREATE TABLE IF NOT EXISTS instrument_snapshots ( + instrument_code text NOT NULL, + asset_class text NOT NULL CHECK (asset_class IN ('stock', 'bond')), + snapshot_date date NOT NULL, + price numeric(28, 8) NOT NULL CHECK (price > 0), + source text NOT NULL, + source_revision text NOT NULL, + source_collected_at timestamptz NOT NULL, + source_payload_hash text NOT NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (instrument_code, asset_class, snapshot_date) + ); + + CREATE INDEX IF NOT EXISTS instrument_snapshots_date_idx + ON instrument_snapshots (instrument_code, asset_class, snapshot_date DESC); + CREATE TABLE IF NOT EXISTS dividend_idempotencies ( idempotency_key text PRIMARY KEY, request_hash text NOT NULL, @@ -2768,6 +2797,107 @@ type FundRepository(connectionString: string) = records |> Seq.toList + member _.UpsertInstrumentSnapshots(records: InstrumentSnapshotRecord list) = + if records.IsEmpty then + () + else + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + for record in records do + use command = + commandWithTransaction + connection + (Some transaction) + """ + INSERT INTO instrument_snapshots + (instrument_code, asset_class, snapshot_date, price, source, + source_revision, source_collected_at, source_payload_hash) + VALUES + (@instrument_code, @asset_class, @snapshot_date, @price, @source, + @source_revision, @source_collected_at, @source_payload_hash) + ON CONFLICT (instrument_code, asset_class, snapshot_date) DO UPDATE SET + price = EXCLUDED.price, + source = EXCLUDED.source, + source_revision = EXCLUDED.source_revision, + source_collected_at = EXCLUDED.source_collected_at, + source_payload_hash = EXCLUDED.source_payload_hash, + last_seen_at = now(), + first_seen_at = CASE + WHEN instrument_snapshots.source_payload_hash = EXCLUDED.source_payload_hash + THEN instrument_snapshots.first_seen_at + ELSE now() + END + """ + + addParameter command "instrument_code" NpgsqlDbType.Text (box record.InstrumentCode) |> ignore + addParameter command "asset_class" NpgsqlDbType.Text (box record.AssetClass) |> ignore + addParameter command "snapshot_date" NpgsqlDbType.Date (box record.SnapshotDate) |> ignore + addParameter command "price" NpgsqlDbType.Numeric (box record.Price) |> ignore + addParameter command "source" NpgsqlDbType.Text (box record.Source) |> ignore + addParameter command "source_revision" NpgsqlDbType.Text (box record.SourceRevision) |> ignore + addParameter command "source_collected_at" NpgsqlDbType.TimestampTz (box record.SourceCollectedAt) |> ignore + addParameter command "source_payload_hash" NpgsqlDbType.Text (box record.SourcePayloadHash) |> ignore + command.ExecuteNonQuery() |> ignore + + transaction.Commit() + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + + /// Latest snapshot price on or before `asOfDate` for every instrument of the + /// given asset class held by the fund. Instruments without any snapshot are + /// omitted so a caller can tell "no snapshot yet" from a stored price. + member _.GetLatestSnapshots(fundId: Guid, assetClass: string, asOfDate: DateOnly) : Map<string, InstrumentSnapshotRecord> = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use command = + commandWithTransaction + connection + None + """ + SELECT s.instrument_code, s.asset_class, s.snapshot_date, s.price, s.source, + s.source_revision, s.source_collected_at, s.source_payload_hash + FROM instrument_snapshots s + JOIN ( + SELECT instrument_code, MAX(snapshot_date) AS snapshot_date + FROM instrument_snapshots + WHERE asset_class = @asset_class AND snapshot_date <= @as_of_date + GROUP BY instrument_code + ) latest + ON latest.instrument_code = s.instrument_code + AND latest.snapshot_date = s.snapshot_date + WHERE s.asset_class = @asset_class + """ + + addParameter command "asset_class" NpgsqlDbType.Text (box assetClass) |> ignore + addParameter command "as_of_date" NpgsqlDbType.Date (box asOfDate) |> ignore + use reader = command.ExecuteReader() + let records = System.Collections.Generic.Dictionary<string, InstrumentSnapshotRecord>() + + while reader.Read() do + let record : InstrumentSnapshotRecord = + { InstrumentCode = reader.GetString(0) + AssetClass = reader.GetString(1) + SnapshotDate = reader.GetFieldValue<DateOnly>(2) + Price = reader.GetDecimal(3) + Source = reader.GetString(4) + SourceRevision = reader.GetString(5) + SourceCollectedAt = reader.GetFieldValue<DateTimeOffset>(6) + SourcePayloadHash = reader.GetString(7) } + + records.[record.InstrumentCode] <- record + + records + |> Seq.map (fun pair -> pair.Key, pair.Value) + |> Map.ofSeq + member _.CreateFund(idempotencyKey: string, command: FundCreateCommand) = if String.IsNullOrWhiteSpace idempotencyKey then FundWriteResult.Invalid "idempotency key cannot be empty" diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 7dea8bb..fe16411 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -505,6 +505,29 @@ type RawFundValuation = positions: RawValuationPosition array } +type RawMarketRefreshTarget = + { + instrumentCode: string + assetClass: string + snapshotDate: string + price: string + } + +type RawMarketRefreshFailure = + { + instrumentCode: string + assetClass: string + reason: string + } + +type RawMarketRefresh = + { + fundId: string + asOfDate: string + refreshed: RawMarketRefreshTarget array + failures: RawMarketRefreshFailure array + } + type CreateAttempt = { idempotencyKey: string @@ -996,6 +1019,9 @@ module Api = [<Import("getFundValuation", "./src/api.js")>] let getFundValuation (token: string) (fundId: string) : JS.Promise<RawFundValuation> = jsNative + [<Import("refreshFundMarketData", "./src/api.js")>] + let refreshFundMarketData (token: string) (fundId: string) : JS.Promise<RawMarketRefresh> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -1492,6 +1518,9 @@ type Model = valuationInFlight: bool valuation: FundValuationView option valuationMessage: string option + marketRefreshSeq: int + marketRefreshInFlight: bool + marketRefreshMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1627,6 +1656,9 @@ type Msg = | ValuationReadRequested | ValuationReadCompleted of requestId: int * fundId: string * valuation: RawFundValuation | ValuationReadFailed of requestId: int * fundId: string * message: string + | MarketRefreshRequested + | MarketRefreshCompleted of requestId: int * fundId: string * response: RawMarketRefresh + | MarketRefreshFailed of requestId: int * fundId: string * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1797,6 +1829,9 @@ let init () = valuationInFlight = false valuation = None valuationMessage = None + marketRefreshSeq = 0 + marketRefreshInFlight = false + marketRefreshMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -2058,6 +2093,13 @@ let private readValuationCommand token fundId requestId = (fun valuation -> ValuationReadCompleted(requestId, fundId, valuation)) (fun error -> ValuationReadFailed(requestId, fundId, errorText error)) +let private refreshMarketDataCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.refreshFundMarketData token fundId) + () + (fun response -> MarketRefreshCompleted(requestId, fundId, response)) + (fun error -> MarketRefreshFailed(requestId, fundId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -3710,6 +3752,42 @@ let update message model = Cmd.none else model, Cmd.none + | MarketRefreshRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.marketRefreshSeq + 1 + + { + model with + marketRefreshSeq = requestId + marketRefreshInFlight = true + marketRefreshMessage = None + }, + refreshMarketDataCommand model.token fund.id requestId + | _ -> model, Cmd.none + | MarketRefreshCompleted (requestId, fundId, response) -> + if requestId = model.marketRefreshSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + marketRefreshInFlight = false + marketRefreshMessage = + Some(sprintf "行情刷新完成(%s):更新 %d 项,失败 %d 项" response.asOfDate response.refreshed.Length response.failures.Length) + }, + Cmd.ofMsg ValuationReadRequested + else + model, Cmd.none + | MarketRefreshFailed (requestId, fundId, message) -> + if requestId = model.marketRefreshSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + marketRefreshInFlight = false + marketRefreshMessage = Some(sprintf "行情刷新失败 — %s" message) + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -5743,8 +5821,19 @@ let private stocksPanel model dispatch = prop.onClick (fun _ -> dispatch ValuationReadRequested) prop.text ((if model.valuationInFlight then "估值中..." else "刷新估值"): string) ] + Html.button [ + prop.className "secondary-action market-refresh-action" + prop.disabled model.marketRefreshInFlight + prop.onClick (fun _ -> dispatch MarketRefreshRequested) + prop.text ((if model.marketRefreshInFlight then "刷新中..." else "刷新行情"): string) + ] ] ] + Html.p [ + prop.className "market-refresh-message" + prop.hidden (model.marketRefreshMessage.IsNone) + prop.text (model.marketRefreshMessage |> Option.defaultValue "") + ] match model.valuation with | Some valuation -> Html.div [ diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index a03b97f..eaa8978 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -183,6 +183,13 @@ export function getFundValuation(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/valuation`, token); } +export function refreshFundMarketData(token, fundId, asOfDate) { + const suffix = asOfDate ? `?asOfDate=${encodeURIComponent(asOfDate)}` : ""; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/market-data/refresh${suffix}`, token, { + method: "POST", + }); +} + 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/tests/FundLab.Api.Tests/FundValuationTests.fs b/tests/FundLab.Api.Tests/FundValuationTests.fs index 0cfee18..1897fd3 100644 --- a/tests/FundLab.Api.Tests/FundValuationTests.fs +++ b/tests/FundLab.Api.Tests/FundValuationTests.fs @@ -42,6 +42,28 @@ module FundValuationSupport = StockQuotes = stockProbeReturning stockPrices StockDaily = NavDateProbeSupport.stubStockDailyProbe } + let emptyStockDailyProbe = + { new IStockDailyProbe with + member _.RecentDaily(_, _, _) = Ok [] } + + let stockDailyProbeReturning (bars: Map<string, StockDailyObservation list>) = + { new IStockDailyProbe with + member _.RecentDaily(code, _, _) = + match bars.TryFind code with + | Some observations -> Ok observations + | None -> Error(MarketDataCollectorUnavailable(sprintf "no daily bars for %s" code)) } + + let probesWithDaily + (stockPrices: Map<string, decimal>) + (bondPrices: Map<string, decimal>) + (stockDaily: IStockDailyProbe) + = + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = bondProbeReturning bondPrices + StockQuotes = stockProbeReturning stockPrices + StockDaily = stockDaily } + [<Collection("postgres")>] type FundValuationTests(fixture: PostgresFixture) = let sharedRepository = @@ -176,3 +198,108 @@ type FundValuationTests(fixture: PostgresFixture) = let status, body = getValuation Map.empty Map.empty (Guid.NewGuid()) Assert.Equal(404, status) Assert.Contains("FUND_NOT_FOUND", body) + + [<Fact>] + member _.``refresh persists a stock daily close and valuation prefers that snapshot over a live quote``() = + let fundId = createFund 10000.00m + buyStock fundId "601398" 100m 1000.00m + + let daily : StockDailyObservation = + { + BarDate = DateOnly(2026, 9, 21) + Close = 1111.00m + Volume = None + Amount = None + } + + let dailyProbe = + FundValuationSupport.stockDailyProbeReturning (Map.ofList [ "601398", [ daily ] ]) + + let appWithDaily = + App.createApplicationWithProbes + (repository ()) + (FundValuationSupport.probesWithDaily Map.empty Map.empty dailyProbe) + + let refreshStatus, refreshBody = + PersistenceTestHelpers.invoke + appWithDaily + "POST" + (sprintf "/api/funds/%O/market-data/refresh" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, refreshStatus) + Assert.Contains("\"asOfDate\":\"2026-09-21\"", refreshBody) + Assert.Contains("\"instrumentCode\":\"601398\"", refreshBody) + Assert.Contains("\"assetClass\":\"stock\"", refreshBody) + Assert.Contains("\"snapshotDate\":\"2026-09-21\"", refreshBody) + Assert.Contains("\"price\":\"1111.00000000\"", refreshBody) + Assert.Contains("\"failures\":[]", refreshBody) + + // A deliberately different live quote must not override the persisted close. + let status, body = getValuation (Map.ofList [ "601398", 9999.00m ]) Map.empty fundId + + Assert.Equal(200, status) + Assert.Contains("\"positionsMarketValue\":\"111100.00\"", body) + Assert.Contains("\"priceSource\":\"snapshot\"", body) + + [<Fact>] + member _.``refresh marks instruments without market data as failures and never fabricates a price``() = + let fundId = createFund 5000.00m + buyStock fundId "600000" 100m 1000.00m + buyBond fundId "110059" 10m 100.00m + + let appMissing = + App.createApplicationWithProbes + (repository ()) + (FundValuationSupport.probesWithDaily Map.empty Map.empty FundValuationSupport.emptyStockDailyProbe) + + let refreshStatus, refreshBody = + PersistenceTestHelpers.invoke + appMissing + "POST" + (sprintf "/api/funds/%O/market-data/refresh" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, refreshStatus) + Assert.Contains("\"refreshed\":[]", refreshBody) + Assert.Contains("\"instrumentCode\":\"600000\"", refreshBody) + Assert.Contains("\"reason\":\"no daily bar on or before the refresh date\"", refreshBody) + Assert.Contains("\"instrumentCode\":\"110059\"", refreshBody) + Assert.Contains("\"reason\":\"no valid quote for 110059\"", refreshBody) + + let status, body = getValuation Map.empty Map.empty fundId + + Assert.Equal(200, status) + Assert.Contains("\"positionsMarketValue\":\"0.00\"", body) + Assert.Contains("\"unavailablePositions\":2", body) + Assert.Contains("\"status\":\"unavailable\"", body) + + [<Fact>] + member _.``refresh persists a bond valuation price and valuation prefers that snapshot``() = + let fundId = createFund 8000.00m + buyBond fundId "113050" 10m 100.00m + + let appWithBond = + App.createApplicationWithProbes + (repository ()) + (FundValuationSupport.probes Map.empty (Map.ofList [ "113050", 106.368m ])) + + let refreshStatus, refreshBody = + PersistenceTestHelpers.invoke + appWithBond + "POST" + (sprintf "/api/funds/%O/market-data/refresh" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, refreshStatus) + Assert.Contains("\"assetClass\":\"bond\"", refreshBody) + Assert.Contains("\"price\":\"106.36800000\"", refreshBody) + + let status, body = getValuation Map.empty (Map.ofList [ "113050", 200.00m ]) fundId + + Assert.Equal(200, status) + Assert.Contains("\"positionsMarketValue\":\"1063.68\"", body) + Assert.Contains("\"priceSource\":\"snapshot\"", body) |
