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: