summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 01:40:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 01:40:10 +0800
commitbe1afd248e4cc9b03a1e21090602a945af6988b2 (patch)
treed31bd6e6c1736ab4ad784b52ebc5010ade074439 /src/FundLab.Api/App.fs
parent2ada70d6467aec11b45328112d598454ca50f2d6 (diff)
downloadfund-lab-be1afd248e4cc9b03a1e21090602a945af6988b2.tar.gz
feat(app): 接入 AKShare 行情采集、净值查询与前端净值曲线
[变更性质] - 本提交完成 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 的现有修改。
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs152
1 files changed, 146 insertions, 6 deletions
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