diff options
Diffstat (limited to 'src/FundLab.Api/StockDailyProbe.fs')
| -rw-r--r-- | src/FundLab.Api/StockDailyProbe.fs | 80 |
1 files changed, 80 insertions, 0 deletions
diff --git a/src/FundLab.Api/StockDailyProbe.fs b/src/FundLab.Api/StockDailyProbe.fs new file mode 100644 index 0000000..8099267 --- /dev/null +++ b/src/FundLab.Api/StockDailyProbe.fs @@ -0,0 +1,80 @@ +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<StockDailyObservation list, MarketDataFailure> + +/// 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<string, StockDailyObservation list>(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 |
