新增品种简介查询页,支持东方财富/新浪合约规格展示

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-15 15:54:38 +08:00
parent 706a0fd1a3
commit b77f30b3ff
7 changed files with 425 additions and 0 deletions
+1
View File
@@ -19,6 +19,7 @@
| **交易记录与复盘** | 平仓记录核对、填入复盘、K 线自动生成 |
| **统计分析** | 胜率、手续费与净盈亏汇总 |
| **手续费配置** | 本地费率表(默认标准×2),可选 AKShare 同步 |
| **品种简介** | 合约规格查询(东方财富 / 新浪) |
| **系统设置** | 实盘资金、企业微信、改密码、深色/浅色主题 |
## 快速开始
+38
View File
@@ -28,6 +28,7 @@ from fee_specs import (
upsert_fee_rate,
)
from fee_sync import sync_fees_from_akshare
from contract_profile import get_contract_profile
from kline_chart import generate_review_kline_chart
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
@@ -1291,6 +1292,43 @@ def stats():
)
@app.route("/contract")
@login_required
def contract_profile_page():
symbol = request.args.get("symbol", "").strip()
profile = None
error = None
if symbol:
try:
profile = get_contract_profile(symbol)
if not profile:
error = "未查询到该合约简介,请检查合约代码"
except Exception as exc:
app.logger.warning("contract profile failed: %s", exc)
error = f"查询失败:{exc}"
return render_template(
"contract.html",
symbol=symbol,
profile=profile,
error=error,
)
@app.route("/api/contract_profile")
@login_required
def api_contract_profile():
symbol = request.args.get("symbol", "").strip()
if not symbol:
return jsonify({"error": "请提供合约代码"}), 400
try:
profile = get_contract_profile(symbol)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
if not profile:
return jsonify({"error": "未查询到合约简介"}), 404
return jsonify(profile)
@app.route("/fees", methods=["GET", "POST"])
@login_required
def fees():
+275
View File
@@ -0,0 +1,275 @@
"""期货合约简介:东方财富 / 新浪 / AKShare。"""
import logging
import re
from typing import Any, Optional
import requests
from contract_specs import get_contract_spec
from symbols import ths_to_codes, search_symbols
logger = logging.getLogger(__name__)
EM_LABEL_MAP = {
"vname": "交易品种",
"vcode": "交易代码",
"jydw": "交易单位",
"bjdw": "报价单位",
"market": "交易所",
"zxbddw": "最小变动价位",
"zdtbfd": "涨跌停幅度",
"hyjgyf": "合约月份",
"jysj": "交易时间",
"zhjyr": "最后交易日",
"zhjgr": "交割日期",
"jgpj": "交割品级",
"zcjybzj": "最低交易保证金",
"jgfs": "交割方式",
"jgdd": "交割地点",
"ssrq": "上市日期",
}
DISPLAY_ORDER = [
"交易品种",
"交易代码",
"交易单位",
"报价单位",
"最小变动价位",
"最低交易保证金",
"涨跌停幅度",
"合约月份",
"交易时间",
"最后交易日",
"交割日期",
"交割方式",
"交割地点",
"交割品级",
"上市日期",
"交易所",
]
SKIP_ITEMS = {"", "-", "None", "nan", "null"}
def _normalize_ths_code(raw: str) -> Optional[str]:
code = (raw or "").strip()
if not code:
return None
# 已是完整合约
if re.match(r"^[A-Za-z]+\d{3,4}$", code):
return code
# 仅品种字母时尝试匹配主力
results = search_symbols(code)
if results:
return results[0].get("ths_code") or code
codes = ths_to_codes(code)
if codes:
return codes["ths_code"]
return code
def _to_sina_quote_symbol(ths_code: str) -> str:
m = re.match(r"^([A-Za-z]+)(\d+)$", ths_code.strip())
if not m:
return ths_code.upper()
return m.group(1).upper() + m.group(2)
def _to_em_page_symbol(ths_code: str) -> str:
return ths_code.strip().lower() + "F"
def _clean_value(val: Any) -> str:
if val is None:
return ""
s = str(val).strip()
if s in SKIP_ITEMS:
return ""
return s
def _rows_from_dict(data: dict[str, str]) -> list[dict]:
rows: list[dict] = []
seen: set[str] = set()
for label in DISPLAY_ORDER:
val = _clean_value(data.get(label))
if not val:
continue
hint = _clean_value(data.get(f"{label}_hint"))
rows.append({"label": label, "value": val, "hint": hint})
seen.add(label)
for label, val in data.items():
if label.endswith("_hint") or label in seen:
continue
val = _clean_value(val)
if val:
rows.append({"label": label, "value": val, "hint": ""})
return rows
def _add_computed_hints(ths_code: str, data: dict[str, str]) -> None:
spec = get_contract_spec(ths_code)
mult = spec.get("mult") or 0
tick_raw = data.get("最小变动价位", "")
m = re.search(r"([\d.]+)", tick_raw)
if m and mult:
tick = float(m.group(1))
data["最小变动价位_hint"] = f"一手合约最小波动{round(tick * mult, 2)}"
def _fetch_em_direct(em_symbol: str) -> dict[str, str]:
page_url = f"https://quote.eastmoney.com/qihuo/{em_symbol}.html"
r = requests.get(page_url, timeout=12)
r.encoding = r.apparent_encoding or "utf-8"
inner = None
for pat in [
r"futures_([A-Za-z0-9_]+)",
r"#(futures_[A-Za-z0-9_]+)",
r"/(futures_[A-Za-z0-9_]+)",
]:
m = re.search(pat, r.text)
if m:
inner = m.group(1).replace("futures_", "")
break
if not inner:
raise ValueError("无法解析东方财富合约标识")
info_url = f"https://futsse-static.eastmoney.com/redis?msgid={inner}_info"
r2 = requests.get(info_url, timeout=12)
payload = r2.json()
if not isinstance(payload, dict):
raise ValueError("东方财富返回数据无效")
out: dict[str, str] = {}
for key, label in EM_LABEL_MAP.items():
val = _clean_value(payload.get(key))
if val:
out[label] = val
if not out:
raise ValueError("东方财富合约字段为空")
return out
def _fetch_em_akshare(em_symbol: str) -> dict[str, str]:
import akshare as ak
df = ak.futures_contract_detail_em(symbol=em_symbol)
out: dict[str, str] = {}
for _, row in df.iterrows():
label = _clean_value(row.get("item"))
val = _clean_value(row.get("value"))
if label and val:
if label == "跌涨停板幅度":
label = "涨跌停幅度"
if label == "最后交割日":
label = "交割日期"
if label == "上市交易所":
label = "交易所"
if label == "合约交割月份":
label = "合约月份"
if label == "最初交易保证金":
label = "最低交易保证金"
if label == "最小变动价格":
label = "最小变动价位"
out[label] = val
return out
def _fetch_sina_direct(sina_symbol: str) -> dict[str, str]:
from io import StringIO
import pandas as pd
url = f"https://finance.sina.com.cn/futures/quotes/{sina_symbol}.shtml"
r = requests.get(url, timeout=12, headers={"Referer": "https://finance.sina.com.cn/"})
r.encoding = "gb2312"
tables = pd.read_html(StringIO(r.text))
if len(tables) < 7:
raise ValueError("新浪页面结构变化")
temp_df = tables[6]
parts = []
for ncol in [slice(0, 2), slice(2, 4), slice(4, None)]:
part = temp_df.iloc[:, ncol]
part.columns = ["item", "value"]
parts.append(part)
merged = pd.concat(parts, axis=0, ignore_index=True)
out: dict[str, str] = {}
for _, row in merged.iterrows():
label = _clean_value(row["item"])
val = _clean_value(row["value"])
if not label or not val or len(label) > 80 or "发帖" in val:
continue
out[label] = val
return out
def _fetch_sina_akshare(sina_symbol: str) -> dict[str, str]:
import akshare as ak
df = ak.futures_contract_detail(symbol=sina_symbol)
out: dict[str, str] = {}
for _, row in df.iterrows():
label = _clean_value(row.get("item"))
val = _clean_value(row.get("value"))
if label and val and "发帖" not in val:
out[label] = val
return out
def _merge_profile(primary: dict[str, str], secondary: dict[str, str]) -> dict[str, str]:
merged = dict(secondary)
merged.update(primary)
return merged
def get_contract_profile(raw_symbol: str) -> Optional[dict]:
ths_code = _normalize_ths_code(raw_symbol)
if not ths_code:
return None
em_symbol = _to_em_page_symbol(ths_code)
sina_symbol = _to_sina_quote_symbol(ths_code)
data: dict[str, str] = {}
source_parts: list[str] = []
# 东方财富(字段与看盘软件简介接近)
try:
try:
data = _fetch_em_akshare(em_symbol)
source_parts.append("东方财富")
except ImportError:
data = _fetch_em_direct(em_symbol)
source_parts.append("东方财富")
except Exception as exc:
logger.warning("eastmoney profile failed %s: %s", em_symbol, exc)
# 新浪补充交割地点、上市日期等
sina_data: dict[str, str] = {}
try:
try:
sina_data = _fetch_sina_akshare(sina_symbol)
except ImportError:
sina_data = _fetch_sina_direct(sina_symbol)
if sina_data:
source_parts.append("新浪")
except Exception as exc:
logger.warning("sina profile failed %s: %s", sina_symbol, exc)
if sina_data:
data = _merge_profile(data, sina_data)
if not data:
return None
_add_computed_hints(ths_code, data)
rows = _rows_from_dict(data)
if not rows:
return None
return {
"ths_code": ths_code,
"symbol_name": data.get("交易品种", ""),
"exchange": data.get("交易所", ""),
"rows": rows,
"source": " + ".join(source_parts) if source_parts else "未知",
}
+32
View File
@@ -24,6 +24,7 @@
| 交易记录与复盘 | `/records` | 平仓记录 + 复盘上传与历史 |
| 统计分析 | `/stats` | 胜率、手续费、盈亏汇总 |
| 手续费配置 | `/fees` | 本地费率表与倍率 |
| 品种简介 | `/contract` | 合约规格查询 |
| 系统设置 | `/settings` | 资金、微信、改密码 |
---
@@ -215,6 +216,36 @@
---
## 品种简介
**路径**`/contract`
### 功能
查询指定合约的**交易所规格说明**,展示风格与看盘软件「合约简介」类似:
- 交易品种、交易代码、交易单位、报价单位
- 最小变动价位(附一手最小波动估算)
- 最低交易保证金、涨跌停幅度
- 合约月份、交易时间、最后交易日、交割日期
- 交割方式、交割地点、交割品级、上市日期、交易所
### 使用
1. 导航进入「品种简介」
2. 输入中文品种名或同花顺合约代码(如 `螺纹钢``rb2510`
3. 从联想列表选择或点击「查询」
### 数据来源
- 主数据:**东方财富** 合约详情接口
- 补充:**新浪财经** 合约页(交割地点、上市日期等)
- 若已安装 AKShare,优先走 AKShare 封装;否则直接请求上述数据源
API`GET /api/contract_profile?symbol=rb2510` 返回 JSON。
---
## 系统设置
**路径**`/settings`
@@ -301,6 +332,7 @@ qihuo/
├── contract_specs.py # 合约乘数、保证金比例
├── fee_specs.py # 手续费计算
├── fee_sync.py # AKShare 费率同步
├── contract_profile.py # 品种/合约简介查询
├── kline_chart.py # 复盘 K 线图
├── data/fee_rates.json # 默认费率表
├── reset_admin.py # 重置管理员密码
+23
View File
@@ -0,0 +1,23 @@
(function () {
var form = document.getElementById('contract-search-form');
if (!form) return;
var wrap = form.querySelector('.symbol-wrap');
var hidden = wrap && wrap.querySelector('input[name="symbol"]');
var visible = form.querySelector('#contract-symbol-input');
// 带 symbol 参数进入时,显示合约代码
if (hidden && hidden.value && visible && !visible.value) {
visible.value = hidden.value;
}
form.addEventListener('submit', function () {
if (!hidden || !visible) return;
var v = visible.value.trim();
// 若未从下拉选择,尝试用输入框内容(支持直接输入 rb2510)
if (!hidden.value && v) {
var m = v.match(/([A-Za-z]+\d{3,4})/);
hidden.value = m ? m[1] : v;
}
});
})();
+9
View File
@@ -372,6 +372,14 @@
.btn-verify:disabled{opacity:.45;cursor:not-allowed}
.badge.result-manual{background:var(--dir-bg);color:var(--accent)}
.badge.result-external{background:var(--expired-bg);color:var(--expired-text)}
.profile-page .profile-head{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;margin:1rem 0 .75rem;font-size:.9rem}
.profile-page .profile-source{font-size:.72rem;color:var(--text-muted)}
.profile-spec{max-width:820px;border:1px solid var(--card-border);border-radius:10px;background:var(--card-inner);padding:.25rem .85rem}
.profile-row{display:grid;grid-template-columns:minmax(120px,28%) 1fr;gap:.5rem 1rem;padding:.6rem 0;border-bottom:1px solid var(--table-border);align-items:start}
.profile-row:last-child{border-bottom:none}
.profile-label{color:var(--text-muted);font-size:.84rem;line-height:1.4}
.profile-value{color:var(--text-primary);font-size:.86rem;line-height:1.5;word-break:break-word}
.profile-hint{color:var(--planned-text);font-size:.74rem;margin-top:.25rem;line-height:1.35}
.calc-readonly{background:var(--calc-bg);color:var(--accent)}
@media(max-width:1100px){
.split-grid{grid-template-columns:1fr}
@@ -406,6 +414,7 @@
<a href="{{ url_for('records') }}" class="{% if request.endpoint in ('records', 'trades') %}active{% endif %}">交易记录与复盘</a>
<a href="{{ url_for('stats') }}" class="{% if request.endpoint == 'stats' %}active{% endif %}">统计分析</a>
<a href="{{ url_for('fees') }}" class="{% if request.endpoint == 'fees' %}active{% endif %}">手续费配置</a>
<a href="{{ url_for('contract_profile_page') }}" class="{% if request.endpoint == 'contract_profile_page' %}active{% endif %}">品种简介</a>
<a href="{{ url_for('settings') }}" class="{% if request.endpoint == 'settings' %}active{% endif %}">系统设置</a>
</nav>
</header>
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}品种简介 - 国内期货监控系统{% endblock %}
{% block content %}
<div class="card profile-page">
<h2>品种简介</h2>
<div class="card-body">
<form id="contract-search-form" class="form-row" method="get" action="{{ url_for('contract_profile_page') }}">
<div class="symbol-wrap" style="flex:1;min-width:220px;max-width:360px">
<input type="text" class="symbol-input" id="contract-symbol-input"
placeholder="输入品种或合约,如 螺纹钢 / rb2510" autocomplete="off" required>
<input type="hidden" name="symbol" id="contract-symbol-hidden" value="{{ symbol or '' }}">
<div class="symbol-dropdown"></div>
<div class="symbol-selected"></div>
</div>
<button type="submit" class="btn-primary">查询</button>
</form>
<p class="hint">展示交易所合约规格:交易单位、最小变动、保证金、交割规则等(数据来源:东方财富 / 新浪)。</p>
{% if error %}
<div class="flash" style="margin-top:1rem">{{ error }}</div>
{% elif profile %}
<div class="profile-head">
<strong>{{ profile.symbol_name or profile.ths_code }}</strong>
<span class="text-muted">{{ profile.ths_code }}</span>
{% if profile.exchange %}<span class="badge active">{{ profile.exchange }}</span>{% endif %}
<span class="profile-source">来源:{{ profile.source }}</span>
</div>
<div class="profile-spec">
{% for row in profile.rows %}
<div class="profile-row">
<div class="profile-label">{{ row.label }}</div>
<div class="profile-value">
{{ row.value }}
{% if row.hint %}<div class="profile-hint">{{ row.hint }}</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
{% elif symbol %}
<p class="text-muted" style="margin-top:1rem">未查询到该合约简介,请检查合约代码是否正确。</p>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/contract.js') }}"></script>
{% endblock %}