summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Api')
-rw-r--r--src/FundLab.Api/App.fs8
-rw-r--r--src/FundLab.Api/MarketData.fs37
-rw-r--r--src/FundLab.Api/StockQuoteProbe.fs9
-rw-r--r--src/FundLab.Api/akshare_collector.py61
4 files changed, 100 insertions, 15 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index d515ca6..a77fdf5 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -591,6 +591,9 @@ type StockQuoteApiResponse =
name: string option
price: string option
currency: string
+ market: string option
+ adjust: string option
+ suspended: bool option
}
type StockDailyObservationApiResponse =
@@ -2291,7 +2294,10 @@ module App =
({ code = quote.Code
name = quote.Name
price = quote.Price |> Option.map decimalText
- currency = quote.Currency }
+ currency = quote.Currency
+ market = quote.Market
+ adjust = quote.Adjust
+ suspended = quote.Suspended }
: StockQuoteApiResponse)
next
ctx
diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs
index 152ffd7..12e4893 100644
--- a/src/FundLab.Api/MarketData.fs
+++ b/src/FundLab.Api/MarketData.fs
@@ -71,6 +71,9 @@ type MarketDataStockQuotePayload =
Name: string option
Price: decimal option
Currency: string
+ Market: string option
+ Adjust: string option
+ Suspended: bool option
}
type MarketDataStockDailyBar =
@@ -87,6 +90,8 @@ type MarketDataStockDailyPayload =
SourceRevision: string
CollectedAt: DateTimeOffset
Code: string
+ Market: string option
+ Adjust: string option
Bars: MarketDataStockDailyBar list
}
@@ -183,6 +188,27 @@ module MarketData =
else
Error(sprintf "payload property '%s' must be null or a string" name))
+ let private optionalBool (root: JsonElement) name =
+ optionalProperty root name
+ |> Result.bind (function
+ | None -> Ok None
+ | Some property ->
+ match property.ValueKind with
+ | JsonValueKind.Null -> Ok None
+ | JsonValueKind.True -> Ok(Some true)
+ | JsonValueKind.False -> Ok(Some false)
+ | _ -> Error(sprintf "payload property '%s' must be null or a boolean" name))
+
+ let private optionalStringOrMissing (root: JsonElement) name =
+ optionalProperty root name
+ |> Result.bind (function
+ | None -> Ok None
+ | Some property when property.ValueKind = JsonValueKind.Null -> Ok None
+ | Some property when property.ValueKind = JsonValueKind.String ->
+ let value = property.GetString()
+ if String.IsNullOrWhiteSpace value then Ok None else Ok(Some value)
+ | Some _ -> Error(sprintf "payload property '%s' must be null or a string" name))
+
let private validateEnvelope root operation =
result {
let! schemaVersion = requiredString root "schema_version"
@@ -475,6 +501,9 @@ module MarketData =
let! priceProperty = requiredProperty quoteProperty "price"
let! price = optionalDecimal "price" priceProperty
let! currency = requiredString quoteProperty "currency"
+ let! market = optionalStringOrMissing quoteProperty "market"
+ let! adjust = optionalStringOrMissing quoteProperty "adjust"
+ let! suspended = optionalBool quoteProperty "suspended"
return
{
@@ -485,6 +514,9 @@ module MarketData =
Name = name
Price = price
Currency = currency
+ Market = market
+ Adjust = adjust
+ Suspended = suspended
}
}
with
@@ -530,12 +562,17 @@ module MarketData =
if List.isEmpty bars then
return! Error "observations must not be empty"
+ let! market = optionalStringOrMissing root "market"
+ let! adjust = optionalStringOrMissing root "adjust"
+
return
{
Source = source
SourceRevision = sourceRevision
CollectedAt = collectedAt
Code = codeProperty
+ Market = market
+ Adjust = adjust
Bars = bars
}
}
diff --git a/src/FundLab.Api/StockQuoteProbe.fs b/src/FundLab.Api/StockQuoteProbe.fs
index a41070a..b450e13 100644
--- a/src/FundLab.Api/StockQuoteProbe.fs
+++ b/src/FundLab.Api/StockQuoteProbe.fs
@@ -12,6 +12,12 @@ type StockQuote =
Name: string option
Price: decimal option
Currency: string
+ /// Exchange prefix: "sh", "sz" or "bj".
+ Market: string option
+ /// Price adjustment flag: "none", "qfq" or "hfq".
+ Adjust: string option
+ /// True when the venue reports no trades for the session.
+ Suspended: bool option
}
/// Read-only probe that returns the current quote for a stock code.
@@ -55,6 +61,9 @@ type AkshareStockQuoteProbe(collector: IMarketDataCollector) =
Name = payload.Name
Price = payload.Price
Currency = payload.Currency
+ Market = payload.Market
+ Adjust = payload.Adjust
+ Suspended = payload.Suspended
}
cache.[normalized] <- quote
diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py
index 9234d81..aed053a 100644
--- a/src/FundLab.Api/akshare_collector.py
+++ b/src/FundLab.Api/akshare_collector.py
@@ -333,17 +333,26 @@ def first_positive_decimal(*values):
def stock_price_from_bid_ask(code):
# Single-symbol realtime snapshot (eastmoney). Returns None when the venue
- # rejects the symbol or carries no valid last price.
+ # rejects the symbol or carries no valid last price. Volume is returned so
+ # callers can flag a suspended session (no trades => zero volume).
frame = ak.stock_bid_ask_em(symbol=code)
if frame is None or frame.empty:
return None
+ price = None
+ volume = None
+
for _, row in frame.iterrows():
item = text(row.get("item"))
if item in ("最新", "最新价"):
- return first_positive_decimal(row.get("value"))
+ price = first_positive_decimal(row.get("value"))
+ elif item in ("总量", "成交量", "总手"):
+ volume = decimal_text(row.get("value"))
- return None
+ if price is None:
+ return None
+
+ return price, volume
def stock_from_snapshot(code):
@@ -407,17 +416,22 @@ def stock_daily_observations(frame, date_key, close_key, volume_key, amount_key)
return rows
-def stock_market_symbol(code):
+def stock_market(code):
+ # A-share exchange mapping from the six-digit code prefix.
if code[0] in ("6", "9"):
- return f"sh{code}"
+ return "sh"
if code[0] in ("0", "3", "2"):
- return f"sz{code}"
+ return "sz"
if code[0] in ("4", "8"):
- return f"bj{code}"
- return f"sh{code}"
+ return "bj"
+ return "sh"
-def stock_daily(code, days_text):
+def stock_market_symbol(code):
+ return f"{stock_market(code)}{code}"
+
+
+def stock_daily(code, days_text, adjust_text=None):
code = text(code)
if code is None or not re.fullmatch(r"\d{6}", code):
raise ValueError("stock code must contain exactly six digits")
@@ -433,10 +447,12 @@ def stock_daily(code, days_text):
if days < 1 or days > 30:
raise ValueError("days must be an integer between 1 and 30")
+ adjust = adjust_text if adjust_text in ("qfq", "hfq") else ""
+
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, {"symbol": code, "period": "daily", "adjust": adjust}), "日期", "收盘", "成交量", "成交额"),
+ ((ak.stock_zh_a_daily, {"symbol": symbol, "adjust": adjust}), "date", "close", "volume", "amount"),
((ak.stock_zh_a_hist_tx, {"symbol": symbol}), "date", "close", "volume", "amount"),
]
@@ -460,6 +476,8 @@ def stock_daily(code, days_text):
"source_revision": source_revision(),
"collected_at": collected_at(),
"instrument": {"code": code},
+ "market": stock_market(code),
+ "adjust": adjust or "none",
"observations": observations[-days:],
}
@@ -473,11 +491,15 @@ def stock_quote(code):
name = None
price = None
+ volume = None
try:
- price = stock_price_from_bid_ask(code)
+ direct = stock_price_from_bid_ask(code)
except Exception:
- price = None
+ direct = None
+
+ if direct is not None:
+ price, volume = direct
if price is None:
found = stock_from_snapshot(code)
@@ -485,6 +507,13 @@ def stock_quote(code):
raise ValueError(f"no valid quote for {code}")
name, price = found
+ suspended = False
+ if volume is not None:
+ try:
+ suspended = Decimal(volume) == 0
+ except Exception:
+ suspended = False
+
return {
"schema_version": SCHEMA_VERSION,
"operation": "stock-quote",
@@ -496,6 +525,9 @@ def stock_quote(code):
"name": name,
"price": price,
"currency": "CNY",
+ "market": stock_market(code),
+ "adjust": "none",
+ "suspended": suspended,
},
}
@@ -506,6 +538,7 @@ def main():
parser.add_argument("--query")
parser.add_argument("--code")
parser.add_argument("--days")
+ parser.add_argument("--adjust", choices=("none", "qfq", "hfq"))
args = parser.parse_args()
try:
@@ -518,7 +551,7 @@ def main():
elif args.operation == "stock-quote":
payload = stock_quote(args.code)
else:
- payload = stock_daily(args.code, args.days)
+ payload = stock_daily(args.code, args.days, args.adjust)
json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":"))
sys.stdout.write("\n")