Label night-session products and hide day-only symbols at night.

Mark tradable varieties with a night tag; during 21:00-02:30 filter out index futures and other products without night sessions from symbol picker and recommend list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 22:27:47 +08:00
parent f2940d41e9
commit 7f8b4cfefd
11 changed files with 193 additions and 13 deletions
+3 -1
View File
@@ -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:
+13
View File
@@ -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):
+4 -1
View File
@@ -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:
+4 -1
View File
@@ -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(
+16
View File
@@ -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()
+76
View File
@@ -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()
+5
View File
@@ -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);
+4 -1
View File
@@ -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 += ' <span class="near-expiry-tag">临期</span>';
}
if (item.has_night_session) {
label += ' <span class="night-session-tag">夜盘</span>';
}
div.innerHTML = label +
'<div class="sub">' + formatSub(item) + '</div>';
div.addEventListener('mousedown', function (e) {
+3 -2
View File
@@ -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 ? ' <span class="night-session-tag">夜盘</span>' : '';
if (marketNavEnabled && r.main_code) {
var href = '/market?symbol=' + encodeURIComponent(r.main_code) + '&period=d';
return (
'<td><a href="' + href + '" class="rec-market-link" title="查看日线 K 线">' +
'<strong' + nameCls + '>' + name + '</strong> ' +
'<strong' + nameCls + '>' + name + nightTag + '</strong> ' +
'<span class="text-accent">' + r.main_code + '</span></a></td>'
);
}
return (
'<td><strong' + nameCls + '>' + name + '</strong> ' +
'<td><strong' + nameCls + '>' + name + nightTag + '</strong> ' +
'<span class="text-accent">' + code + '</span></td>'
);
}
+62 -5
View File
@@ -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:
+3 -2
View File
@@ -133,6 +133,7 @@
<div class="card-body">
<p class="hint">最大手数 = floor(权益 × 保证金上限 <strong>{{ max_margin_pct }}%</strong> ÷ 1手保证金);当前权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(capital) }}</strong> 元。
{% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ <strong>{{ fixed_lots }}</strong> 手的品种。{% endif %}
{% if night_session %}<span class="text-muted">当前为夜盘时段,品种下拉与下表仅显示有夜盘品种;带「夜盘」标记。</span>{% else %}<span class="text-muted">有夜盘交易的品种带「夜盘」标记。</span>{% endif %}
保证金优先读取 CTP 柜台合约信息。
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
</p>
@@ -173,11 +174,11 @@
<td>
{% if r.main_code and nav_items.market %}
<a href="{{ url_for('market_page', symbol=r.main_code, period='d') }}" class="rec-market-link" title="查看日线 K 线">
<strong class="{% if r.trend_transition %}trend-name{% endif %}">{{ r.name }}</strong>
<strong class="{% if r.trend_transition %}trend-name{% endif %}">{{ r.name }}</strong>{% if r.has_night_session %} <span class="night-session-tag">夜盘</span>{% endif %}
<span class="text-accent">{{ r.main_code }}</span>
</a>
{% else %}
<strong class="{% if r.trend_transition %}trend-name{% endif %}">{{ r.name }}</strong>
<strong class="{% if r.trend_transition %}trend-name{% endif %}">{{ r.name }}</strong>{% if r.has_night_session %} <span class="night-session-tag">夜盘</span>{% endif %}
<span class="text-accent">{{ r.main_code or r.ths }}</span>
{% endif %}
</td>