summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Api')
-rw-r--r--src/FundLab.Api/App.fs52
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj1
-rw-r--r--src/FundLab.Api/MarketData.fs69
-rw-r--r--src/FundLab.Api/MarketDataService.fs4
-rw-r--r--src/FundLab.Api/Program.fs4
-rw-r--r--src/FundLab.Api/StockDailyProbe.fs80
-rw-r--r--src/FundLab.Api/akshare_collector.py88
7 files changed, 294 insertions, 4 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 9565485..4313329 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -358,6 +358,7 @@ type MarketProbes =
NavSeries: INavSeriesProbe
BondQuotes: IBondQuoteProbe
StockQuotes: IStockQuoteProbe
+ StockDaily: IStockDailyProbe
}
type BondQuoteApiResponse =
@@ -376,6 +377,20 @@ type StockQuoteApiResponse =
currency: string
}
+type StockDailyObservationApiResponse =
+ {
+ date: string
+ close: string
+ volume: string option
+ amount: string option
+ }
+
+type StockDailyApiResponse =
+ {
+ code: string
+ observations: StockDailyObservationApiResponse list
+ }
+
module App =
let addOptionFriendlyJson (services: IServiceCollection) =
let settings =
@@ -1630,7 +1645,7 @@ module App =
lastSeenAt = timestampText observation.LastSeenAt
}
- let private marketDataNavResponse code observations =
+ let private marketDataNavResponse code (observations: MarketDataNavRecord list) : MarketDataNavApiResponse =
{
code = code
observations = observations |> List.map marketDataObservationResponse
@@ -1753,12 +1768,47 @@ module App =
ctx
| Error failure -> marketDataError failure next ctx
+ let private stockDailyObservationResponse (observation: StockDailyObservation) : StockDailyObservationApiResponse =
+ {
+ date = dateText observation.BarDate
+ close = decimalText observation.Close
+ volume = observation.Volume |> Option.map decimalText
+ amount = observation.Amount |> Option.map decimalText
+ }
+
+ let private getStockDaily (probe: IStockDailyProbe) : HttpHandler =
+ fun next ctx ->
+ let code = ctx.Request.Query["code"].ToString()
+ let daysText = ctx.Request.Query["days"].ToString()
+
+ let days =
+ if String.IsNullOrWhiteSpace daysText then
+ Ok 5
+ else
+ match Int32.TryParse(daysText, NumberStyles.Integer, invariant) with
+ | true, value when value >= 1 && value <= 30 -> Ok value
+ | _ -> Error "days must be an integer between 1 and 30"
+
+ match days with
+ | Error message -> marketDataError (InvalidMarketDataRequest message) next ctx
+ | Ok value ->
+ match probe.RecentDaily(code, value, ctx.RequestAborted) with
+ | Ok observations ->
+ json
+ ({ code = code.Trim()
+ observations = observations |> List.map stockDailyObservationResponse }
+ : StockDailyApiResponse)
+ next
+ ctx
+ | Error failure -> marketDataError failure next ctx
+
let private marketProbeRoutes (probes: MarketProbes) =
[
GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates
GET >=> route "/market/nav-series" >=> getMarketNavSeries probes.NavSeries
GET >=> route "/market/bond-quote" >=> getBondQuote probes.BondQuotes
GET >=> route "/market/stock-quote" >=> getStockQuote probes.StockQuotes
+ GET >=> route "/market/stock-daily" >=> getStockDaily probes.StockDaily
]
let private createApplicationInternal
diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj
index 62c6324..2703e4b 100644
--- a/src/FundLab.Api/FundLab.Api.fsproj
+++ b/src/FundLab.Api/FundLab.Api.fsproj
@@ -22,6 +22,7 @@
<Compile Include="NavSeriesProbe.fs" />
<Compile Include="BondQuoteProbe.fs" />
<Compile Include="StockQuoteProbe.fs" />
+ <Compile Include="StockDailyProbe.fs" />
<Compile Include="App.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs
index 87ef145..82da1c9 100644
--- a/src/FundLab.Api/MarketData.fs
+++ b/src/FundLab.Api/MarketData.fs
@@ -59,6 +59,23 @@ type MarketDataStockQuotePayload =
Currency: string
}
+type MarketDataStockDailyBar =
+ {
+ BarDate: DateOnly
+ Close: decimal
+ Volume: decimal option
+ Amount: decimal option
+ }
+
+type MarketDataStockDailyPayload =
+ {
+ Source: string
+ SourceRevision: string
+ CollectedAt: DateTimeOffset
+ Code: string
+ Bars: MarketDataStockDailyBar list
+ }
+
type MarketDataInstrumentRecord =
{
Code: string
@@ -398,3 +415,55 @@ module MarketData =
}
with
| :? JsonException -> Error "payload must be valid JSON"
+
+ let private parseStockDailyBar root =
+ result {
+ let! dateText = requiredString root "date"
+ let! date = parseDate dateText
+ let! closeProperty = requiredProperty root "close"
+ let! close = parseDecimal "close" closeProperty
+ let! volumeProperty = requiredProperty root "volume"
+ let! volume = optionalDecimal "volume" volumeProperty
+ let! amountProperty = requiredProperty root "amount"
+ let! amount = optionalDecimal "amount" amountProperty
+
+ return
+ {
+ BarDate = date
+ Close = close
+ Volume = volume
+ Amount = amount
+ }
+ }
+
+ let parseStockDailyPayload (json: string) : Result<MarketDataStockDailyPayload, string> =
+ try
+ use document = JsonDocument.Parse(json)
+ let root = document.RootElement
+
+ result {
+ let! source, sourceRevision, collectedAt = validateEnvelope root "stock-daily"
+ let! instrumentProperty = requiredProperty root "instrument"
+ let! codeProperty = requiredString instrumentProperty "code"
+
+ if not (isFundCode codeProperty) then
+ return! Error "stock code must contain exactly six digits"
+
+ let! observationsProperty = requiredProperty root "observations"
+ let! barsProperty = requiredProperty root "observations"
+ let! bars = parseArray parseStockDailyBar barsProperty
+
+ if List.isEmpty bars then
+ return! Error "observations must not be empty"
+
+ return
+ {
+ Source = source
+ SourceRevision = sourceRevision
+ CollectedAt = collectedAt
+ Code = codeProperty
+ Bars = bars
+ }
+ }
+ with
+ | :? JsonException -> Error "payload must be valid JSON"
diff --git a/src/FundLab.Api/MarketDataService.fs b/src/FundLab.Api/MarketDataService.fs
index 7460bad..ee3aa67 100644
--- a/src/FundLab.Api/MarketDataService.fs
+++ b/src/FundLab.Api/MarketDataService.fs
@@ -20,6 +20,7 @@ type IMarketDataCollector =
abstract FetchNav: code: string * CancellationToken -> Result<string, string>
abstract FetchBondQuote: code: string * CancellationToken -> Result<string, string>
abstract FetchStockQuote: code: string * CancellationToken -> Result<string, string>
+ abstract FetchStockDaily: code: string * days: int * CancellationToken -> Result<string, string>
type IMarketDataService =
abstract Search: query: string * CancellationToken -> Result<MarketDataSearchPayload, MarketDataFailure>
@@ -211,6 +212,9 @@ type ProcessMarketDataCollector(pythonExecutable: string, scriptPath: string, py
member _.FetchStockQuote(code: string, token: CancellationToken) =
execute token [ "--operation"; "stock-quote"; "--code"; code ]
+ member _.FetchStockDaily(code: string, days: int, token: CancellationToken) =
+ execute token [ "--operation"; "stock-daily"; "--code"; code; "--days"; string days ]
+
type MarketDataService(repository: FundRepository, collector: IMarketDataCollector) =
let codePattern = Text.RegularExpressions.Regex("^[0-9]{6}$", Text.RegularExpressions.RegexOptions.Compiled)
diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs
index 410583c..768fb26 100644
--- a/src/FundLab.Api/Program.fs
+++ b/src/FundLab.Api/Program.fs
@@ -23,12 +23,14 @@ let main argv =
let navSeriesProbe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe
let bondQuoteProbe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe
let stockQuoteProbe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe
+ let stockDailyProbe = AkshareStockDailyProbe(collector) :> IStockDailyProbe
let probes =
{ NavDates = navDateProbe
NavSeries = navSeriesProbe
BondQuotes = bondQuoteProbe
- StockQuotes = stockQuoteProbe }
+ StockQuotes = stockQuoteProbe
+ StockDaily = stockDailyProbe }
let app = builder.Build()
app.UseGiraffe(App.createApplicationWithMarketDataAndProbes repository marketData probes)
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
diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py
index 62632bf..f9a0f06 100644
--- a/src/FundLab.Api/akshare_collector.py
+++ b/src/FundLab.Api/akshare_collector.py
@@ -269,6 +269,87 @@ def stock_from_snapshot(code):
return None
+def stock_daily_observations(frame, date_key, close_key, volume_key, amount_key):
+ rows = []
+
+ for _, row in frame.iterrows():
+ row_date = date_text(row.get(date_key))
+ close = decimal_text(row.get(close_key))
+ if row_date is None or close is None or Decimal(close) <= 0:
+ continue
+
+ rows.append(
+ {
+ "date": row_date,
+ "close": close,
+ "volume": decimal_text(row.get(volume_key)),
+ "amount": decimal_text(row.get(amount_key)),
+ }
+ )
+
+ rows.sort(key=lambda item: item["date"])
+ return rows
+
+
+def stock_market_symbol(code):
+ if code[0] in ("6", "9"):
+ return f"sh{code}"
+ if code[0] in ("0", "3", "2"):
+ return f"sz{code}"
+ if code[0] in ("4", "8"):
+ return f"bj{code}"
+ return f"sh{code}"
+
+
+def stock_daily(code, days_text):
+ code = text(code)
+ if code is None or not re.fullmatch(r"\d{6}", code):
+ raise ValueError("stock code must contain exactly six digits")
+
+ if days_text is None:
+ days = 5
+ else:
+ try:
+ days = int(days_text)
+ except (TypeError, ValueError):
+ raise ValueError("days must be an integer between 1 and 30")
+
+ if days < 1 or days > 30:
+ raise ValueError("days must be an integer between 1 and 30")
+
+ symbol = stock_market_symbol(code)
+ providers = [
+ ((ak.stock_zh_a_hist, {"symbol": code, "period": "daily", "adjust": ""}), "日期", "收盘", "成交量", "成交额"),
+ ((ak.stock_zh_a_daily, {"symbol": symbol, "adjust": ""}), "date", "close", "volume", "amount"),
+ ((ak.stock_zh_a_hist_tx, {"symbol": symbol}), "date", "close", "volume", "amount"),
+ ]
+
+ for (fetch, kwargs), date_key, close_key, volume_key, amount_key in providers:
+ try:
+ frame = fetch(**kwargs)
+ except Exception:
+ continue
+
+ if frame is None or frame.empty:
+ continue
+
+ observations = stock_daily_observations(frame, date_key, close_key, volume_key, amount_key)
+ if not observations:
+ continue
+
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "operation": "stock-daily",
+ "source": "akshare",
+ "source_revision": source_revision(),
+ "collected_at": collected_at(),
+ "instrument": {"code": code},
+ "observations": observations[-days:],
+ }
+
+ raise ValueError(f"no valid daily history for {code}")
+
+
def stock_quote(code):
code = text(code)
if code is None or not re.fullmatch(r"\d{6}", code):
@@ -305,9 +386,10 @@ def stock_quote(code):
def main():
parser = argparse.ArgumentParser()
- parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote"), required=True)
+ parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote", "stock-daily"), required=True)
parser.add_argument("--query")
parser.add_argument("--code")
+ parser.add_argument("--days")
args = parser.parse_args()
try:
@@ -317,8 +399,10 @@ def main():
payload = nav(args.code)
elif args.operation == "bond-quote":
payload = bond_quote(args.code)
- else:
+ elif args.operation == "stock-quote":
payload = stock_quote(args.code)
+ else:
+ payload = stock_daily(args.code, args.days)
json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":"))
sys.stdout.write("\n")