blob: 8099267a494c8813b6ce957a2c899deb4ac2f910 (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
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
|