diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 03:25:00 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 03:25:00 +0800 |
| commit | 7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3 (patch) | |
| tree | d7374c1b4bb5308374c3386ac3768ac7209a6440 /src/FundLab.Api/StockDailyProbe.fs | |
| parent | 8c01adf05f1428bf2ada49574b25529c064da884 (diff) | |
| download | fund-lab-7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3.tar.gz | |
Add real AKShare stock daily history probe slice (3d-19)
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 |
