summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 11:44:12 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 11:44:12 +0800
commit4ff91444dc9f29acf6f8459572a6cbf7db676b59 (patch)
treee9b50ffcc57925ef9392d5420455aae6d579b562
parent9818daebaf6fb99124a3196f880b3b8a7998d3a6 (diff)
downloadfund-lab-4ff91444dc9f29acf6f8459572a6cbf7db676b59.tar.gz
leader fix continuation: FS3511 release publish regression (3d-31 continuation)
-rwxr-xr-xqa/run.sh3
-rwxr-xr-xscripts/publish-check.sh30
-rw-r--r--src/FundLab.Api/App.fs318
3 files changed, 200 insertions, 151 deletions
diff --git a/qa/run.sh b/qa/run.sh
index e6f6f86..d28a084 100755
--- a/qa/run.sh
+++ b/qa/run.sh
@@ -88,6 +88,9 @@ fi
log "building API"
dotnet build "$ROOT/src/FundLab.Api/FundLab.Api.fsproj" -v q --nologo >/dev/null
+log "checking Release publish (FS3511 regression guard)"
+bash "$ROOT/scripts/publish-check.sh"
+
log "starting API on 127.0.0.1:$API_PORT (stub interpreter, _synthetic payloads)"
# The stub serves NAV through 2026-09-21 and the driver asserts that trade date, so pin the
# trading-date clock (same test hook the xUnit fixture uses) instead of crossing the shanghai cutoff.
diff --git a/scripts/publish-check.sh b/scripts/publish-check.sh
new file mode 100755
index 0000000..b975d60
--- /dev/null
+++ b/scripts/publish-check.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Release publish regression guard.
+#
+# `dotnet publish -c Release` fails when a resumable state machine cannot be
+# statically compiled (FS3511) because the API project sets
+# TreatWarningsAsErrors=true. Debug builds never emit it, so this check runs the
+# Release publish and fails if it exits non-zero or reports FS3511.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PROJECT="${1:-$ROOT/src/FundLab.Api/FundLab.Api.fsproj}"
+OUT_DIR="$(mktemp -d /tmp/pubcheck-XXXXXX)"
+LOG_FILE="$(mktemp /tmp/pubcheck-XXXXXX.log)"
+trap 'rm -rf "$OUT_DIR" "$LOG_FILE"' EXIT
+
+printf '[publish-check] dotnet publish -c Release %s\n' "$PROJECT"
+
+if ! dotnet publish "$PROJECT" -c Release -o "$OUT_DIR" >"$LOG_FILE" 2>&1; then
+ cat "$LOG_FILE" >&2
+ printf '[publish-check] FAIL: release publish exited non-zero\n' >&2
+ exit 1
+fi
+
+if grep -q "FS3511" "$LOG_FILE"; then
+ grep -n "FS3511" "$LOG_FILE" >&2
+ printf '[publish-check] FAIL: FS3511 present in release publish output\n' >&2
+ exit 1
+fi
+
+printf '[publish-check] PASS: release publish clean (no FS3511)\n'
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 3d89dae..c14ab66 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -3421,6 +3421,168 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "fund valuation failed" next ctx
+ // Synchronous refresh work kept out of the resumable task below so the handler's
+ // state machine is statically compilable (avoids FS3511 under TreatWarningsAsErrors).
+ let private collectMarketRefresh
+ (repository: FundRepository)
+ (marketData: IMarketDataService option)
+ (probes: MarketProbes option)
+ (fundId: Guid)
+ (asOfDate: DateOnly)
+ (token: System.Threading.CancellationToken)
+ : MarketRefreshTargetResponse list * MarketRefreshFailureResponse list =
+ 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 }
+ )
+
+ refreshed |> Seq.toList, failures |> Seq.toList
+
let private refreshFundMarketData
(repository: FundRepository)
(marketData: IMarketDataService option)
@@ -3445,161 +3607,15 @@ module App =
| _ -> 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 refreshed, failures =
+ collectMarketRefresh repository marketData probes fundId asOfDate token
let response: MarketRefreshResponse =
{ fundId = fundId
asOfDate = dateText asOfDate
- refreshed = refreshed |> Seq.toList
- failures = failures |> Seq.toList }
+ refreshed = refreshed
+ failures = failures }
return! json response next ctx
}