summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/StockDailyProbe.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 03:25:00 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 03:25:00 +0800
commit7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3 (patch)
treed7374c1b4bb5308374c3386ac3768ac7209a6440 /src/FundLab.Api/StockDailyProbe.fs
parent8c01adf05f1428bf2ada49574b25529c064da884 (diff)
downloadfund-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.fs80
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