From be1afd248e4cc9b03a1e21090602a945af6988b2 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 01:40:10 +0800 Subject: feat(app): 接入 AKShare 行情采集、净值查询与前端净值曲线 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] - 本提交完成 3b 行情闭环:真实 AKShare 搜索与净值采集、PostgreSQL 持久化、鉴权 API 与 Fable 前端净值曲线展示。 [新增功能] - API 新增 /api/instruments/search、/api/instruments/{code}/nav 与 nav/refresh 接口,经 Bearer 鉴权调用 AKShare Python 采集器并幂等落库。 - 前端提供基金搜索、精确代码选择、历史净值刷新/重读与 SVG 折线图,token 变更即清空私有结果并失效在途请求。 [实现方案] - MarketData 以 requiredProperty 校验采集器 payload,净值观测按 (code, nav_date) 幂等 upsert 并保留来源与哈希。 - 前端以 requestId 序列守卫 SearchCompleted/SearchFailed/NavCompleted/NavFailed,TokenChanged 同时递增 searchSeq/navSeq 拒绝过期响应;边界解码兼容 F# option 的 {"case":"Some"} 线格式。 [影响范围] - 新增 FundLab.Web.Tests(边界解码、序列失效、图表纯函数共 8 项),扩展 API 测试至 20 项;vite dev 代理 /api 至本地 API。 - 真实 AKShare 端到端依赖本机 akshare 环境,由负责人另行验收;本提交不包含 docs/overnight-progress.md 的现有修改。 --- src/FundLab.Api/App.fs | 152 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 6 deletions(-) (limited to 'src/FundLab.Api/App.fs') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 0eb367d..a4e0a00 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -31,11 +31,51 @@ type ApiErrorResponse = message: string } +type MarketDataInstrumentApiResponse = + { + code: string + name: string + fundType: string option + } + +type MarketDataSearchApiResponse = + { + source: string + sourceRevision: string + collectedAt: string + instruments: MarketDataInstrumentApiResponse list + } + +type MarketDataObservationApiResponse = + { + code: string + navDate: string + publishedAt: string option + nav: string + accumulatedNav: string option + dailyReturn: string option + source: string + sourceRevision: string + sourceCollectedAt: string + sourcePayloadHash: string + firstSeenAt: string + lastSeenAt: string + } + +type MarketDataNavApiResponse = + { + code: string + observations: MarketDataObservationApiResponse list + } + module App = let private invariant = CultureInfo.InvariantCulture let private cashText (value: decimal) = value.ToString("0.00", invariant) let private unitNavText (value: decimal) = value.ToString("0.00000000", invariant) + let private decimalText (value: decimal) = value.ToString("0.00000000", invariant) + let private dateText (value: DateOnly) = value.ToString("yyyy-MM-dd", invariant) + let private timestampText (value: DateTimeOffset) = value.ToString("O", invariant) let private fundResponse (fund: FundRecord) : FundApiResponse = { @@ -176,16 +216,116 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed" next ctx - let createApplication (repository: FundRepository) : HttpHandler = + let private marketDataError (failure: MarketDataFailure) : HttpHandler = + let status, error, message = + match failure with + | InvalidMarketDataRequest message -> 400, "INVALID_MARKET_DATA_REQUEST", message + | MarketDataCollectorUnavailable message -> 503, "MARKET_DATA_UNAVAILABLE", message + | InvalidMarketDataPayload message -> 502, "INVALID_MARKET_DATA_PAYLOAD", message + | MarketDataPersistenceFailure _ -> 500, "PERSISTENCE_ERROR", "market data persistence failed" + + errorResponse status error message + + let private marketDataInstrumentResponse (instrument: MarketDataInstrument) = + { + code = instrument.Code + name = instrument.Name + fundType = instrument.FundType + } + + let private marketDataSearchResponse (payload: MarketDataSearchPayload) = + { + source = payload.Source + sourceRevision = payload.SourceRevision + collectedAt = timestampText payload.CollectedAt + instruments = payload.Instruments |> List.map marketDataInstrumentResponse + } + + let private marketDataObservationResponse (observation: MarketDataNavRecord) = + { + code = observation.Code + navDate = dateText observation.NavDate + publishedAt = observation.PublishedAt |> Option.map timestampText + nav = decimalText observation.Nav + accumulatedNav = observation.AccumulatedNav |> Option.map decimalText + dailyReturn = observation.DailyReturn |> Option.map decimalText + source = observation.Source + sourceRevision = observation.SourceRevision + sourceCollectedAt = timestampText observation.SourceCollectedAt + sourcePayloadHash = observation.SourcePayloadHash + firstSeenAt = timestampText observation.FirstSeenAt + lastSeenAt = timestampText observation.LastSeenAt + } + + let private marketDataNavResponse code observations = + { + code = code + observations = observations |> List.map marketDataObservationResponse + } + + let private searchInstruments (marketData: IMarketDataService) : HttpHandler = + fun next ctx -> + match marketData.Search(ctx.Request.Query["q"].ToString()) with + | Ok payload -> json (marketDataSearchResponse payload) next ctx + | Error failure -> marketDataError failure next ctx + + let private refreshNav (marketData: IMarketDataService) (code: string) : HttpHandler = + fun next ctx -> + match marketData.RefreshNav code with + | Ok observations -> json (marketDataNavResponse code observations) next ctx + | Error failure -> marketDataError failure next ctx + + let private queryDate (name: string) (ctx: HttpContext) = + let value = ctx.Request.Query[name].ToString() + + if String.IsNullOrWhiteSpace value then + Ok None + else + let mutable date = DateOnly.MinValue + + if DateOnly.TryParseExact(value, "yyyy-MM-dd", invariant, DateTimeStyles.None, &date) then + Ok(Some date) + else + Error(sprintf "%s must be an ISO date" name) + + let private getNav (marketData: IMarketDataService) (code: string) : HttpHandler = + fun next ctx -> + match queryDate "from" ctx, queryDate "to" ctx with + | Ok fromDate, Ok toDate -> + match marketData.GetNav(code, fromDate, toDate) with + | Ok observations -> json (marketDataNavResponse code observations) next ctx + | Error failure -> marketDataError failure next ctx + | Error message, _ + | _, Error message -> + marketDataError (InvalidMarketDataRequest message) next ctx + + let private marketDataRoutes (marketData: IMarketDataService) = + [ + GET >=> route "/instruments/search" >=> searchInstruments marketData + POST >=> routef "/instruments/%s/nav/refresh" (refreshNav marketData) + GET >=> routef "/instruments/%s/nav" (getNav marketData) + ] + + let private createApplicationInternal (repository: FundRepository) (marketData: IMarketDataService option) : HttpHandler = + let apiRoutes = + [ + GET >=> route "/portfolio/summary" >=> emptyPortfolio + POST >=> route "/funds" >=> createFund repository + GET >=> routef "/funds/%s" (getFund repository) + ] + @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) + choose [ GET >=> route "/health" >=> health subRoute "/api" ( requireBearer - >=> choose [ - GET >=> route "/portfolio/summary" >=> emptyPortfolio - POST >=> route "/funds" >=> createFund repository - GET >=> routef "/funds/%s" (getFund repository) - ] + >=> choose apiRoutes ) setStatusCode 404 >=> text "Not Found" ] + + let createApplicationWithMarketData (repository: FundRepository) (marketData: IMarketDataService) : HttpHandler = + createApplicationInternal repository (Some marketData) + + let createApplication (repository: FundRepository) : HttpHandler = + createApplicationInternal repository None -- cgit v1.2.3