summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 00:19:09 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 00:19:09 +0800
commit0597ac74757e223c17a6477ff49d69a559168279 (patch)
tree3ab8934481bf9aaef89163fd216f15248fe118ac
parent36ee23a0c406c16e975276238a861299899ec79a (diff)
downloadfund-lab-0597ac74757e223c17a6477ff49d69a559168279.tar.gz
Add real AKShare NAV-date probe slice (3d-12)
-rw-r--r--qa/driver/browser-test.js63
-rwxr-xr-xqa/stub/fund-lab-python6
-rw-r--r--scripts/probe_akshare_nav_dates.py81
-rw-r--r--src/FundLab.Api/App.fs39
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj5
-rw-r--r--src/FundLab.Api/NavDateProbe.fs57
-rw-r--r--src/FundLab.Api/Persistence.fs20
-rw-r--r--src/FundLab.Api/Program.fs3
-rw-r--r--src/FundLab.Web/App.fs143
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css21
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/NavDateProbeTests.fs248
13 files changed, 675 insertions, 16 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
index c7ee468..76fa69b 100644
--- a/qa/driver/browser-test.js
+++ b/qa/driver/browser-test.js
@@ -32,6 +32,7 @@ let lastRebalancePlansResponse = null;
let lastRebalancePreviewResponse = null;
let lastRebalanceExecuteResponse = null;
let lastRebalanceExecutionsResponse = null;
+let lastNavDatesResponse = null;
function check(name, ok, detail) {
results.push({ name, ok, detail: detail || "" });
@@ -171,7 +172,12 @@ async function summaryLabelExists(page, label, name) {
lastRebalanceExecutionsResponse = await r.json();
} catch {}
}
- if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) {
+ if (r.url().includes("/market/nav-dates") && r.request().method() === "GET" && r.status() < 400) {
+ try {
+ lastNavDatesResponse = await r.json();
+ } catch {}
+ }
+ if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) {
consoleErrors.push("resource " + r.status() + ": " + r.url());
}
});
@@ -218,6 +224,7 @@ async function summaryLabelExists(page, label, name) {
await capitalScenario(page);
await dividendScenario(page);
await returnsScenario(page);
+ await navDatesScenario(page);
await plansScenario(page);
await rebalanceScenario(page);
} finally {
@@ -501,6 +508,60 @@ async function returnsScenario(page) {
await page.screenshot({ path: SHOTS + "/16-returns-curve.png" });
}
+async function navDatesScenario(page) {
+ // R1: 真实 AKShare 净值日期下拉(可用时显示真实日期,不可用时显示提示)。
+ await page.waitForSelector(".returns-panel .returns-date-picker", { timeout: 10000 });
+
+ const token = process.env.QA_TOKEN || "qa-token";
+
+ for (let i = 0; i < 200 && lastNavDatesResponse === null; i++) {
+ await page.waitForTimeout(100);
+ }
+
+ const navDates = lastNavDatesResponse || {};
+ const dates = Array.isArray(navDates.dates) ? navDates.dates : [];
+ const pickerState = await page.getAttribute(".returns-date-picker", "data-nav-dates-state");
+ const optionValues = await page
+ .locator(".returns-date-input option")
+ .evaluateAll((elements) => elements.map((element) => element.value));
+ const emptyExists = (await page.locator(".returns-date-empty").count()) === 1;
+ const emptyText = ((await page.textContent(".returns-date-empty")) || "").trim();
+
+ check(
+ "R1a 真实可用时下拉显示真实净值日期",
+ navDates.code === "000001" &&
+ dates.includes("2026-09-21") &&
+ pickerState === "available" &&
+ optionValues.includes("2026-09-21") &&
+ emptyExists &&
+ emptyText === "暂无真实净值数据",
+ `state=${pickerState} dates=${JSON.stringify(dates).slice(0, 160)} options=${JSON.stringify(optionValues).slice(0, 160)}`
+ );
+
+ const failure = await page.evaluate(async (token) => {
+ const response = await fetch("/api/market/nav-dates?code=999999", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ let body = null;
+ try {
+ body = await response.json();
+ } catch {}
+ return { status: response.status, body };
+ }, token);
+
+ check(
+ "R1b 不可用时返回 503 且保留提示路径",
+ failure.status === 503 &&
+ failure.body &&
+ failure.body.error === "MARKET_DATA_UNAVAILABLE" &&
+ emptyExists &&
+ emptyText === "暂无真实净值数据",
+ `status=${failure.status} body=${JSON.stringify(failure.body).slice(0, 160)}`
+ );
+
+ await page.screenshot({ path: SHOTS + "/19-nav-dates.png" });
+}
+
async function postInvestmentPlanViaApi(page, fundId, token) {
return page.evaluate(
async ({ fundId, token }) => {
diff --git a/qa/stub/fund-lab-python b/qa/stub/fund-lab-python
index 9cdfa6a..cefcecd 100755
--- a/qa/stub/fund-lab-python
+++ b/qa/stub/fund-lab-python
@@ -23,6 +23,12 @@ case "$OP" in
printf '{"schema_version":"fund-lab.akshare.v1","operation":"search","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instruments":[{"code":"000001","name":"桩测试基金一号","fund_type":"混合型"},{"code":"110011","name":"桩测试基金二号","fund_type":null}]}\n' "$TS"
;;
nav)
+ # Sentinel code used by QA to exercise the AKShare-unavailable branch of
+ # GET /api/market/nav-dates (collector exits non-zero -> API answers 503).
+ if [ "$CODE" = "999999" ]; then
+ echo "AKShare collector failed: simulated outage for $CODE" >&2
+ exit 2
+ fi
printf '{"schema_version":"fund-lab.akshare.v1","operation":"nav","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"observations":[{"nav_date":"2026-09-15","published_at":null,"nav":"1.1000","accumulated_nav":null,"daily_return":null},{"nav_date":"2026-09-16","published_at":null,"nav":"1.1205","accumulated_nav":null,"daily_return":"1.86"},{"nav_date":"2026-09-17","published_at":null,"nav":"1.1180","accumulated_nav":null,"daily_return":"-0.22"},{"nav_date":"2026-09-18","published_at":null,"nav":"1.1500","accumulated_nav":null,"daily_return":"2.86"},{"nav_date":"2026-09-21","published_at":null,"nav":"1.1902","accumulated_nav":null,"daily_return":"3.49"}]}\n' "$TS" "$CODE"
;;
*)
diff --git a/scripts/probe_akshare_nav_dates.py b/scripts/probe_akshare_nav_dates.py
new file mode 100644
index 0000000..255cf91
--- /dev/null
+++ b/scripts/probe_akshare_nav_dates.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+"""Runtime probe for real AKShare NAV dates.
+
+This is the "can the host actually reach AKShare?" check that backs the
+`GET /api/market/nav-dates` endpoint. It talks to AKShare directly (no mocks),
+memoises the akshare frame lookup with functools.lru_cache, and prints a single
+JSON object on success:
+
+ {"code": "000001", "dates": ["2026-09-21", "2026-09-18", ...]}
+
+On any failure (missing dependency, network error, unusable payload) it writes a
+clear message to stderr and exits non-zero so callers can treat it as a skipped /
+unavailable probe instead of inventing data.
+"""
+
+import argparse
+import functools
+import json
+import sys
+
+import akshare as ak
+
+
[email protected]_cache(maxsize=8)
+def fetch_nav_frame(code: str):
+ """Fetch the since-inception unit-NAV frame for a fund (cached per code)."""
+ return ak.fund_open_fund_info_em(
+ symbol=code,
+ indicator="单位净值走势",
+ period="成立来",
+ )
+
+
+def recent_nav_dates(code: str, limit: int = 5):
+ """Return the most recent distinct NAV dates (descending) for a fund code."""
+ if len(code) != 6 or not code.isdigit():
+ raise ValueError("fund code must contain exactly six digits")
+
+ frame = fetch_nav_frame(code)
+ if frame is None or frame.empty:
+ raise RuntimeError(f"AKShare returned no NAV observations for {code}")
+
+ column = "净值日期"
+ if column not in frame.columns:
+ raise RuntimeError(f"AKShare payload for {code} is missing the {column} column")
+
+ dates = []
+ for value in frame[column].tolist():
+ text = str(value).strip()[:10]
+ if len(text) == 10 and text not in dates:
+ dates.append(text)
+
+ dates.sort(reverse=True)
+ return dates[:limit]
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Probe real AKShare NAV dates for a fund code",
+ )
+ parser.add_argument("--code", default="000001")
+ parser.add_argument("--limit", type=int, default=5)
+ args = parser.parse_args()
+
+ try:
+ dates = recent_nav_dates(args.code, args.limit)
+ except Exception as error: # noqa: BLE001 - probe reports the concrete reason
+ print(f"AKShare NAV-date probe failed for {args.code}: {error}", file=sys.stderr)
+ return 2
+
+ if not dates:
+ print(f"AKShare NAV-date probe found no dates for {args.code}", file=sys.stderr)
+ return 2
+
+ json.dump({"code": args.code, "dates": dates}, sys.stdout, ensure_ascii=False)
+ sys.stdout.write("\n")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 34b4f2d..370f503 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -331,6 +331,12 @@ type MarketDataNavApiResponse =
observations: MarketDataObservationApiResponse list
}
+type MarketNavDatesApiResponse =
+ {
+ code: string
+ dates: string list
+ }
+
module App =
let addOptionFriendlyJson (services: IServiceCollection) =
let settings =
@@ -1634,7 +1640,23 @@ module App =
GET >=> routef "/instruments/%s/nav" (getNav marketData)
]
- let private createApplicationInternal (repository: FundRepository) (marketData: IMarketDataService option) : HttpHandler =
+ let private getMarketNavDates (probe: INavDateProbe) : HttpHandler =
+ fun next ctx ->
+ let code = ctx.Request.Query["code"].ToString()
+
+ match probe.RecentNavDates(code, 5, ctx.RequestAborted) with
+ | Ok dates ->
+ json ({ code = code.Trim(); dates = dates |> List.map dateText } : MarketNavDatesApiResponse) next ctx
+ | Error failure -> marketDataError failure next ctx
+
+ let private navDateProbeRoutes (probe: INavDateProbe) =
+ [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probe ]
+
+ let private createApplicationInternal
+ (repository: FundRepository)
+ (marketData: IMarketDataService option)
+ (navDateProbe: INavDateProbe option)
+ : HttpHandler =
let apiRoutes =
[
GET >=> route "/portfolio/summary" >=> emptyPortfolio
@@ -1665,6 +1687,7 @@ module App =
GET >=> routef "/funds/%s" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])
+ @ (navDateProbe |> Option.map navDateProbeRoutes |> Option.defaultValue [])
choose [
GET >=> route "/health" >=> health
@@ -1676,7 +1699,17 @@ module App =
]
let createApplicationWithMarketData (repository: FundRepository) (marketData: IMarketDataService) : HttpHandler =
- createApplicationInternal repository (Some marketData)
+ createApplicationInternal repository (Some marketData) None
+
+ let createApplicationWithNavDateProbe (repository: FundRepository) (probe: INavDateProbe) : HttpHandler =
+ createApplicationInternal repository None (Some probe)
+
+ let createApplicationWithMarketDataAndProbe
+ (repository: FundRepository)
+ (marketData: IMarketDataService)
+ (probe: INavDateProbe)
+ : HttpHandler =
+ createApplicationInternal repository (Some marketData) (Some probe)
let createApplication (repository: FundRepository) : HttpHandler =
- createApplicationInternal repository None
+ createApplicationInternal repository None None
diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj
index 04724c0..ed2c637 100644
--- a/src/FundLab.Api/FundLab.Api.fsproj
+++ b/src/FundLab.Api/FundLab.Api.fsproj
@@ -17,8 +17,9 @@
<Compile Include="Health.fs" />
<Compile Include="MarketData.fs" />
<Compile Include="Persistence.fs" />
- <Compile Include="MarketDataService.fs" />
- <Compile Include="App.fs" />
+ <Compile Include="MarketDataService.fs" />
+ <Compile Include="NavDateProbe.fs" />
+ <Compile Include="App.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
diff --git a/src/FundLab.Api/NavDateProbe.fs b/src/FundLab.Api/NavDateProbe.fs
new file mode 100644
index 0000000..0437fd8
--- /dev/null
+++ b/src/FundLab.Api/NavDateProbe.fs
@@ -0,0 +1,57 @@
+namespace FundLab.Api
+
+open System
+open System.Collections.Concurrent
+open System.Threading
+
+/// Read-only probe that returns the most recent NAV dates for a fund code.
+///
+/// This is the "real data first" seam for the market NAV-date endpoint: the only
+/// implementation talks to the AKShare collector, and there is deliberately no
+/// synthetic fallback. A failed probe surfaces as a MarketDataFailure so the API
+/// can answer 503 with the underlying reason instead of inventing dates.
+type INavDateProbe =
+ abstract RecentNavDates: code: string * limit: int * CancellationToken -> Result<DateOnly list, MarketDataFailure>
+
+/// AKShare-backed probe. Successful lookups are memoised per fund code
+/// (the F# equivalent of functools.lru_cache) so repeated UI reads during one
+/// process do not re-spawn the collector; failures are never cached.
+type AkshareNavDateProbe(collector: IMarketDataCollector) =
+ let cache = ConcurrentDictionary<string, DateOnly list>(StringComparer.Ordinal)
+
+ let isFundCode (value: string) =
+ not (String.IsNullOrWhiteSpace value)
+ && value.Length = 6
+ && value |> Seq.forall Char.IsDigit
+
+ let clip limit =
+ if limit < 1 then 1
+ elif limit > 60 then 60
+ else limit
+
+ member _.Clear() = cache.Clear()
+
+ interface INavDateProbe with
+ member _.RecentNavDates(code, limit, token) =
+ let normalized = if isNull code then "" else code.Trim()
+
+ if not (isFundCode normalized) then
+ Error(InvalidMarketDataRequest "fund code must contain exactly six digits")
+ else
+ match cache.TryGetValue normalized with
+ | true, cached -> Ok(cached |> List.truncate (clip limit))
+ | _ ->
+ match collector.FetchNav(normalized, token) with
+ | Error message -> Error(MarketDataCollectorUnavailable message)
+ | Ok json ->
+ match MarketData.parseNavPayload json with
+ | Error message -> Error(InvalidMarketDataPayload message)
+ | Ok payload ->
+ let dates =
+ payload.Observations
+ |> List.map (fun observation -> observation.NavDate)
+ |> List.distinct
+ |> List.sortDescending
+
+ cache.[normalized] <- dates
+ Ok(dates |> List.truncate (clip limit))
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index 7a7ab32..6028b7c 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -59,6 +59,18 @@ module ConfirmationPolicy =
let candidate = if local.TimeOfDay >= cutoffTimeOfDay then date.AddDays 1 else date
rollToWeekday candidate
+ /// Calendar date used to bucket events (cash flows, holdings) on the returns
+ /// timeline. Shares the FUND_LAB_TEST_TRADE_DATE anchor with tradeDateFor so a
+ /// pinned suite does not drift when the host crosses midnight; production (no
+ /// pin) is exactly the Shanghai calendar date of the moment.
+ let eventDateFor (moment: DateTimeOffset) : DateOnly =
+ match Environment.GetEnvironmentVariable("FUND_LAB_TEST_TRADE_DATE") with
+ | value when not (String.IsNullOrWhiteSpace value) ->
+ match DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with
+ | true, anchored -> anchored
+ | _ -> shanghaiDate moment
+ | _ -> shanghaiDate moment
+
type NavQuote =
{ NavDate: DateOnly
Nav: decimal
@@ -4867,7 +4879,7 @@ type FundRepository(connectionString: string) =
match this.GetFund fundId with
| None -> None
| Some fund ->
- let today = ConfirmationPolicy.shanghaiDate DateTimeOffset.UtcNow
+ let today = ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow
let deposits = this.GetCapitalDeposits fundId
let orders = this.GetSubscriptionOrders fundId
let redemptions = this.GetRedemptionOrders fundId
@@ -4902,7 +4914,7 @@ type FundRepository(connectionString: string) =
|> List.map (fun observation -> observation.NavDate))
|> Set.ofList
- let creationDate = ConfirmationPolicy.shanghaiDate fund.CreatedAt
+ let creationDate = ConfirmationPolicy.eventDateFor fund.CreatedAt
let dividendCredited =
dividends
@@ -4926,7 +4938,7 @@ type FundRepository(connectionString: string) =
else
let eventDates =
[ yield creationDate
- yield! deposits |> List.map (fun deposit -> ConfirmationPolicy.shanghaiDate deposit.CreatedAt)
+ yield! deposits |> List.map (fun deposit -> ConfirmationPolicy.eventDateFor deposit.CreatedAt)
yield! orders |> List.map (fun order -> order.TradeDate)
yield! redemptions |> List.map (fun order -> order.TradeDate)
yield! dividendCredited |> List.map (fun record -> record.NavDate) ]
@@ -4964,7 +4976,7 @@ type FundRepository(connectionString: string) =
for date in dates do
for deposit in deposits do
- if ConfirmationPolicy.shanghaiDate deposit.CreatedAt = date then
+ if ConfirmationPolicy.eventDateFor deposit.CreatedAt = date then
availableCash <- availableCash + deposit.Amount
cumulativeDeposits <- cumulativeDeposits + deposit.Amount
diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs
index b7d0ef6..ba5936e 100644
--- a/src/FundLab.Api/Program.fs
+++ b/src/FundLab.Api/Program.fs
@@ -19,8 +19,9 @@ let main argv =
repository.EnsureSchema()
let collector = ProcessMarketDataCollector.FromEnvironment() :> IMarketDataCollector
let marketData = MarketDataService(repository, collector) :> IMarketDataService
+ let navDateProbe = AkshareNavDateProbe(collector) :> INavDateProbe
let app = builder.Build()
- app.UseGiraffe(App.createApplicationWithMarketData repository marketData)
+ app.UseGiraffe(App.createApplicationWithMarketDataAndProbe repository marketData navDateProbe)
app.Run()
0
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 8eb598e..44130de 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -373,6 +373,12 @@ type RawReturns =
points: RawReturnsPoint array
}
+type RawMarketNavDates =
+ {
+ code: string
+ dates: string array
+ }
+
type CreateAttempt =
{
idempotencyKey: string
@@ -709,6 +715,9 @@ module Api =
[<Import("getReturns", "./src/api.js")>]
let getReturns (token: string) (fundId: string) : JS.Promise<RawReturns> = jsNative
+ [<Import("getMarketNavDates", "./src/api.js")>]
+ let getMarketNavDates (token: string) (code: string) : JS.Promise<RawMarketNavDates> = jsNative
+
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
@@ -1049,6 +1058,11 @@ type Model =
returnsReadSeq: int
returnsInFlight: bool
returns: FundReturns option
+ navDatesReadSeq: int
+ navDatesInFlight: bool
+ navDates: string list
+ navDatesAvailable: bool
+ returnsSelectedDate: string option
planReadSeq: int
planInFlight: bool
planRunSeq: int
@@ -1142,6 +1156,10 @@ type Msg =
| ReturnsReadRequested
| ReturnsReadCompleted of requestId: int * returns: RawReturns
| ReturnsReadFailed of requestId: int * message: string
+ | NavDatesReadRequested
+ | NavDatesReadCompleted of requestId: int * dates: RawMarketNavDates
+ | NavDatesReadFailed of requestId: int * message: string
+ | ReturnsDateChanged of string
| InvestmentPlansReadRequested
| InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array
| InvestmentPlansReadFailed of requestId: int * message: string
@@ -1252,6 +1270,11 @@ let init () =
returnsReadSeq = 0
returnsInFlight = false
returns = None
+ navDatesReadSeq = 0
+ navDatesInFlight = false
+ navDates = []
+ navDatesAvailable = false
+ returnsSelectedDate = None
planReadSeq = 0
planInFlight = false
planRunSeq = 0
@@ -1421,6 +1444,13 @@ let private readReturnsCommand token fundId requestId =
(fun returns -> ReturnsReadCompleted(requestId, returns))
(fun error -> ReturnsReadFailed(requestId, errorText error))
+let private readNavDatesCommand token code requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getMarketNavDates token code)
+ ()
+ (fun dates -> NavDatesReadCompleted(requestId, dates))
+ (fun error -> NavDatesReadFailed(requestId, errorText error))
+
let private readInvestmentPlansCommand token fundId requestId =
Cmd.OfPromise.either
(fun () -> Api.getInvestmentPlans token fundId)
@@ -1512,6 +1542,11 @@ let update message model =
returnsReadSeq = model.returnsReadSeq + 1
returnsInFlight = false
returns = None
+ navDatesReadSeq = model.navDatesReadSeq + 1
+ navDatesInFlight = false
+ navDates = []
+ navDatesAvailable = false
+ returnsSelectedDate = None
planReadSeq = model.planReadSeq + 1
planInFlight = false
planRunSeq = model.planRunSeq + 1
@@ -1721,6 +1756,11 @@ let update message model =
returnsReadSeq = model.returnsReadSeq + 1
returnsInFlight = false
returns = None
+ navDatesReadSeq = model.navDatesReadSeq + 1
+ navDatesInFlight = false
+ navDates = []
+ navDatesAvailable = false
+ returnsSelectedDate = None
planReadSeq = model.planReadSeq + 1
planInFlight = false
planRunSeq = model.planRunSeq + 1
@@ -2499,7 +2539,7 @@ let update message model =
returnsInFlight = true
error = None
},
- readReturnsCommand model.token fund.id requestId
+ Cmd.batch [ readReturnsCommand model.token fund.id requestId; Cmd.ofMsg NavDatesReadRequested ]
| Some _ -> { model with error = Some "请输入 API token" }, Cmd.none
| None -> model, Cmd.none
| ReturnsReadCompleted (requestId, returns) ->
@@ -2518,6 +2558,61 @@ let update message model =
{ model with returnsInFlight = false; error = Some message }, Cmd.none
else
model, Cmd.none
+ | NavDatesReadRequested ->
+ match model.createdFund with
+ | Some _ when String.IsNullOrWhiteSpace model.token ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | Some _ ->
+ let heldCode =
+ model.positions
+ |> Option.bind (fun positions -> positions.positions |> List.tryHead)
+ |> Option.map (fun position -> position.instrumentCode)
+
+ match heldCode with
+ | Some code ->
+ let requestId = model.navDatesReadSeq + 1
+
+ {
+ model with
+ navDatesReadSeq = requestId
+ navDatesInFlight = true
+ },
+ readNavDatesCommand model.token code requestId
+ | None ->
+ {
+ model with
+ navDates = []
+ navDatesAvailable = false
+ navDatesInFlight = false
+ },
+ Cmd.none
+ | None -> model, Cmd.none
+ | NavDatesReadCompleted (requestId, dates) ->
+ if requestId = model.navDatesReadSeq then
+ let resolved = dates.dates |> Array.toList
+
+ {
+ model with
+ navDates = resolved
+ navDatesAvailable = not (List.isEmpty resolved)
+ navDatesInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | NavDatesReadFailed (requestId, _) ->
+ if requestId = model.navDatesReadSeq then
+ {
+ model with
+ navDates = []
+ navDatesAvailable = false
+ navDatesInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | ReturnsDateChanged value ->
+ { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none
| InvestmentPlansReadRequested ->
match model.createdFund with
| Some fund when not (String.IsNullOrWhiteSpace model.token) ->
@@ -3889,9 +3984,15 @@ let private returnsChartView (points: ReturnsPoint list) =
]
let private returnsPanel model dispatch =
- let latest (selector: ReturnsPoint -> string option) =
- model.returns
- |> Option.bind (fun returns -> returns.points |> List.rev |> List.tryPick selector)
+ let visiblePoints =
+ match model.returns with
+ | None -> []
+ | Some returns ->
+ match model.returnsSelectedDate with
+ | Some selected -> returns.points |> List.filter (fun point -> point.date <= selected)
+ | None -> returns.points
+
+ let latest (selector: ReturnsPoint -> string option) = visiblePoints |> List.rev |> List.tryPick selector
let totalAssets = latest (fun point -> point.totalAssets)
let cumulativeReturn = latest (fun point -> point.cumulativeReturn)
@@ -3910,6 +4011,38 @@ let private returnsPanel model dispatch =
Html.span [ prop.className "section-note"; prop.text "Returns - cash-flow adjusted" ]
]
]
+ Html.div [
+ prop.className "returns-date-picker"
+ prop.custom (
+ "data-nav-dates-state",
+ (if model.navDatesAvailable then "available" else "empty")
+ )
+ prop.children [
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "真实净值日期"
+ Html.select [
+ prop.className "text-input returns-date-input"
+ prop.disabled (not model.navDatesAvailable)
+ prop.value (defaultArg model.returnsSelectedDate "")
+ prop.onChange (fun value -> dispatch (ReturnsDateChanged value))
+ prop.children [
+ Html.option [ prop.value ""; prop.text "最新" ]
+ yield!
+ (model.navDates
+ |> List.map (fun date -> Html.option [ prop.value date; prop.text date ]))
+ ]
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "returns-date-empty"
+ prop.hidden model.navDatesAvailable
+ prop.text "暂无真实净值数据"
+ ]
+ ]
+ ]
match model.returns with
| None ->
Html.p [ prop.className "hint"; prop.text "创建基金后可读取收益曲线。" ]
@@ -3945,7 +4078,7 @@ let private returnsPanel model dispatch =
]
]
]
- returnsChartView returns.points
+ returnsChartView visiblePoints
Html.p [
prop.className "returns-note"
prop.text "蓝线 总资产 / 橙线 单位净值(各自归一化);缺少净值的数据点不连线,也不补零。"
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 79b20b0..80ded41 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -127,6 +127,10 @@ export function getReturns(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/returns`, token);
}
+export function getMarketNavDates(token, code) {
+ return requestJson(`/api/market/nav-dates?code=${encodeURIComponent(code)}`, token);
+}
+
export function createSipPlan(token, fundId, payload) {
const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`;
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, {
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index a3bdfba..4cdf165 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -545,6 +545,27 @@ h2 {
font-size: 11px;
}
+.returns-date-picker {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 12px;
+ margin-bottom: 10px;
+}
+
+.returns-date-picker .field-label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 12px;
+ color: #475569;
+}
+
+.returns-date-empty {
+ color: #b45309;
+ font-size: 12px;
+}
+
.returns-pending {
color: #b45309;
font-size: 12px;
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 374c63e..a77e503 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -29,6 +29,7 @@
<Compile Include="ReturnsTests.fs" />
<Compile Include="InvestmentPlanTests.fs" />
<Compile Include="RebalanceTests.fs" />
+ <Compile Include="NavDateProbeTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
new file mode 100644
index 0000000..0f7e506
--- /dev/null
+++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
@@ -0,0 +1,248 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Diagnostics
+open System.IO
+open System.Text.Json
+open System.Threading
+open Xunit
+open FundLab.Api
+
+module NavDateProbeSupport =
+ let navPayloadJson (code: string) (observations: (string * string) list) =
+ let body =
+ observations
+ |> List.map (fun (date, nav) ->
+ sprintf
+ "{\"nav_date\":\"%s\",\"published_at\":null,\"nav\":\"%s\",\"accumulated_nav\":null,\"daily_return\":null}"
+ date
+ nav)
+ |> String.concat ","
+
+ sprintf
+ "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"nav\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"observations\":[%s]}"
+ code
+ body
+
+ let collectorReturning (fetch: unit -> Result<string, string>) =
+ { new IMarketDataCollector with
+ member _.Search(_, _) = Ok ""
+ member _.FetchNav(_, _) = fetch () }
+
+ let realAksharePython () =
+ let configured = Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHON")
+
+ if String.IsNullOrWhiteSpace configured then
+ None
+ elif File.Exists configured then
+ Some configured
+ else
+ let path = Environment.GetEnvironmentVariable("PATH") |> Option.ofObj |> Option.defaultValue ""
+
+ path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
+ |> Array.tryPick (fun directory ->
+ let candidate = Path.Combine(directory, configured)
+ if File.Exists candidate then Some candidate else None)
+
+ let repositoryRoot () =
+ let mutable directory = DirectoryInfo(AppContext.BaseDirectory)
+
+ while not (isNull directory)
+ && not (File.Exists(Path.Combine(directory.FullName, "scripts", "probe_akshare_nav_dates.py"))) do
+ directory <- directory.Parent
+
+ if isNull directory then
+ failwith "could not locate scripts/probe_akshare_nav_dates.py from the test output directory"
+ else
+ directory.FullName
+
+/// xUnit has no first-class runtime skip in 2.x, so this fact sets Skip during
+/// discovery when the host has no reachable AKShare interpreter configured.
+/// That keeps the live probe detectable ("skipped, not faked") instead of red.
+type RealAkshareFactAttribute() =
+ inherit FactAttribute()
+
+ do
+ if (NavDateProbeSupport.realAksharePython ()).IsNone then
+ base.Skip <-
+ "set FUND_LAB_AKSHARE_PYTHON to a python interpreter with akshare installed to run the live probe"
+
+[<Collection("postgres")>]
+type NavDateProbeTests(fixture: PostgresFixture) =
+ let repository () = FundRepository(fixture.ConnectionString)
+
+ let appWithProbe (probe: INavDateProbe) =
+ App.createApplicationWithNavDateProbe (repository ()) probe
+
+ [<Fact>]
+ member _.``probe parses, dedupes, sorts descending and truncates``() =
+ let payload =
+ NavDateProbeSupport.navPayloadJson
+ "000001"
+ [
+ "2026-09-16", "1.1205"
+ "2026-09-21", "1.1902"
+ "2026-09-16", "1.1205"
+ "2026-09-18", "1.1500"
+ "2026-09-17", "1.1180"
+ "2026-09-15", "1.1000"
+ "2026-09-11", "1.0900"
+ ]
+
+ let probe = AkshareNavDateProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok payload)) :> INavDateProbe
+
+ match probe.RecentNavDates("000001", 5, CancellationToken.None) with
+ | Ok dates ->
+ Assert.Equal<DateOnly list>(
+ [
+ DateOnly(2026, 9, 21)
+ DateOnly(2026, 9, 18)
+ DateOnly(2026, 9, 17)
+ DateOnly(2026, 9, 16)
+ DateOnly(2026, 9, 15)
+ ],
+ dates
+ )
+ | Error failure -> failwithf "expected NAV dates, got %A" failure
+
+ [<Fact>]
+ member _.``probe memoises successful lookups per code``() =
+ let mutable calls = 0
+
+ let collector =
+ NavDateProbeSupport.collectorReturning (fun () ->
+ calls <- calls + 1
+ Ok(NavDateProbeSupport.navPayloadJson "000001" [ "2026-09-21", "1.1902" ]))
+
+ let probe = AkshareNavDateProbe(collector) :> INavDateProbe
+ let first = probe.RecentNavDates("000001", 5, CancellationToken.None)
+ let second = probe.RecentNavDates("000001", 5, CancellationToken.None)
+
+ Assert.Equal(Ok [ DateOnly(2026, 9, 21) ], first)
+ Assert.Equal(Ok [ DateOnly(2026, 9, 21) ], second)
+ Assert.Equal(1, calls)
+
+ [<Fact>]
+ member _.``probe rejects a malformed fund code without touching the collector``() =
+ let collector =
+ NavDateProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")
+
+ let probe = AkshareNavDateProbe(collector) :> INavDateProbe
+
+ match probe.RecentNavDates("abc", 5, CancellationToken.None) with
+ | Error (InvalidMarketDataRequest message) -> Assert.Contains("six digits", message)
+ | other -> failwithf "expected InvalidMarketDataRequest, got %A" other
+
+ [<Fact>]
+ member _.``probe maps collector failures to an unavailable reason``() =
+ let collector =
+ NavDateProbeSupport.collectorReturning (fun () -> Error "AKShare is unreachable: connection timed out")
+
+ let probe = AkshareNavDateProbe(collector) :> INavDateProbe
+
+ match probe.RecentNavDates("000001", 5, CancellationToken.None) with
+ | Error (MarketDataCollectorUnavailable message) -> Assert.Contains("connection timed out", message)
+ | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other
+
+ [<Fact>]
+ member _.``probe maps malformed payloads to an invalid payload reason``() =
+ let probe =
+ AkshareNavDateProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> INavDateProbe
+
+ match probe.RecentNavDates("000001", 5, CancellationToken.None) with
+ | Error (InvalidMarketDataPayload _) -> ()
+ | other -> failwithf "expected InvalidMarketDataPayload, got %A" other
+
+ [<Fact>]
+ member _.``market nav-dates endpoint returns the probe dates``() =
+ let probe =
+ { new INavDateProbe with
+ member _.RecentNavDates(code, limit, _) =
+ Assert.Equal("000001", code)
+ Assert.Equal(5, limit)
+ Ok [ DateOnly(2026, 9, 21); DateOnly(2026, 9, 18) ] }
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithProbe probe)
+ "GET"
+ "/api/market/nav-dates?code=000001"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"code\":\"000001\"", body)
+ Assert.Contains("\"dates\":[\"2026-09-21\",\"2026-09-18\"]", body)
+
+ [<Fact>]
+ member _.``market nav-dates endpoint answers 503 when the probe fails``() =
+ let probe =
+ { new INavDateProbe with
+ member _.RecentNavDates(_, _, _) =
+ Error(MarketDataCollectorUnavailable "AKShare is unreachable: connection timed out") }
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithProbe probe)
+ "GET"
+ "/api/market/nav-dates?code=000001"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(503, status)
+ Assert.Contains("MARKET_DATA_UNAVAILABLE", body)
+ Assert.Contains("connection timed out", body)
+
+ [<Fact>]
+ member _.``market nav-dates endpoint rejects a malformed code with 400``() =
+ let probe =
+ { new INavDateProbe with
+ member _.RecentNavDates(_, _, _) =
+ Error(InvalidMarketDataRequest "fund code must contain exactly six digits") }
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithProbe probe)
+ "GET"
+ "/api/market/nav-dates?code=abc"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_MARKET_DATA_REQUEST", body)
+
+ [<RealAkshareFact>]
+ member _.``live AKShare probe returns the five most recent NAV dates``() =
+ let python = (NavDateProbeSupport.realAksharePython ()).Value
+ let script = Path.Combine(NavDateProbeSupport.repositoryRoot (), "scripts", "probe_akshare_nav_dates.py")
+
+ let startInfo = ProcessStartInfo(python)
+ startInfo.ArgumentList.Add script
+ startInfo.ArgumentList.Add "--code"
+ startInfo.ArgumentList.Add "000001"
+ startInfo.ArgumentList.Add "--limit"
+ startInfo.ArgumentList.Add "5"
+ startInfo.RedirectStandardOutput <- true
+ startInfo.RedirectStandardError <- true
+ startInfo.UseShellExecute <- false
+
+ match Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHONPATH") with
+ | value when not (String.IsNullOrWhiteSpace value) -> startInfo.Environment["PYTHONPATH"] <- value
+ | _ -> ()
+
+ use probeProcess = Process.Start startInfo
+ let standardOutput = probeProcess.StandardOutput.ReadToEnd()
+ let standardError = probeProcess.StandardError.ReadToEnd()
+ probeProcess.WaitForExit 60000 |> ignore
+
+ Assert.True(probeProcess.ExitCode = 0, sprintf "probe exited with %d: %s" probeProcess.ExitCode standardError)
+
+ use document = JsonDocument.Parse standardOutput
+
+ let dates =
+ document.RootElement.GetProperty("dates").EnumerateArray()
+ |> Seq.map (fun element -> element.GetString())
+ |> Seq.toList
+
+ Assert.Equal(5, List.length dates)
+ Assert.Equal<string list>(List.sortDescending dates, dates)