修复主力合约识别:按持仓量判定并移除当月占位

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-15 18:28:51 +08:00
parent 404872007f
commit 9c0e5d9c57
4 changed files with 109 additions and 46 deletions
+2 -1
View File
@@ -17,7 +17,7 @@ from flask import (
) )
from werkzeug.security import check_password_hash, generate_password_hash 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 contract_specs import calc_position_metrics
from fee_specs import ( from fee_specs import (
calc_fee_breakdown, calc_fee_breakdown,
@@ -580,6 +580,7 @@ def start_background_threads():
target=lambda: kline_hub.worker_loop(DB_PATH, build_market_quote_payload), target=lambda: kline_hub.worker_loop(DB_PATH, build_market_quote_payload),
daemon=True, daemon=True,
).start() ).start()
threading.Thread(target=refresh_main_index, daemon=True).start()
start_background_threads() start_background_threads()
+47 -21
View File
@@ -51,6 +51,52 @@ def _sina_headers() -> dict:
return {"Referer": "https://finance.sina.com.cn"} 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]: def _fetch_sina_raw(sina_code: str) -> Optional[dict]:
try: try:
url = f"https://hq.sinajs.cn/list={sina_code}" 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: if not body:
return None return None
parts = body.split(",") parts = body.split(",")
if len(parts) < 9: return _parse_sina_futures_quote(parts)
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,
}
except Exception as exc: except Exception as exc:
logger.debug("sina fetch failed %s: %s", sina_code, exc) logger.debug("sina fetch failed %s: %s", sina_code, exc)
return None return None
+7 -1
View File
@@ -112,8 +112,14 @@
renderGrouped(mainsCache, filterQ); renderGrouped(mainsCache, filterQ);
return; return;
} }
if (mainsLoading) return; if (mainsLoading) {
dropdown.innerHTML = '<div class="symbol-option">正在识别主力合约…</div>';
dropdown.classList.add('show');
return;
}
mainsLoading = true; mainsLoading = true;
dropdown.innerHTML = '<div class="symbol-option">正在识别主力合约…</div>';
dropdown.classList.add('show');
fetch('/api/symbols/mains') fetch('/api/symbols/mains')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (groups) { .then(function (groups) {
+38 -8
View File
@@ -71,6 +71,7 @@ _CACHE_TTL = 300
_main_index_lock = threading.Lock() _main_index_lock = threading.Lock()
_main_index: dict[str, dict] = {} _main_index: dict[str, dict] = {}
_main_index_ts = 0.0 _main_index_ts = 0.0
_index_refresh_lock = threading.Lock()
def build_ths_code(product: dict, year: int, month: int) -> str: 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 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) ths = build_ths_code(product, year, month)
name = product["name"] name = product["name"]
return { return {
@@ -223,6 +237,7 @@ def _make_symbol_item(product: dict, year: int, month: int, volume: float) -> di
"display": f"{name} 主力 {ths}", "display": f"{name} 主力 {ths}",
"input_label": f"{name} {ths}", "input_label": f"{name} {ths}",
"volume": volume, "volume": volume,
"open_interest": open_interest,
} }
@@ -236,6 +251,7 @@ def resolve_main_contract(product: dict) -> Optional[dict]:
today = date.today() today = date.today()
y, m = today.year, today.month y, m = today.year, today.month
best = None best = None
best_score = 0.0
for i in range(14): for i in range(14):
cy, cm = y, m + i cy, cm = y, m + i
@@ -244,9 +260,16 @@ def resolve_main_contract(product: dict) -> Optional[dict]:
cy += 1 cy += 1
sina = build_sina_code(product, cy, cm) sina = build_sina_code(product, cy, cm)
raw = fetch_raw_for_volume(sina) raw = fetch_raw_for_volume(sina)
if raw and raw["volume"] > 0: if not raw:
item = _make_symbol_item(product, cy, cm, raw["volume"]) continue
if best is None or raw["volume"] > best["volume"]: 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 best = item
if best is None: if best is None:
@@ -288,6 +311,7 @@ def _enrich_item(item: dict) -> dict:
def refresh_main_index(): def refresh_main_index():
"""后台预热全部品种主力合约,搜索时只读本地缓存。""" """后台预热全部品种主力合约,搜索时只读本地缓存。"""
global _main_index, _main_index_ts global _main_index, _main_index_ts
with _index_refresh_lock:
new_idx: dict[str, dict] = {} new_idx: dict[str, dict] = {}
with ThreadPoolExecutor(max_workers=10) as pool: with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS} futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS}
@@ -399,15 +423,21 @@ def list_main_contracts_grouped() -> list[dict]:
"""按交易所分类返回全部品种主力合约(行情页下拉用)。""" """按交易所分类返回全部品种主力合约(行情页下拉用)。"""
with _main_index_lock: with _main_index_lock:
index = dict(_main_index) 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) buckets: dict[str, list] = defaultdict(list)
for p in PRODUCTS: for p in PRODUCTS:
main = index.get(p["sina"]) main = index.get(p["sina"])
if not main and not index_ready: if not main:
main = _stub_main_contract(p) resolved = resolve_main_contract(p)
if resolved:
main = _enrich_item(resolved)
if main: if main:
buckets[p["exchange"]].append(_enrich_item(main)) buckets[p["exchange"]].append(main)
groups: list[dict] = [] groups: list[dict] = []
for cat in EXCHANGE_ORDER: for cat in EXCHANGE_ORDER: