summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/akshare_collector.py
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 02:13:04 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 02:13:04 +0800
commit7da3c3d938e4b64a43963d602ab4a0a8c89f6f9e (patch)
tree6fc3c06f6474aa4ad06fde775b3618e8bf392fba /src/FundLab.Api/akshare_collector.py
parent0d1af831c10683bb1e5a5853e5924c5ddfb1310d (diff)
downloadfund-lab-7da3c3d938e4b64a43963d602ab4a0a8c89f6f9e.tar.gz
Add real AKShare stock quote probe slice (3d-17)
Diffstat (limited to 'src/FundLab.Api/akshare_collector.py')
-rw-r--r--src/FundLab.Api/akshare_collector.py102
1 files changed, 100 insertions, 2 deletions
diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py
index 652f32d..62632bf 100644
--- a/src/FundLab.Api/akshare_collector.py
+++ b/src/FundLab.Api/akshare_collector.py
@@ -207,9 +207,105 @@ def bond_quote(code):
}
+def first_positive_decimal(*values):
+ for value in values:
+ price = decimal_text(value)
+ if price is not None and Decimal(price) > 0:
+ return price
+ return None
+
+
+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.
+ frame = ak.stock_bid_ask_em(symbol=code)
+ if frame is None or frame.empty:
+ return None
+
+ for _, row in frame.iterrows():
+ item = text(row.get("item"))
+ if item in ("最新", "最新价"):
+ return first_positive_decimal(row.get("value"))
+
+ return None
+
+
+def stock_from_snapshot(code):
+ # Whole-market snapshots differ per provider; each entry is
+ # (fetch, code column, name column, price column, exchange prefixes).
+ providers = [
+ (ak.stock_zh_a_spot_tx, "code", "name", "zxj", {"sh": 0, "sz": 1, "bj": 2}),
+ (ak.stock_zh_a_spot_em, "代码", "名称", "最新价", None),
+ ]
+
+ for fetch, code_key, name_key, price_key, prefixes in providers:
+ try:
+ frame = fetch()
+ except Exception:
+ continue
+
+ if frame is None or frame.empty:
+ continue
+
+ candidates = []
+
+ for _, row in frame.iterrows():
+ row_code = text(row.get(code_key)) or ""
+ if row_code != code and not row_code.lower().endswith(code):
+ continue
+
+ price = first_positive_decimal(row.get(price_key))
+ if price is None:
+ continue
+
+ prefix = row_code[:2].lower()
+ rank = prefixes.get(prefix, 3) if prefixes is not None else 0
+ candidates.append((rank, row_code, text(row.get(name_key)), price))
+
+ if candidates:
+ _, _, name, price = sorted(candidates, key=lambda item: (item[0], item[1]))[0]
+ return name, price
+
+ return None
+
+
+def stock_quote(code):
+ code = text(code)
+ if code is None or not re.fullmatch(r"\d{6}", code):
+ raise ValueError("stock code must contain exactly six digits")
+
+ name = None
+ price = None
+
+ try:
+ price = stock_price_from_bid_ask(code)
+ except Exception:
+ price = None
+
+ if price is None:
+ found = stock_from_snapshot(code)
+ if found is None:
+ raise ValueError(f"no valid quote for {code}")
+ name, price = found
+
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "operation": "stock-quote",
+ "source": "akshare",
+ "source_revision": source_revision(),
+ "collected_at": collected_at(),
+ "instrument": {"code": code},
+ "quote": {
+ "name": name,
+ "price": price,
+ "currency": "CNY",
+ },
+ }
+
+
def main():
parser = argparse.ArgumentParser()
- parser.add_argument("--operation", choices=("search", "nav", "bond-quote"), required=True)
+ parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote"), required=True)
parser.add_argument("--query")
parser.add_argument("--code")
args = parser.parse_args()
@@ -219,8 +315,10 @@ def main():
payload = search(args.query)
elif args.operation == "nav":
payload = nav(args.code)
- else:
+ elif args.operation == "bond-quote":
payload = bond_quote(args.code)
+ else:
+ payload = stock_quote(args.code)
json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":"))
sys.stdout.write("\n")