From 9c0e5d9c5780a25f86d63e567689e6abaec975ec Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 15 Jun 2026 18:28:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=BB=E5=8A=9B=E5=90=88?= =?UTF-8?q?=E7=BA=A6=E8=AF=86=E5=88=AB=EF=BC=9A=E6=8C=89=E6=8C=81=E4=BB=93?= =?UTF-8?q?=E9=87=8F=E5=88=A4=E5=AE=9A=E5=B9=B6=E7=A7=BB=E9=99=A4=E5=BD=93?= =?UTF-8?q?=E6=9C=88=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app.py | 3 +- market.py | 68 +++++++++++++++++++++++++++------------- static/js/symbol.js | 8 ++++- symbols.py | 76 +++++++++++++++++++++++++++++++-------------- 4 files changed, 109 insertions(+), 46 deletions(-) diff --git a/app.py b/app.py index 8cb94ad..56f0979 100644 --- a/app.py +++ b/app.py @@ -17,7 +17,7 @@ from flask import ( ) from werkzeug.security import check_password_hash, generate_password_hash -from symbols import search_symbols, ths_to_codes, list_main_contracts_grouped +from symbols import search_symbols, ths_to_codes, list_main_contracts_grouped, refresh_main_index from contract_specs import calc_position_metrics from fee_specs import ( calc_fee_breakdown, @@ -580,6 +580,7 @@ def start_background_threads(): target=lambda: kline_hub.worker_loop(DB_PATH, build_market_quote_payload), daemon=True, ).start() + threading.Thread(target=refresh_main_index, daemon=True).start() start_background_threads() diff --git a/market.py b/market.py index 2874af8..524112e 100644 --- a/market.py +++ b/market.py @@ -51,6 +51,52 @@ def _sina_headers() -> dict: return {"Referer": "https://finance.sina.com.cn"} +def _parse_sina_futures_quote(parts: list) -> Optional[dict]: + """解析新浪 nf_/CFF_RE_ 期货行情字段。""" + if len(parts) < 9: + return None + price = None + for idx in (8, 7, 6, 5): + if len(parts) > idx and parts[idx]: + try: + val = float(parts[idx]) + if val > 0: + price = val + break + except ValueError: + pass + if price is None: + price = 0.0 + + open_interest = 0.0 + volume = 0.0 + if len(parts) > 13 and parts[13]: + try: + open_interest = float(parts[13]) + except ValueError: + pass + if len(parts) > 14 and parts[14]: + try: + volume = float(parts[14]) + except ValueError: + pass + + prev_close = None + if len(parts) > 9 and parts[9]: + try: + prev_close = float(parts[9]) + except ValueError: + pass + + return { + "name": parts[0], + "price": price, + "volume": volume, + "open_interest": open_interest, + "prev_close": prev_close, + } + + def _fetch_sina_raw(sina_code: str) -> Optional[dict]: try: url = f"https://hq.sinajs.cn/list={sina_code}" @@ -62,27 +108,7 @@ def _fetch_sina_raw(sina_code: str) -> Optional[dict]: if not body: return None parts = body.split(",") - if len(parts) < 9: - return None - price = float(parts[8]) - volume = float(parts[14]) if len(parts) > 14 and parts[14] else 0 - prev_close = None - if len(parts) > 9 and parts[9]: - try: - prev_close = float(parts[9]) - except ValueError: - pass - if prev_close is None and len(parts) > 2 and parts[2]: - try: - prev_close = float(parts[2]) - except ValueError: - pass - return { - "name": parts[0], - "price": price, - "volume": volume, - "prev_close": prev_close, - } + return _parse_sina_futures_quote(parts) except Exception as exc: logger.debug("sina fetch failed %s: %s", sina_code, exc) return None diff --git a/static/js/symbol.js b/static/js/symbol.js index 0f5ecd7..c4c4518 100644 --- a/static/js/symbol.js +++ b/static/js/symbol.js @@ -112,8 +112,14 @@ renderGrouped(mainsCache, filterQ); return; } - if (mainsLoading) return; + if (mainsLoading) { + dropdown.innerHTML = '
正在识别主力合约…
'; + dropdown.classList.add('show'); + return; + } mainsLoading = true; + dropdown.innerHTML = '
正在识别主力合约…
'; + dropdown.classList.add('show'); fetch('/api/symbols/mains') .then(function (r) { return r.json(); }) .then(function (groups) { diff --git a/symbols.py b/symbols.py index b35d699..ced42c2 100644 --- a/symbols.py +++ b/symbols.py @@ -71,6 +71,7 @@ _CACHE_TTL = 300 _main_index_lock = threading.Lock() _main_index: dict[str, dict] = {} _main_index_ts = 0.0 +_index_refresh_lock = threading.Lock() def build_ths_code(product: dict, year: int, month: int) -> str: @@ -210,7 +211,20 @@ def is_near_expiry_main(ths_code: str) -> bool: return months_ahead <= 1 -def _make_symbol_item(product: dict, year: int, month: int, volume: float) -> dict: +def _main_contract_score(raw: dict) -> float: + """主力判定:优先持仓量,其次成交量。""" + oi = float(raw.get("open_interest") or 0) + vol = float(raw.get("volume") or 0) + return oi if oi > 0 else vol + + +def _make_symbol_item( + product: dict, + year: int, + month: int, + volume: float, + open_interest: float = 0, +) -> dict: ths = build_ths_code(product, year, month) name = product["name"] return { @@ -223,6 +237,7 @@ def _make_symbol_item(product: dict, year: int, month: int, volume: float) -> di "display": f"{name} 主力 {ths}", "input_label": f"{name} {ths}", "volume": volume, + "open_interest": open_interest, } @@ -236,6 +251,7 @@ def resolve_main_contract(product: dict) -> Optional[dict]: today = date.today() y, m = today.year, today.month best = None + best_score = 0.0 for i in range(14): cy, cm = y, m + i @@ -244,10 +260,17 @@ def resolve_main_contract(product: dict) -> Optional[dict]: cy += 1 sina = build_sina_code(product, cy, cm) raw = fetch_raw_for_volume(sina) - if raw and raw["volume"] > 0: - item = _make_symbol_item(product, cy, cm, raw["volume"]) - if best is None or raw["volume"] > best["volume"]: - best = item + if not raw: + continue + score = _main_contract_score(raw) + if score <= 0: + continue + item = _make_symbol_item( + product, cy, cm, raw["volume"], raw.get("open_interest", 0), + ) + if score > best_score: + best_score = score + best = item if best is None: sina_main = build_sina_main_code(product) @@ -288,20 +311,21 @@ def _enrich_item(item: dict) -> dict: def refresh_main_index(): """后台预热全部品种主力合约,搜索时只读本地缓存。""" global _main_index, _main_index_ts - new_idx: dict[str, dict] = {} - with ThreadPoolExecutor(max_workers=10) as pool: - futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS} - for fut in as_completed(futures): - product = futures[fut] - try: - main = fut.result() - if main: - new_idx[product["sina"]] = _enrich_item(main) - except Exception: - pass - with _main_index_lock: - _main_index = new_idx - _main_index_ts = time.time() + with _index_refresh_lock: + new_idx: dict[str, dict] = {} + with ThreadPoolExecutor(max_workers=10) as pool: + futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS} + for fut in as_completed(futures): + product = futures[fut] + try: + main = fut.result() + if main: + new_idx[product["sina"]] = _enrich_item(main) + except Exception: + pass + with _main_index_lock: + _main_index = new_idx + _main_index_ts = time.time() def _warm_loop(): @@ -399,15 +423,21 @@ def list_main_contracts_grouped() -> list[dict]: """按交易所分类返回全部品种主力合约(行情页下拉用)。""" with _main_index_lock: index = dict(_main_index) - index_ready = bool(index) + + if len(index) < len(PRODUCTS) // 2: + refresh_main_index() + with _main_index_lock: + index = dict(_main_index) buckets: dict[str, list] = defaultdict(list) for p in PRODUCTS: main = index.get(p["sina"]) - if not main and not index_ready: - main = _stub_main_contract(p) + if not main: + resolved = resolve_main_contract(p) + if resolved: + main = _enrich_item(resolved) if main: - buckets[p["exchange"]].append(_enrich_item(main)) + buckets[p["exchange"]].append(main) groups: list[dict] = [] for cat in EXCHANGE_ORDER: