diff options
| -rw-r--r-- | qa/driver/browser-test.js | 63 | ||||
| -rwxr-xr-x | qa/stub/fund-lab-python | 6 | ||||
| -rw-r--r-- | scripts/probe_akshare_nav_dates.py | 81 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 39 | ||||
| -rw-r--r-- | src/FundLab.Api/FundLab.Api.fsproj | 5 | ||||
| -rw-r--r-- | src/FundLab.Api/NavDateProbe.fs | 57 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 20 | ||||
| -rw-r--r-- | src/FundLab.Api/Program.fs | 3 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 143 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 21 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/NavDateProbeTests.fs | 248 |
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) |
