summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 05:56:14 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 05:56:14 +0800
commit79600c15aef9bd411abe88dcf36286a102b5abea (patch)
treee5e3bfbff6f60a48a3c0ff8b78b48da98c8f238a /src
parent6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (diff)
downloadfund-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.fs283
-rw-r--r--src/FundLab.Api/Persistence.fs130
-rw-r--r--src/FundLab.Web/App.fs89
-rw-r--r--src/FundLab.Web/src/api.js7
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, {