diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 05:56:14 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 05:56:14 +0800 |
| commit | 79600c15aef9bd411abe88dcf36286a102b5abea (patch) | |
| tree | e5e3bfbff6f60a48a3c0ff8b78b48da98c8f238a /src | |
| parent | 6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (diff) | |
| download | fund-lab-79600c15aef9bd411abe88dcf36286a102b5abea.tar.gz | |
Add daily market refresh endpoint, snapshot-first valuation and UI refresh (3d-25)
Diffstat (limited to 'src')
| -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 |
4 files changed, 491 insertions, 18 deletions
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, { |
