summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/MarketDataService.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/MarketDataService.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/MarketDataService.fs')
-rw-r--r--src/FundLab.Api/MarketDataService.fs181
1 files changed, 181 insertions, 0 deletions
diff --git a/src/FundLab.Api/MarketDataService.fs b/src/FundLab.Api/MarketDataService.fs
new file mode 100644
index 0000000..8047071
--- /dev/null
+++ b/src/FundLab.Api/MarketDataService.fs
@@ -0,0 +1,181 @@
+namespace FundLab.Api
+
+open System
+open System.Diagnostics
+open System.IO
+open System.Security.Cryptography
+open System.Text
+
+type MarketDataFailure =
+ | InvalidMarketDataRequest of string
+ | MarketDataCollectorUnavailable of string
+ | InvalidMarketDataPayload of string
+ | MarketDataPersistenceFailure of string
+
+type IMarketDataCollector =
+ abstract Search: query: string -> Result<string, string>
+ abstract FetchNav: code: string -> Result<string, string>
+
+type IMarketDataService =
+ abstract Search: query: string -> Result<MarketDataSearchPayload, MarketDataFailure>
+ abstract RefreshNav: code: string -> Result<MarketDataNavRecord list, MarketDataFailure>
+ abstract GetNav: code: string * fromDate: DateOnly option * toDate: DateOnly option -> Result<MarketDataNavRecord list, MarketDataFailure>
+
+type ProcessMarketDataCollector(pythonExecutable: string, scriptPath: string, pythonPath: string option) =
+ let execute arguments =
+ if not (File.Exists scriptPath) then
+ Error(sprintf "collector script was not found at %s" scriptPath)
+ else
+ let startInfo = ProcessStartInfo()
+ startInfo.FileName <- pythonExecutable
+ startInfo.UseShellExecute <- false
+ startInfo.RedirectStandardOutput <- true
+ startInfo.RedirectStandardError <- true
+ startInfo.WorkingDirectory <- Path.GetDirectoryName(scriptPath)
+
+ startInfo.ArgumentList.Add(scriptPath)
+
+ for argument in arguments do
+ startInfo.ArgumentList.Add(argument)
+
+ match pythonPath with
+ | Some value -> startInfo.Environment["PYTHONPATH"] <- value
+ | None -> ()
+
+ use child = new Process()
+ child.StartInfo <- startInfo
+
+ try
+ if not (child.Start()) then
+ Error "could not start the AKShare collector"
+ else
+ let output = child.StandardOutput.ReadToEnd()
+ let error = child.StandardError.ReadToEnd()
+ child.WaitForExit()
+
+ if child.ExitCode = 0 && not (String.IsNullOrWhiteSpace output) then
+ Ok output
+ elif String.IsNullOrWhiteSpace error then
+ Error(sprintf "collector exited with code %d" child.ExitCode)
+ else
+ Error(error.Trim())
+ with error ->
+ Error(error.Message)
+
+ static member FromEnvironment() =
+ let value name fallback =
+ Environment.GetEnvironmentVariable(name)
+ |> Option.ofObj
+ |> Option.filter (String.IsNullOrWhiteSpace >> not)
+ |> Option.defaultValue fallback
+
+ let python = value "FUND_LAB_AKSHARE_PYTHON" "python3"
+ let script = Path.Combine(AppContext.BaseDirectory, "akshare_collector.py")
+
+ let pythonPath =
+ Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHONPATH")
+ |> Option.ofObj
+ |> Option.filter (String.IsNullOrWhiteSpace >> not)
+
+ ProcessMarketDataCollector(python, script, pythonPath)
+
+ interface IMarketDataCollector with
+ member _.Search(query: string) =
+ execute [ "--operation"; "search"; "--query"; query ]
+
+ member _.FetchNav(code: string) =
+ execute [ "--operation"; "nav"; "--code"; code ]
+
+type MarketDataService(repository: FundRepository, collector: IMarketDataCollector) =
+ let codePattern = Text.RegularExpressions.Regex("^[0-9]{6}$", Text.RegularExpressions.RegexOptions.Compiled)
+
+ let payloadHash (raw: string) =
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))
+
+ let collectorFailure error = MarketDataCollectorUnavailable error
+ let payloadFailure error = InvalidMarketDataPayload error
+
+ let parseSearch raw =
+ match MarketData.parseSearchPayload raw with
+ | Ok payload -> Ok(payload, payloadHash raw)
+ | Error message -> Error(payloadFailure message)
+
+ let parseNav raw =
+ match MarketData.parseNavPayload raw with
+ | Ok payload -> Ok(payload, payloadHash raw)
+ | Error message -> Error(payloadFailure message)
+
+ let persistSearch (payload: MarketDataSearchPayload) hash =
+ try
+ repository.UpsertInstruments(payload, hash)
+ Ok payload
+ with error ->
+ Error(MarketDataPersistenceFailure error.Message)
+
+ let persistNav (payload: MarketDataNavPayload) hash =
+ try
+ repository.UpsertNavObservations(payload, hash)
+ Ok(repository.GetNav(payload.Code, None, None))
+ with error ->
+ Error(MarketDataPersistenceFailure error.Message)
+
+ let validCode code =
+ not (String.IsNullOrWhiteSpace code) && codePattern.IsMatch(code)
+
+ member _.Search(query: string) =
+ let normalizedQuery = if isNull query then "" else query.Trim()
+
+ if String.IsNullOrWhiteSpace normalizedQuery || normalizedQuery.Length > 80 then
+ Error(InvalidMarketDataRequest "search query must contain 1 to 80 characters")
+ else
+ match collector.Search normalizedQuery with
+ | Error message -> Error(collectorFailure message)
+ | Ok raw ->
+ match parseSearch raw with
+ | Error failure -> Error failure
+ | Ok(payload, hash) -> persistSearch payload hash
+
+ member _.RefreshNav(code: string) =
+ let normalizedCode = if isNull code then "" else code.Trim()
+
+ if not (validCode normalizedCode) then
+ Error(InvalidMarketDataRequest "fund code must contain exactly six digits")
+ else
+ match collector.Search normalizedCode with
+ | Error message -> Error(collectorFailure message)
+ | Ok searchRaw ->
+ match parseSearch searchRaw with
+ | Error failure -> Error failure
+ | Ok(searchPayload, searchHash) ->
+ match searchPayload.Instruments |> List.tryFind (fun instrument -> instrument.Code = normalizedCode) with
+ | None -> Error(InvalidMarketDataRequest "fund code was not found in the source catalog")
+ | Some _ ->
+ match persistSearch searchPayload searchHash with
+ | Error failure -> Error failure
+ | Ok _ ->
+ match collector.FetchNav normalizedCode with
+ | Error message -> Error(collectorFailure message)
+ | Ok navRaw ->
+ match parseNav navRaw with
+ | Error failure -> Error failure
+ | Ok(navPayload, navHash) when navPayload.Code <> normalizedCode ->
+ Error(payloadFailure "NAV payload code does not match the requested fund code")
+ | Ok(navPayload, navHash) -> persistNav navPayload navHash
+
+ member _.GetNav(code: string, fromDate: DateOnly option, toDate: DateOnly option) =
+ let normalizedCode = if isNull code then "" else code.Trim()
+
+ if not (validCode normalizedCode) then
+ Error(InvalidMarketDataRequest "fund code must contain exactly six digits")
+ elif fromDate.IsSome && toDate.IsSome && fromDate.Value > toDate.Value then
+ Error(InvalidMarketDataRequest "from date must not be after to date")
+ else
+ try
+ Ok(repository.GetNav(normalizedCode, fromDate, toDate))
+ with error ->
+ Error(MarketDataPersistenceFailure error.Message)
+
+ interface IMarketDataService with
+ member this.Search(query) = this.Search(query)
+ member this.RefreshNav(code) = this.RefreshNav(code)
+ member this.GetNav(code, fromDate, toDate) = this.GetNav(code, fromDate, toDate)