diff --git a/install_trading.py b/install_trading.py index 3de840a..03ef6d9 100644 --- a/install_trading.py +++ b/install_trading.py @@ -17,7 +17,7 @@ from flask import flash, jsonify, redirect, render_template, request, url_for, R from contract_specs import calc_position_metrics, get_contract_spec from fee_specs import calc_fee_breakdown from kline_stream import sse_format -from market_sessions import is_trading_session +from market_sessions import is_night_trading_session, is_trading_session from position_sizing import ( MODE_AMOUNT, MODE_FIXED, @@ -1493,6 +1493,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "trading_mode_label": trading_mode_label(get_setting), "risk_status": risk, "trading_session": is_trading_session(), + "night_session": is_night_trading_session(), "pending_order_timeout_min": get_pending_order_timeout_min(get_setting), "sync_state": trading_state.sync_state, "sync_label": trading_state.sync_label(), @@ -1636,6 +1637,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se ctp_auto_connect=is_ctp_auto_connect_enabled(get_setting), recommend_rows=rec_cache.get("rows") or [], recommend_updated_at=rec_cache.get("updated_at"), + night_session=is_night_trading_session(), product_categories=PRODUCT_CATEGORIES, ) finally: diff --git a/market_sessions.py b/market_sessions.py index 237e242..9d613f7 100644 --- a/market_sessions.py +++ b/market_sessions.py @@ -45,6 +45,19 @@ def is_trading_session(now: Optional[datetime] = None) -> bool: return False +def is_night_trading_session(now: Optional[datetime] = None) -> bool: + """当前是否处于夜盘时段(21:00–02:30,且整体仍在交易时段内)。""" + if not is_trading_session(now): + return False + d = now or datetime.now(TZ) + if d.tzinfo is None: + d = d.replace(tzinfo=TZ) + else: + d = d.astimezone(TZ) + t = d.hour * 60 + d.minute + return t >= 21 * 60 or t < 2 * 60 + 30 + + def _session_open_allowed(day: datetime, hour: int, minute: int) -> bool: wd = day.weekday() if (hour, minute) == (9, 0) or (hour, minute) == (13, 30): diff --git a/product_recommend.py b/product_recommend.py index 398ccf5..ffceb2e 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -14,7 +14,7 @@ from typing import Callable, Optional from contract_specs import get_contract_spec from fee_specs import calc_fee_breakdown from recommend_trend import analyze_product_daily, sort_recommend_by_trend -from symbols import PRODUCTS, product_category +from symbols import PRODUCTS, product_category, product_has_night_session logger = logging.getLogger(__name__) @@ -74,6 +74,7 @@ def assess_product_for_capital( "margin_one_lot": None, "max_lots": 0, "risk_one_lot_1pct": None, + "has_night_session": product_has_night_session(product), } margin_one = p * mult * margin_rate @@ -125,6 +126,7 @@ def assess_product_for_capital( "roundtrip_fee_one_lot": fee_info["total_fee"], "status": status, "status_label": label, + "has_night_session": product_has_night_session(product), } @@ -167,6 +169,7 @@ def list_product_recommendations( "status_label": "计算失败", "main_code": "", "max_lots": 0, + "has_night_session": product_has_night_session(product), } with ThreadPoolExecutor(max_workers=10) as pool: diff --git a/recommend_store.py b/recommend_store.py index 14dde65..133ec34 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -181,9 +181,12 @@ def enrich_recommend_rows( row["status_label"] = "资金不足" if not row.get("category"): row["category"] = product_category(row.get("ths") or "") + from symbols import enrich_recommend_row + row = enrich_recommend_row(row) _attach_turnover(row) enriched.append(row) - return enriched + from symbols import filter_for_trading_session + return filter_for_trading_session(enriched) def filter_recommend_by_sizing( diff --git a/scripts/check_server_night.py b/scripts/check_server_night.py new file mode 100644 index 0000000..58e8656 --- /dev/null +++ b/scripts/check_server_night.py @@ -0,0 +1,16 @@ +import paramiko +import sys +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("192.168.8.21", username="root", password="woaini88", timeout=15) +for cmd in [ + "ls -la /opt/qihuo/market_sessions.py", + "head -5 /opt/qihuo/market_sessions.py", + "cd /opt/qihuo && /opt/qihuo/venv/bin/python -c \"import market_sessions; print(market_sessions.is_night_trading_session())\"", +]: + _, o, e = c.exec_command(cmd) + print(">>>", cmd) + print(o.read().decode()) + print(e.read().decode()) +c.close() diff --git a/scripts/deploy_night_session.py b/scripts/deploy_night_session.py new file mode 100644 index 0000000..a5d7f85 --- /dev/null +++ b/scripts/deploy_night_session.py @@ -0,0 +1,76 @@ +"""Deploy night session symbol filter.""" +import paramiko +import sys +from pathlib import Path + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +root = Path(__file__).resolve().parents[1] + +FILES = [ + "market_sessions.py", + "symbols.py", + "product_recommend.py", + "recommend_store.py", + "install_trading.py", + "templates/trade.html", + "static/js/symbol.js", + "static/js/trade.js", + "static/css/base.css", +] + +VERIFY = r""" +import sys +sys.path.insert(0, "/opt/qihuo") +from market_sessions import is_night_trading_session, is_trading_session +from symbols import product_has_night_session, list_recommended_symbols_grouped, enrich_recommend_row + +print("trading", is_trading_session(), "night", is_night_trading_session()) +print("IF night", product_has_night_session("IF")) +print("ag night", product_has_night_session("ag")) +print("jd night", product_has_night_session("jd")) + +rows = [ + enrich_recommend_row({"ths": "IF", "name": "沪深300", "status": "ok", "max_lots": 1}), + enrich_recommend_row({"ths": "ag", "name": "白银", "status": "ok", "max_lots": 1}), +] +groups = list_recommended_symbols_grouped(rows) +items = [i for g in groups for i in g.get("items", [])] +ths_set = {i.get("ths_code", "")[:2].lower() for i in items} +print("group ths prefixes", ths_set) +if is_night_trading_session() and any(x.startswith("if") for x in ths_set): + print("FAIL IF shown during night") +elif is_night_trading_session() and not any(x.startswith("ag") for x in ths_set): + print("FAIL ag missing during night") +else: + print("VERIFY PASS") +""" + + +def main() -> None: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect("192.168.8.21", username="root", password="woaini88", timeout=15) + sftp = c.open_sftp() + for rel in FILES: + sftp.put(str(root / rel), f"/opt/qihuo/{rel.replace(chr(92), '/')}") + print("uploaded", rel) + sftp.close() + for cmd in ("cd /opt/qihuo && pm2 restart qihuo", "sleep 3"): + print(">>>", cmd) + _, o, e = c.exec_command(cmd) + print(o.read().decode("utf-8", errors="replace")) + err = e.read().decode("utf-8", errors="replace") + if err.strip(): + print(err.strip()) + sftp = c.open_sftp() + with sftp.open("/tmp/verify_night.py", "w") as f: + f.write(VERIFY) + sftp.close() + _, o, e = c.exec_command("cd /opt/qihuo && /opt/qihuo/venv/bin/python /tmp/verify_night.py") + print(o.read().decode("utf-8", errors="replace")) + print(e.read().decode("utf-8", errors="replace")) + c.close() + + +if __name__ == "__main__": + main() diff --git a/static/css/base.css b/static/css/base.css index 419c829..3f36545 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -281,6 +281,11 @@ font-size:.68rem;padding:.1rem .35rem;border-radius:4px; background:rgba(255,107,122,.15);color:inherit;font-weight:600; } html[data-theme="light"] .near-expiry-tag{background:rgba(220,38,38,.12)} +.night-session-tag{ + display:inline-block;margin-left:4px;padding:0 5px;border-radius:4px;font-size:.7rem;font-weight:600; + color:#7dd3fc;background:rgba(56,189,248,.15);vertical-align:middle;line-height:1.3 +} +html[data-theme="light"] .night-session-tag{color:#0369a1;background:rgba(14,165,233,.12)} .symbol-group-head{ padding:.4rem .85rem;font-size:.72rem;font-weight:600; color:var(--text-muted);background:var(--card-inner); diff --git a/static/js/symbol.js b/static/js/symbol.js index 6a123b9..42cdd30 100644 --- a/static/js/symbol.js +++ b/static/js/symbol.js @@ -106,9 +106,12 @@ div.classList.add('near-expiry'); } var label = item.display || (item.name + ' ' + item.ths_code); - if (item.near_expiry) { + if item.near_expiry) { label += ' 临期'; } + if (item.has_night_session) { + label += ' 夜盘'; + } div.innerHTML = label + '
' + formatSub(item) + '
'; div.addEventListener('mousedown', function (e) { diff --git a/static/js/trade.js b/static/js/trade.js index 281f54e..20c2b8d 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -1405,16 +1405,17 @@ var code = r.main_code || r.ths || ''; var nameCls = r.trend_transition ? ' class="trend-name"' : ''; var name = r.name || ''; + var nightTag = r.has_night_session ? ' 夜盘' : ''; if (marketNavEnabled && r.main_code) { var href = '/market?symbol=' + encodeURIComponent(r.main_code) + '&period=d'; return ( '' + - '' + name + ' ' + + '' + name + nightTag + ' ' + '' + r.main_code + '' ); } return ( - '' + name + ' ' + + '' + name + nightTag + ' ' + '' + code + '' ); } diff --git a/symbols.py b/symbols.py index e4662d9..51a2608 100644 --- a/symbols.py +++ b/symbols.py @@ -87,6 +87,38 @@ PRODUCT_CATEGORIES = ["贵金属", "有色金属", "黑色金属", "能源化工 for _p in PRODUCTS: _p["category"] = PRODUCT_CATEGORY_MAP.get(_p["ths"], "其他") +# 无夜盘品种(日盘-only):中金所股指、大商所鸡蛋/生猪等 +NO_NIGHT_SESSION_THS = frozenset({"IF", "IH", "IC", "IM", "jd", "lh"}) + + +def product_has_night_session(ths_or_product) -> bool: + """品种是否参与夜盘交易。""" + if isinstance(ths_or_product, dict): + ths = (ths_or_product.get("ths") or "").strip() + else: + ths = (ths_or_product or "").strip() + if not ths: + return True + m = re.match(r"^([A-Za-z]+)", ths) + letters = m.group(1) if m else ths + return letters not in NO_NIGHT_SESSION_THS and letters.upper() not in NO_NIGHT_SESSION_THS + + +def filter_for_trading_session(rows: list[dict]) -> list[dict]: + """夜盘时段隐藏无夜盘品种。""" + from market_sessions import is_night_trading_session + + if not is_night_trading_session(): + return rows + out: list[dict] = [] + for row in rows: + if row.get("has_night_session") is False: + continue + ths = row.get("ths") or row.get("ths_code") or "" + if row.get("has_night_session") is True or product_has_night_session(ths): + out.append(row) + return out + def product_category(ths: str) -> str: return PRODUCT_CATEGORY_MAP.get((ths or "").strip(), "其他") @@ -341,15 +373,22 @@ def resolve_main_contract(product: dict) -> Optional[dict]: } if best: + best = _enrich_item(best, product) _MAIN_CACHE[cache_key] = (now, best) return best -def _enrich_item(item: dict) -> dict: +def _enrich_item(item: dict, product: Optional[dict] = None) -> dict: out = dict(item) if not out.get("input_label"): out["input_label"] = f"{out.get('name', '')} {out.get('ths_code', '')}".strip() out["near_expiry"] = is_near_expiry_main(out.get("ths_code", "")) + if product is None and out.get("ths_code"): + product = _product_for_contract_code(out["ths_code"]) + if product is not None: + out["has_night_session"] = product_has_night_session(product) + elif "has_night_session" not in out: + out["has_night_session"] = product_has_night_session(out.get("ths_code") or "") return out @@ -365,7 +404,7 @@ def refresh_main_index(): try: main = fut.result() if main: - new_idx[product["sina"]] = _enrich_item(main) + new_idx[product["sina"]] = _enrich_item(main, product) except Exception: pass with _main_index_lock: @@ -389,7 +428,7 @@ def _start_warm_thread(): def _stub_main_contract(product: dict) -> dict: """缓存未就绪时的快速占位(当月合约),避免首次打开搜索为空。""" today = date.today() - return _enrich_item(_make_symbol_item(product, today.year, today.month, 0)) + return _enrich_item(_make_symbol_item(product, today.year, today.month, 0), product) def _product_matches(product: dict, q_lower: str) -> bool: @@ -428,12 +467,16 @@ def search_symbols(query: str) -> list: return [] q_lower = q.lower() + from market_sessions import is_night_trading_session + night_only = is_night_trading_session() with _main_index_lock: index = dict(_main_index) index_ready = bool(index) scored: list[tuple[int, dict]] = [] for p in PRODUCTS: + if night_only and not product_has_night_session(p): + continue if not _product_matches(p, q_lower): continue main = index.get(p["sina"]) @@ -444,6 +487,7 @@ def search_symbols(query: str) -> list: scored.sort(key=lambda x: -x[0]) results = [item for _, item in scored[:12]] + results = filter_for_trading_session(results) if not results and len(q) >= 3: codes = ths_to_codes(q) @@ -460,10 +504,19 @@ def search_symbols(query: str) -> list: "display": f"{name} ({codes['ths_code']})", "volume": raw.get("volume", 0) if raw else 0, })) + results = filter_for_trading_session(results) return results +def enrich_recommend_row(row: dict) -> dict: + """补全推荐行字段(含是否夜盘)。""" + out = dict(row) + ths = out.get("ths") or "" + out["has_night_session"] = product_has_night_session(ths) + return out + + _THS_TO_PRODUCT = {p["ths"]: p for p in PRODUCTS} for _p in PRODUCTS: _THS_TO_PRODUCT.setdefault(_p["ths"].lower(), _p) @@ -531,7 +584,7 @@ def _item_from_recommend_row(row: dict, product: dict) -> Optional[dict]: } if max_lots is not None: item["max_lots"] = max_lots - return _enrich_item(item) + return _enrich_item(item, product) with _main_index_lock: main = _main_index.get(product["sina"]) @@ -539,7 +592,7 @@ def _item_from_recommend_row(row: dict, product: dict) -> Optional[dict]: item = dict(main) if max_lots is not None: item["max_lots"] = max_lots - return _enrich_item(item) + return _enrich_item(item, product) item = _stub_main_contract(product) if max_lots is not None: @@ -563,6 +616,10 @@ def list_recommended_symbols_grouped(recommend_rows: list[dict]) -> list[dict]: product = _product_for_ths(ths_key) if not product: continue + if not product_has_night_session(product): + from market_sessions import is_night_trading_session + if is_night_trading_session(): + continue seen.add(ths_key) item = _item_from_recommend_row(row, product) if not item: diff --git a/templates/trade.html b/templates/trade.html index 23e60be..71b5f29 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -133,6 +133,7 @@

最大手数 = floor(权益 × 保证金上限 {{ max_margin_pct }}% ÷ 1手保证金);当前权益 {{ '%.2f'|format(capital) }} 元。 {% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ {{ fixed_lots }} 手的品种。{% endif %} + {% if night_session %}当前为夜盘时段,品种下拉与下表仅显示有夜盘品种;带「夜盘」标记。{% else %}有夜盘交易的品种带「夜盘」标记。{% endif %} 保证金优先读取 CTP 柜台合约信息。 {% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %}

@@ -173,11 +174,11 @@ {% if r.main_code and nav_items.market %} - {{ r.name }} + {{ r.name }}{% if r.has_night_session %} 夜盘{% endif %} {{ r.main_code }} {% else %} - {{ r.name }} + {{ r.name }}{% if r.has_night_session %} 夜盘{% endif %} {{ r.main_code or r.ths }} {% endif %}