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 abstract FetchNav: code: string -> Result type IMarketDataService = abstract Search: query: string -> Result abstract RefreshNav: code: string -> Result abstract GetNav: code: string * fromDate: DateOnly option * toDate: DateOnly option -> Result 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)