diff --git a/README.md b/README.md index 133492e..69026f4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ | **交易记录与复盘** | 平仓记录核对、填入复盘、K 线自动生成 | | **统计分析** | 胜率、手续费与净盈亏汇总 | | **手续费配置** | 本地费率表(默认标准×2),可选 AKShare 同步 | +| **品种简介** | 合约规格查询(东方财富 / 新浪) | | **系统设置** | 实盘资金、企业微信、改密码、深色/浅色主题 | ## 快速开始 diff --git a/app.py b/app.py index 2fc7505..2a3b599 100644 --- a/app.py +++ b/app.py @@ -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(): diff --git a/contract_profile.py b/contract_profile.py new file mode 100644 index 0000000..5713478 --- /dev/null +++ b/contract_profile.py @@ -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 "未知", + } diff --git a/docs/FEATURES.md b/docs/FEATURES.md index fb455e3..548eefe 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 # 重置管理员密码 diff --git a/static/js/contract.js b/static/js/contract.js new file mode 100644 index 0000000..bc2ae49 --- /dev/null +++ b/static/js/contract.js @@ -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; + } + }); +})(); diff --git a/templates/base.html b/templates/base.html index 82f983a..b9b708a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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 @@ 交易记录与复盘 统计分析 手续费配置 + 品种简介 系统设置 diff --git a/templates/contract.html b/templates/contract.html new file mode 100644 index 0000000..76d52c6 --- /dev/null +++ b/templates/contract.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}品种简介 - 国内期货监控系统{% endblock %} +{% block content %} +
展示交易所合约规格:交易单位、最小变动、保证金、交割规则等(数据来源:东方财富 / 新浪)。
+ + {% if error %} +未查询到该合约简介,请检查合约代码是否正确。
+ {% endif %} +