diff options
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) |
