diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 01:40:10 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 01:40:10 +0800 |
| commit | be1afd248e4cc9b03a1e21090602a945af6988b2 (patch) | |
| tree | d31bd6e6c1736ab4ad784b52ebc5010ade074439 /src/FundLab.Api/MarketDataService.fs | |
| parent | 2ada70d6467aec11b45328112d598454ca50f2d6 (diff) | |
| download | fund-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.fs | 181 |
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) |
