namespace FundLab.Api open System open System.Collections.Concurrent open System.Threading /// One daily close of a stock's history. type StockDailyObservation = { BarDate: DateOnly Close: decimal Volume: decimal option Amount: decimal option } /// Read-only probe that returns recent daily history for a stock code. /// /// Like the other market probes there is no synthetic fallback: a failure is /// surfaced as a MarketDataFailure so the API answers 503 with the reason. type IStockDailyProbe = abstract RecentDaily: code: string * days: int * CancellationToken -> Result /// AKShare-backed stock-daily probe. Successful lookups are memoised per code /// and requested window; failures are never cached. A payload whose instrument /// code disagrees with the request is rejected outright. type AkshareStockDailyProbe(collector: IMarketDataCollector) = let cache = ConcurrentDictionary(StringComparer.Ordinal) let isStockCode (value: string) = not (String.IsNullOrWhiteSpace value) && value.Length = 6 && value |> Seq.forall Char.IsDigit let clip days = if days < 1 then 1 elif days > 30 then 30 else days member _.Clear() = cache.Clear() interface IStockDailyProbe with member _.RecentDaily(code, days, token) = let normalized = if isNull code then "" else code.Trim() if not (isStockCode normalized) then Error(InvalidMarketDataRequest "stock code must contain exactly six digits") else let bounded = clip days let cacheKey = sprintf "%s|%d" normalized bounded match cache.TryGetValue cacheKey with | true, cached -> Ok cached | _ -> match collector.FetchStockDaily(normalized, bounded, token) with | Error message -> Error(MarketDataCollectorUnavailable message) | Ok json -> match MarketData.parseStockDailyPayload json with | Error message -> Error(InvalidMarketDataPayload message) | Ok payload when not (String.Equals(payload.Code, normalized, StringComparison.Ordinal)) -> Error( InvalidMarketDataPayload( sprintf "payload code '%s' does not match requested code '%s'" payload.Code normalized ) ) | Ok payload -> let observations = payload.Bars |> List.filter (fun bar -> bar.Close > 0m) |> List.map (fun bar -> { BarDate = bar.BarDate Close = bar.Close Volume = bar.Volume Amount = bar.Amount }) |> List.distinctBy (fun bar -> bar.BarDate) |> List.sortBy (fun bar -> bar.BarDate) cache.[cacheKey] <- observations Ok observations