Add personal license agreement and rename product section to tradable symbols.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
国内期货交易监控复盘系统 — 软件使用许可与版权声明
|
||||
|
||||
著作权人:马建军
|
||||
Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
【权利声明】
|
||||
本软件(含源代码、文档、界面、脚本及后续更新版本)之著作权及相关知识产权,
|
||||
均归马建军所有。除本许可明确允许的范围外,保留一切权利。
|
||||
|
||||
【授权范围 — 个人版】
|
||||
经著作权人书面或付费交付同意的自然人购买者,仅可在本人名下单一服务器或
|
||||
个人设备上部署并使用本软件,用于个人期货交易纪律管理、记录与复盘,且须
|
||||
遵守中华人民共和国相关法律法规及期货监管规定。
|
||||
|
||||
【严禁用途】
|
||||
未经著作权人事先书面许可,严禁将本软件用于包括但不限于以下用途:
|
||||
(1)带单、代客理财、代客下单、跟单室、信号群、付费喊单、向他人推荐具体
|
||||
期货买卖方向或具体合约;
|
||||
(2)向他人推荐、介绍、引导参与特定期货品种或交易机会(若构成投资咨询或
|
||||
其他需许可之业务,使用者依法另行承担法律责任);
|
||||
(3)融资、配资、分仓、分润、对赌、非法吸收资金等与期货相关的资金融通
|
||||
或变相配资业务;
|
||||
(4)复制、传播、转售、出租、出借源代码或编译产物,或授权第三方使用;
|
||||
(5)搭建共享交易室、多租户 SaaS、白标系统对外经营(须另行签订机构版协议);
|
||||
(6)删除、篡改或隐藏本版权及许可声明。
|
||||
|
||||
【免责声明】
|
||||
本软件为交易纪律与记录辅助工具,不构成任何投资建议、咨询或收益承诺。
|
||||
期货交易具有高风险,使用者须独立决策并自行承担全部盈亏及法律责任。
|
||||
因使用者违反法律法规、监管规定或本许可导致的后果,由使用者自行承担。
|
||||
|
||||
【更新与维护】
|
||||
源代码更新、部署服务及共享交易室等机构授权,以双方另行书面约定为准。
|
||||
未经约定,不视为自动授予新版本或扩展用途之权利。
|
||||
|
||||
【联系】
|
||||
著作权人:马建军
|
||||
手机:18364911125
|
||||
微信:dekun03
|
||||
|
||||
详细购买条款见 docs/软件购买与使用协议.md。
|
||||
本许可之解释与适用以中华人民共和国法律为准(法律强制性规定除外)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 国内期货交易监控复盘系统
|
||||
|
||||
基于 Flask 的国内期货 **CTP 下单 + 监控 + 复盘 + 统计** Web 应用。模拟盘连接 SimNow,实盘连接期货公司 CTP;支持关键位/计划提醒、交易记录同步、资金曲线、品种推荐与企业微信推送。
|
||||
基于 Flask 的国内期货 **CTP 下单 + 监控 + 复盘 + 统计** Web 应用。模拟盘连接 SimNow,实盘连接期货公司 CTP;支持关键位/计划提醒、交易记录同步、资金曲线、可开仓品种(仓位纪律)与企业微信推送。
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -9,14 +9,15 @@
|
||||
| **[功能说明](docs/FEATURES.md)** | 各模块功能、页面路径、数据库与后台任务 |
|
||||
| **[部署文档](docs/DEPLOY.md)** | 一键部署、更新、PM2、故障排查 |
|
||||
| **[SimNow 接入](docs/SIMNOW.md)** | 仿真账号注册与 CTP 前置 |
|
||||
| **[交易与策略](docs/TRADING.md)** | 下单、持仓、品种推荐、策略 API |
|
||||
| **[交易与策略](docs/TRADING.md)** | 下单、持仓、可开仓品种、策略 API |
|
||||
| **[手续费与导航](docs/FEES.md)** | CTP 费率同步、导航开关 |
|
||||
| **[软件购买与使用协议](docs/软件购买与使用协议.md)** | 个人版授权模板(含签署栏) |
|
||||
|
||||
## 功能一览
|
||||
|
||||
| 模块 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| **下单监控**(默认首页) | `/positions` | CTP 连接、期货下单、当前持仓、品种推荐 |
|
||||
| **下单监控**(默认首页) | `/positions` | CTP 连接、期货下单、当前持仓、可开仓品种 |
|
||||
| **策略交易** | `/strategy` | 趋势回调 / 顺势加仓(可导航开关) |
|
||||
| **开单计划** | `/plans` | 当日决策区间、触发推送(可开关) |
|
||||
| **关键位监控** | `/keys` | 箱体/阻力支撑突破提醒 |
|
||||
@@ -74,6 +75,12 @@ python app.py
|
||||
|
||||
https://git.bz121.com/dekun/qihuo.git
|
||||
|
||||
## License
|
||||
## 版权与授权
|
||||
|
||||
Private / 个人使用
|
||||
- 著作权人:**马建军**
|
||||
- 许可说明:[LICENSE.zh-CN.txt](LICENSE.zh-CN.txt)
|
||||
- 个人购买协议模板:[docs/软件购买与使用协议.md](docs/软件购买与使用协议.md)
|
||||
|
||||
本软件为 **专有软件**,仅供经授权的个人自用部署。严禁用于带单、向他人推荐期货品种或买卖建议、融资配资、转售源码或搭建共享交易室等用途。本软件不构成投资建议,期货交易风险由使用者自行承担。
|
||||
|
||||
联系:手机 18364911125 · 微信 dekun03
|
||||
|
||||
+280
-275
@@ -1,275 +1,280 @@
|
||||
"""期货合约简介:东方财富 / 新浪 / 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 "未知",
|
||||
}
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""期货合约简介:东方财富 / 新浪 / 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 "未知",
|
||||
}
|
||||
|
||||
+127
-122
@@ -1,122 +1,127 @@
|
||||
"""国内期货合约乘数与参考保证金比例(用于估算保证金与风险)。"""
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10, "tick_size": 1.0}
|
||||
|
||||
# 参考交易所常见规格(乘数 + 保证金比例 + 最小变动价位)
|
||||
_SPEC_BY_THS: dict[str, dict] = {
|
||||
"ag": {"mult": 15, "margin_rate": 0.14, "tick_size": 1.0},
|
||||
"au": {"mult": 1000, "margin_rate": 0.10, "tick_size": 0.02},
|
||||
"cu": {"mult": 5, "margin_rate": 0.10, "tick_size": 10.0},
|
||||
"al": {"mult": 5, "margin_rate": 0.10},
|
||||
"zn": {"mult": 5, "margin_rate": 0.10},
|
||||
"pb": {"mult": 5, "margin_rate": 0.10},
|
||||
"ni": {"mult": 1, "margin_rate": 0.12},
|
||||
"sn": {"mult": 1, "margin_rate": 0.12},
|
||||
"rb": {"mult": 10, "margin_rate": 0.09},
|
||||
"hc": {"mult": 10, "margin_rate": 0.09},
|
||||
"ss": {"mult": 5, "margin_rate": 0.11},
|
||||
"sc": {"mult": 1000, "margin_rate": 0.11},
|
||||
"fu": {"mult": 10, "margin_rate": 0.11},
|
||||
"bu": {"mult": 10, "margin_rate": 0.11},
|
||||
"ru": {"mult": 10, "margin_rate": 0.11},
|
||||
"sp": {"mult": 10, "margin_rate": 0.10},
|
||||
"i": {"mult": 100, "margin_rate": 0.11},
|
||||
"j": {"mult": 100, "margin_rate": 0.12},
|
||||
"jm": {"mult": 60, "margin_rate": 0.12},
|
||||
"m": {"mult": 10, "margin_rate": 0.08},
|
||||
"y": {"mult": 10, "margin_rate": 0.08},
|
||||
"p": {"mult": 10, "margin_rate": 0.09},
|
||||
"c": {"mult": 10, "margin_rate": 0.08},
|
||||
"cs": {"mult": 10, "margin_rate": 0.08},
|
||||
"jd": {"mult": 10, "margin_rate": 0.09},
|
||||
"lh": {"mult": 16, "margin_rate": 0.12},
|
||||
"l": {"mult": 5, "margin_rate": 0.09},
|
||||
"pp": {"mult": 5, "margin_rate": 0.09},
|
||||
"v": {"mult": 5, "margin_rate": 0.09},
|
||||
"eg": {"mult": 10, "margin_rate": 0.09},
|
||||
"eb": {"mult": 5, "margin_rate": 0.10},
|
||||
"pg": {"mult": 20, "margin_rate": 0.10},
|
||||
"RM": {"mult": 10, "margin_rate": 0.08},
|
||||
"OI": {"mult": 10, "margin_rate": 0.08},
|
||||
"SR": {"mult": 10, "margin_rate": 0.08},
|
||||
"CF": {"mult": 5, "margin_rate": 0.08},
|
||||
"MA": {"mult": 10, "margin_rate": 0.09},
|
||||
"TA": {"mult": 5, "margin_rate": 0.09},
|
||||
"FG": {"mult": 20, "margin_rate": 0.10},
|
||||
"SA": {"mult": 20, "margin_rate": 0.10},
|
||||
"UR": {"mult": 20, "margin_rate": 0.10},
|
||||
"SF": {"mult": 5, "margin_rate": 0.10},
|
||||
"SM": {"mult": 5, "margin_rate": 0.10},
|
||||
"AP": {"mult": 10, "margin_rate": 0.10},
|
||||
"CJ": {"mult": 5, "margin_rate": 0.10},
|
||||
"PK": {"mult": 5, "margin_rate": 0.10},
|
||||
"IF": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IH": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IC": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IM": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
}
|
||||
|
||||
_TICK_OVERRIDES: dict[str, float] = {
|
||||
"sc": 0.1, "TA": 2.0, "CF": 5.0, "SF": 2.0, "SM": 2.0,
|
||||
}
|
||||
|
||||
|
||||
def get_contract_spec(ths_code: str) -> dict:
|
||||
code = (ths_code or "").strip()
|
||||
m = re.match(r"^([A-Za-z]+)", code)
|
||||
if not m:
|
||||
return dict(DEFAULT_SPEC)
|
||||
letters = m.group(1)
|
||||
spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower())
|
||||
if spec:
|
||||
tick = spec.get("tick_size")
|
||||
if tick is None:
|
||||
tick = _TICK_OVERRIDES.get(letters) or _TICK_OVERRIDES.get(letters.upper()) or 1.0
|
||||
return {"mult": spec["mult"], "margin_rate": spec["margin_rate"], "tick_size": float(tick)}
|
||||
return dict(DEFAULT_SPEC)
|
||||
|
||||
|
||||
def calc_position_metrics(
|
||||
direction: str,
|
||||
entry: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
lots: float,
|
||||
mark_price: Optional[float],
|
||||
capital: float,
|
||||
ths_code: str,
|
||||
) -> dict:
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec["mult"]
|
||||
margin_rate = spec["margin_rate"]
|
||||
lots = lots or 1.0
|
||||
margin = entry * mult * lots * margin_rate
|
||||
|
||||
if direction == "long":
|
||||
risk_amt = max(0.0, (entry - stop_loss) * mult * lots)
|
||||
reward = max(0.0, (take_profit - entry) * mult * lots)
|
||||
float_pnl = (mark_price - entry) * mult * lots if mark_price is not None else None
|
||||
else:
|
||||
risk_amt = max(0.0, (stop_loss - entry) * mult * lots)
|
||||
reward = max(0.0, (entry - take_profit) * mult * lots)
|
||||
float_pnl = (entry - mark_price) * mult * lots if mark_price is not None else None
|
||||
|
||||
risk_pct = (risk_amt / capital * 100) if capital > 0 else 0.0
|
||||
pos_pct = (margin / capital * 100) if capital > 0 else 0.0
|
||||
rr = (reward / risk_amt) if risk_amt > 0 else None
|
||||
float_pct = (float_pnl / margin * 100) if margin > 0 and float_pnl is not None else None
|
||||
|
||||
return {
|
||||
"mult": mult,
|
||||
"margin_rate": margin_rate,
|
||||
"margin": round(margin, 2),
|
||||
"risk_amount": round(risk_amt, 2),
|
||||
"risk_pct": round(risk_pct, 2),
|
||||
"position_pct": round(pos_pct, 2),
|
||||
"float_pnl": round(float_pnl, 2) if float_pnl is not None else None,
|
||||
"float_pct": round(float_pct, 2) if float_pct is not None else None,
|
||||
"reward_amount": round(reward, 2) if reward else None,
|
||||
"rr_ratio": round(rr, 2) if rr is not None else None,
|
||||
}
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""国内期货合约乘数与参考保证金比例(用于估算保证金与风险)。"""
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10, "tick_size": 1.0}
|
||||
|
||||
# 参考交易所常见规格(乘数 + 保证金比例 + 最小变动价位)
|
||||
_SPEC_BY_THS: dict[str, dict] = {
|
||||
"ag": {"mult": 15, "margin_rate": 0.14, "tick_size": 1.0},
|
||||
"au": {"mult": 1000, "margin_rate": 0.10, "tick_size": 0.02},
|
||||
"cu": {"mult": 5, "margin_rate": 0.10, "tick_size": 10.0},
|
||||
"al": {"mult": 5, "margin_rate": 0.10},
|
||||
"zn": {"mult": 5, "margin_rate": 0.10},
|
||||
"pb": {"mult": 5, "margin_rate": 0.10},
|
||||
"ni": {"mult": 1, "margin_rate": 0.12},
|
||||
"sn": {"mult": 1, "margin_rate": 0.12},
|
||||
"rb": {"mult": 10, "margin_rate": 0.09},
|
||||
"hc": {"mult": 10, "margin_rate": 0.09},
|
||||
"ss": {"mult": 5, "margin_rate": 0.11},
|
||||
"sc": {"mult": 1000, "margin_rate": 0.11},
|
||||
"fu": {"mult": 10, "margin_rate": 0.11},
|
||||
"bu": {"mult": 10, "margin_rate": 0.11},
|
||||
"ru": {"mult": 10, "margin_rate": 0.11},
|
||||
"sp": {"mult": 10, "margin_rate": 0.10},
|
||||
"i": {"mult": 100, "margin_rate": 0.11},
|
||||
"j": {"mult": 100, "margin_rate": 0.12},
|
||||
"jm": {"mult": 60, "margin_rate": 0.12},
|
||||
"m": {"mult": 10, "margin_rate": 0.08},
|
||||
"y": {"mult": 10, "margin_rate": 0.08},
|
||||
"p": {"mult": 10, "margin_rate": 0.09},
|
||||
"c": {"mult": 10, "margin_rate": 0.08},
|
||||
"cs": {"mult": 10, "margin_rate": 0.08},
|
||||
"jd": {"mult": 10, "margin_rate": 0.09},
|
||||
"lh": {"mult": 16, "margin_rate": 0.12},
|
||||
"l": {"mult": 5, "margin_rate": 0.09},
|
||||
"pp": {"mult": 5, "margin_rate": 0.09},
|
||||
"v": {"mult": 5, "margin_rate": 0.09},
|
||||
"eg": {"mult": 10, "margin_rate": 0.09},
|
||||
"eb": {"mult": 5, "margin_rate": 0.10},
|
||||
"pg": {"mult": 20, "margin_rate": 0.10},
|
||||
"RM": {"mult": 10, "margin_rate": 0.08},
|
||||
"OI": {"mult": 10, "margin_rate": 0.08},
|
||||
"SR": {"mult": 10, "margin_rate": 0.08},
|
||||
"CF": {"mult": 5, "margin_rate": 0.08},
|
||||
"MA": {"mult": 10, "margin_rate": 0.09},
|
||||
"TA": {"mult": 5, "margin_rate": 0.09},
|
||||
"FG": {"mult": 20, "margin_rate": 0.10},
|
||||
"SA": {"mult": 20, "margin_rate": 0.10},
|
||||
"UR": {"mult": 20, "margin_rate": 0.10},
|
||||
"SF": {"mult": 5, "margin_rate": 0.10},
|
||||
"SM": {"mult": 5, "margin_rate": 0.10},
|
||||
"AP": {"mult": 10, "margin_rate": 0.10},
|
||||
"CJ": {"mult": 5, "margin_rate": 0.10},
|
||||
"PK": {"mult": 5, "margin_rate": 0.10},
|
||||
"IF": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IH": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IC": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IM": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
}
|
||||
|
||||
_TICK_OVERRIDES: dict[str, float] = {
|
||||
"sc": 0.1, "TA": 2.0, "CF": 5.0, "SF": 2.0, "SM": 2.0,
|
||||
}
|
||||
|
||||
|
||||
def get_contract_spec(ths_code: str) -> dict:
|
||||
code = (ths_code or "").strip()
|
||||
m = re.match(r"^([A-Za-z]+)", code)
|
||||
if not m:
|
||||
return dict(DEFAULT_SPEC)
|
||||
letters = m.group(1)
|
||||
spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower())
|
||||
if spec:
|
||||
tick = spec.get("tick_size")
|
||||
if tick is None:
|
||||
tick = _TICK_OVERRIDES.get(letters) or _TICK_OVERRIDES.get(letters.upper()) or 1.0
|
||||
return {"mult": spec["mult"], "margin_rate": spec["margin_rate"], "tick_size": float(tick)}
|
||||
return dict(DEFAULT_SPEC)
|
||||
|
||||
|
||||
def calc_position_metrics(
|
||||
direction: str,
|
||||
entry: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
lots: float,
|
||||
mark_price: Optional[float],
|
||||
capital: float,
|
||||
ths_code: str,
|
||||
) -> dict:
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec["mult"]
|
||||
margin_rate = spec["margin_rate"]
|
||||
lots = lots or 1.0
|
||||
margin = entry * mult * lots * margin_rate
|
||||
|
||||
if direction == "long":
|
||||
risk_amt = max(0.0, (entry - stop_loss) * mult * lots)
|
||||
reward = max(0.0, (take_profit - entry) * mult * lots)
|
||||
float_pnl = (mark_price - entry) * mult * lots if mark_price is not None else None
|
||||
else:
|
||||
risk_amt = max(0.0, (stop_loss - entry) * mult * lots)
|
||||
reward = max(0.0, (entry - take_profit) * mult * lots)
|
||||
float_pnl = (entry - mark_price) * mult * lots if mark_price is not None else None
|
||||
|
||||
risk_pct = (risk_amt / capital * 100) if capital > 0 else 0.0
|
||||
pos_pct = (margin / capital * 100) if capital > 0 else 0.0
|
||||
rr = (reward / risk_amt) if risk_amt > 0 else None
|
||||
float_pct = (float_pnl / margin * 100) if margin > 0 and float_pnl is not None else None
|
||||
|
||||
return {
|
||||
"mult": mult,
|
||||
"margin_rate": margin_rate,
|
||||
"margin": round(margin, 2),
|
||||
"risk_amount": round(risk_amt, 2),
|
||||
"risk_pct": round(risk_pct, 2),
|
||||
"position_pct": round(pos_pct, 2),
|
||||
"float_pnl": round(float_pnl, 2) if float_pnl is not None else None,
|
||||
"float_pct": round(float_pct, 2) if float_pct is not None else None,
|
||||
"reward_amount": round(reward, 2) if reward else None,
|
||||
"rr_ratio": round(rr, 2) if rr is not None else None,
|
||||
}
|
||||
|
||||
+144
-139
@@ -1,139 +1,144 @@
|
||||
"""从 CTP 柜台同步手续费率(SimNow / 期货公司)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
from fee_specs import upsert_fee_rate
|
||||
from vnpy_bridge import get_bridge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _product_from_instrument(instrument_id: str) -> str:
|
||||
m = re.match(r"^([A-Za-z]+)", instrument_id or "")
|
||||
return m.group(1).lower() if m else ""
|
||||
|
||||
|
||||
def ctp_commission_to_fee_fields(data: dict, ths_code: str) -> dict:
|
||||
"""CTP OnRspQryInstrumentCommissionRate → fee_rates 字段。"""
|
||||
mult = int(get_contract_spec(ths_code)["mult"])
|
||||
exchange = str(data.get("ExchangeID") or "").strip()
|
||||
return {
|
||||
"exchange": exchange,
|
||||
"mult": mult,
|
||||
"open_fixed": float(data.get("OpenRatioByVolume") or 0),
|
||||
"open_ratio": float(data.get("OpenRatioByMoney") or 0),
|
||||
"close_yesterday_fixed": float(data.get("CloseRatioByVolume") or 0),
|
||||
"close_yesterday_ratio": float(data.get("CloseRatioByMoney") or 0),
|
||||
"close_today_fixed": float(data.get("CloseTodayRatioByVolume") or 0),
|
||||
"close_today_ratio": float(data.get("CloseTodayRatioByMoney") or 0),
|
||||
"source": "ctp",
|
||||
}
|
||||
|
||||
|
||||
def _collect_main_ths_codes() -> list[str]:
|
||||
"""从主力列表收集同花顺合约代码(供 CTP 手续费查询)。"""
|
||||
from datetime import date
|
||||
|
||||
from symbols import PRODUCTS, build_ths_code, list_main_contracts_grouped
|
||||
|
||||
symbols: list[str] = []
|
||||
for group in list_main_contracts_grouped():
|
||||
for item in group.get("items") or []:
|
||||
ths = (item.get("ths_code") or item.get("ths") or item.get("code") or "").strip()
|
||||
if ths and not ths.endswith("888"):
|
||||
symbols.append(ths)
|
||||
|
||||
if symbols:
|
||||
return symbols
|
||||
|
||||
today = date.today()
|
||||
for p in PRODUCTS:
|
||||
symbols.append(build_ths_code(p, today.year, today.month))
|
||||
return symbols
|
||||
|
||||
|
||||
def sync_fees_from_ctp(mode: str, *, max_symbols: int = 80) -> tuple[int, str]:
|
||||
"""CTP 已连接时查询手续费并写入 fee_rates(source=ctp,覆盖同品种旧数据)。"""
|
||||
bridge = get_bridge()
|
||||
if not bridge.available():
|
||||
return 0, "vnpy 未安装"
|
||||
if bridge.connected_mode != mode:
|
||||
return 0, "请先连接 CTP"
|
||||
if not bridge.ping():
|
||||
return 0, "CTP 连接无效,请重连"
|
||||
|
||||
seen: set[str] = set()
|
||||
ok = 0
|
||||
errors = 0
|
||||
|
||||
batch = bridge.query_all_commissions(mode=mode)
|
||||
if batch:
|
||||
for raw in batch:
|
||||
inst = str(raw.get("InstrumentID") or "").strip()
|
||||
product = _product_from_instrument(inst)
|
||||
if not product or product in seen:
|
||||
continue
|
||||
seen.add(product)
|
||||
try:
|
||||
fields = ctp_commission_to_fee_fields(raw, inst or product)
|
||||
upsert_fee_rate(product, fields)
|
||||
ok += 1
|
||||
except Exception as exc:
|
||||
logger.debug("CTP fee batch %s: %s", inst, exc)
|
||||
errors += 1
|
||||
if ok > 0:
|
||||
msg = f"已从 CTP 批量同步 {ok} 个品种手续费"
|
||||
if errors:
|
||||
msg += f"({errors} 个跳过)"
|
||||
return ok, msg
|
||||
|
||||
symbols = _collect_main_ths_codes()[:max_symbols]
|
||||
|
||||
if not symbols:
|
||||
return 0, "无主力合约列表"
|
||||
|
||||
for ths in symbols:
|
||||
product = _product_from_instrument(ths)
|
||||
if not product or product in seen:
|
||||
continue
|
||||
seen.add(product)
|
||||
try:
|
||||
raw = bridge.query_instrument_commission(ths, mode=mode)
|
||||
if not raw:
|
||||
errors += 1
|
||||
continue
|
||||
fields = ctp_commission_to_fee_fields(raw, ths)
|
||||
upsert_fee_rate(product, fields)
|
||||
ok += 1
|
||||
time.sleep(0.35)
|
||||
except Exception as exc:
|
||||
logger.debug("CTP fee sync %s: %s", ths, exc)
|
||||
errors += 1
|
||||
|
||||
if ok == 0:
|
||||
return 0, f"CTP 未返回手续费率(失败 {errors} 次),请确认柜台支持查询"
|
||||
msg = f"已从 CTP 同步 {ok} 个品种手续费"
|
||||
if errors:
|
||||
msg += f"({errors} 个跳过)"
|
||||
return ok, msg
|
||||
|
||||
|
||||
def sync_fee_for_symbol(mode: str, ths_code: str) -> Optional[dict]:
|
||||
"""单品种按需从 CTP 拉取并缓存。"""
|
||||
bridge = get_bridge()
|
||||
if bridge.connected_mode != mode or not bridge.ping():
|
||||
return None
|
||||
raw = bridge.query_instrument_commission(ths_code, mode=mode)
|
||||
if not raw:
|
||||
return None
|
||||
product = _product_from_instrument(ths_code)
|
||||
if not product:
|
||||
return None
|
||||
fields = ctp_commission_to_fee_fields(raw, ths_code)
|
||||
upsert_fee_rate(product, fields)
|
||||
return fields
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""从 CTP 柜台同步手续费率(SimNow / 期货公司)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
from fee_specs import upsert_fee_rate
|
||||
from vnpy_bridge import get_bridge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _product_from_instrument(instrument_id: str) -> str:
|
||||
m = re.match(r"^([A-Za-z]+)", instrument_id or "")
|
||||
return m.group(1).lower() if m else ""
|
||||
|
||||
|
||||
def ctp_commission_to_fee_fields(data: dict, ths_code: str) -> dict:
|
||||
"""CTP OnRspQryInstrumentCommissionRate → fee_rates 字段。"""
|
||||
mult = int(get_contract_spec(ths_code)["mult"])
|
||||
exchange = str(data.get("ExchangeID") or "").strip()
|
||||
return {
|
||||
"exchange": exchange,
|
||||
"mult": mult,
|
||||
"open_fixed": float(data.get("OpenRatioByVolume") or 0),
|
||||
"open_ratio": float(data.get("OpenRatioByMoney") or 0),
|
||||
"close_yesterday_fixed": float(data.get("CloseRatioByVolume") or 0),
|
||||
"close_yesterday_ratio": float(data.get("CloseRatioByMoney") or 0),
|
||||
"close_today_fixed": float(data.get("CloseTodayRatioByVolume") or 0),
|
||||
"close_today_ratio": float(data.get("CloseTodayRatioByMoney") or 0),
|
||||
"source": "ctp",
|
||||
}
|
||||
|
||||
|
||||
def _collect_main_ths_codes() -> list[str]:
|
||||
"""从主力列表收集同花顺合约代码(供 CTP 手续费查询)。"""
|
||||
from datetime import date
|
||||
|
||||
from symbols import PRODUCTS, build_ths_code, list_main_contracts_grouped
|
||||
|
||||
symbols: list[str] = []
|
||||
for group in list_main_contracts_grouped():
|
||||
for item in group.get("items") or []:
|
||||
ths = (item.get("ths_code") or item.get("ths") or item.get("code") or "").strip()
|
||||
if ths and not ths.endswith("888"):
|
||||
symbols.append(ths)
|
||||
|
||||
if symbols:
|
||||
return symbols
|
||||
|
||||
today = date.today()
|
||||
for p in PRODUCTS:
|
||||
symbols.append(build_ths_code(p, today.year, today.month))
|
||||
return symbols
|
||||
|
||||
|
||||
def sync_fees_from_ctp(mode: str, *, max_symbols: int = 80) -> tuple[int, str]:
|
||||
"""CTP 已连接时查询手续费并写入 fee_rates(source=ctp,覆盖同品种旧数据)。"""
|
||||
bridge = get_bridge()
|
||||
if not bridge.available():
|
||||
return 0, "vnpy 未安装"
|
||||
if bridge.connected_mode != mode:
|
||||
return 0, "请先连接 CTP"
|
||||
if not bridge.ping():
|
||||
return 0, "CTP 连接无效,请重连"
|
||||
|
||||
seen: set[str] = set()
|
||||
ok = 0
|
||||
errors = 0
|
||||
|
||||
batch = bridge.query_all_commissions(mode=mode)
|
||||
if batch:
|
||||
for raw in batch:
|
||||
inst = str(raw.get("InstrumentID") or "").strip()
|
||||
product = _product_from_instrument(inst)
|
||||
if not product or product in seen:
|
||||
continue
|
||||
seen.add(product)
|
||||
try:
|
||||
fields = ctp_commission_to_fee_fields(raw, inst or product)
|
||||
upsert_fee_rate(product, fields)
|
||||
ok += 1
|
||||
except Exception as exc:
|
||||
logger.debug("CTP fee batch %s: %s", inst, exc)
|
||||
errors += 1
|
||||
if ok > 0:
|
||||
msg = f"已从 CTP 批量同步 {ok} 个品种手续费"
|
||||
if errors:
|
||||
msg += f"({errors} 个跳过)"
|
||||
return ok, msg
|
||||
|
||||
symbols = _collect_main_ths_codes()[:max_symbols]
|
||||
|
||||
if not symbols:
|
||||
return 0, "无主力合约列表"
|
||||
|
||||
for ths in symbols:
|
||||
product = _product_from_instrument(ths)
|
||||
if not product or product in seen:
|
||||
continue
|
||||
seen.add(product)
|
||||
try:
|
||||
raw = bridge.query_instrument_commission(ths, mode=mode)
|
||||
if not raw:
|
||||
errors += 1
|
||||
continue
|
||||
fields = ctp_commission_to_fee_fields(raw, ths)
|
||||
upsert_fee_rate(product, fields)
|
||||
ok += 1
|
||||
time.sleep(0.35)
|
||||
except Exception as exc:
|
||||
logger.debug("CTP fee sync %s: %s", ths, exc)
|
||||
errors += 1
|
||||
|
||||
if ok == 0:
|
||||
return 0, f"CTP 未返回手续费率(失败 {errors} 次),请确认柜台支持查询"
|
||||
msg = f"已从 CTP 同步 {ok} 个品种手续费"
|
||||
if errors:
|
||||
msg += f"({errors} 个跳过)"
|
||||
return ok, msg
|
||||
|
||||
|
||||
def sync_fee_for_symbol(mode: str, ths_code: str) -> Optional[dict]:
|
||||
"""单品种按需从 CTP 拉取并缓存。"""
|
||||
bridge = get_bridge()
|
||||
if bridge.connected_mode != mode or not bridge.ping():
|
||||
return None
|
||||
raw = bridge.query_instrument_commission(ths_code, mode=mode)
|
||||
if not raw:
|
||||
return None
|
||||
product = _product_from_instrument(ths_code)
|
||||
if not product:
|
||||
return None
|
||||
fields = ctp_commission_to_fee_fields(raw, ths_code)
|
||||
upsert_fee_rate(product, fields)
|
||||
return fields
|
||||
|
||||
+131
-126
@@ -1,126 +1,131 @@
|
||||
"""CTP 手续费后台同步:每日一次写入数据库,前端只读展示。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from typing import Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
FEE_SYNC_KEY = "ctp_fee_last_sync"
|
||||
CHECK_INTERVAL_SEC = 3600
|
||||
_sync_lock = threading.Lock()
|
||||
|
||||
|
||||
def fee_sync_in_progress() -> bool:
|
||||
return _sync_lock.locked()
|
||||
|
||||
|
||||
def _today_str() -> str:
|
||||
return datetime.now(TZ).date().isoformat()
|
||||
|
||||
|
||||
def get_fee_last_sync(get_setting: Callable[[str, str], str]) -> str:
|
||||
return (get_setting(FEE_SYNC_KEY, "") or "").strip()
|
||||
|
||||
|
||||
def fees_synced_today(get_setting: Callable[[str, str], str]) -> bool:
|
||||
last = get_fee_last_sync(get_setting)
|
||||
return bool(last) and last[:10] == _today_str()
|
||||
|
||||
|
||||
def mark_fees_synced(set_setting: Callable[[str, str], None]) -> None:
|
||||
set_setting(FEE_SYNC_KEY, datetime.now(TZ).isoformat(timespec="seconds"))
|
||||
|
||||
|
||||
def try_daily_ctp_fee_sync(
|
||||
mode: str,
|
||||
*,
|
||||
get_setting: Callable[[str, str], str],
|
||||
set_setting: Callable[[str, str], None],
|
||||
force: bool = False,
|
||||
) -> tuple[int, str]:
|
||||
"""CTP 已连接且今日未同步时拉取费率入库;force=True 忽略日期限制。"""
|
||||
if not force and fees_synced_today(get_setting):
|
||||
return 0, "今日已从 CTP 同步过,无需重复(可点「立即同步」强制刷新)"
|
||||
|
||||
with _sync_lock:
|
||||
if not force and fees_synced_today(get_setting):
|
||||
return 0, "今日已从 CTP 同步过"
|
||||
|
||||
t0 = time.monotonic()
|
||||
from ctp_fee_sync import sync_fees_from_ctp
|
||||
|
||||
count, msg = sync_fees_from_ctp(mode)
|
||||
elapsed = time.monotonic() - t0
|
||||
if count > 0:
|
||||
mark_fees_synced(set_setting)
|
||||
msg = f"{msg}(耗时 {elapsed:.1f} 秒)"
|
||||
logger.info("CTP 手续费每日同步: %s", msg)
|
||||
elif force:
|
||||
msg = f"{msg}(耗时 {elapsed:.1f} 秒)"
|
||||
logger.warning("CTP 手续费强制同步未写入: %s", msg)
|
||||
return count, msg
|
||||
|
||||
|
||||
def schedule_ctp_fee_sync(
|
||||
mode: str,
|
||||
*,
|
||||
get_setting: Callable[[str, str], str],
|
||||
set_setting: Callable[[str, str], None],
|
||||
force: bool = False,
|
||||
) -> tuple[bool, str]:
|
||||
"""后台线程同步,避免阻塞 Web 请求。"""
|
||||
if _sync_lock.locked():
|
||||
return False, "手续费同步进行中,请稍后再试(约 1~3 分钟)"
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
try_daily_ctp_fee_sync(
|
||||
mode,
|
||||
get_setting=get_setting,
|
||||
set_setting=set_setting,
|
||||
force=force,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("CTP 手续费后台同步失败: %s", exc)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="ctp-fee-sync-run").start()
|
||||
if force:
|
||||
return True, "已在后台开始同步,约 30 秒~2 分钟完成,请稍后刷新本页查看"
|
||||
return True, "已在后台检查同步,请稍后刷新本页"
|
||||
|
||||
|
||||
def start_ctp_fee_worker(
|
||||
*,
|
||||
get_mode_fn: Callable[[], str],
|
||||
get_setting_fn: Callable[[str, str], str],
|
||||
set_setting_fn: Callable[[str, str], None],
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""后台线程:每小时检查,CTP 已连接且当日未同步则自动同步。"""
|
||||
|
||||
def _loop() -> None:
|
||||
time.sleep(20)
|
||||
while True:
|
||||
try:
|
||||
from vnpy_bridge import ctp_status
|
||||
|
||||
mode = get_mode_fn()
|
||||
st = ctp_status(mode)
|
||||
if st.get("connected") and not fees_synced_today(get_setting_fn):
|
||||
try_daily_ctp_fee_sync(
|
||||
mode,
|
||||
get_setting=get_setting_fn,
|
||||
set_setting=set_setting_fn,
|
||||
force=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("CTP fee worker: %s", exc)
|
||||
time.sleep(max(300, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="ctp-fee-worker").start()
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""CTP 手续费后台同步:每日一次写入数据库,前端只读展示。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from typing import Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
FEE_SYNC_KEY = "ctp_fee_last_sync"
|
||||
CHECK_INTERVAL_SEC = 3600
|
||||
_sync_lock = threading.Lock()
|
||||
|
||||
|
||||
def fee_sync_in_progress() -> bool:
|
||||
return _sync_lock.locked()
|
||||
|
||||
|
||||
def _today_str() -> str:
|
||||
return datetime.now(TZ).date().isoformat()
|
||||
|
||||
|
||||
def get_fee_last_sync(get_setting: Callable[[str, str], str]) -> str:
|
||||
return (get_setting(FEE_SYNC_KEY, "") or "").strip()
|
||||
|
||||
|
||||
def fees_synced_today(get_setting: Callable[[str, str], str]) -> bool:
|
||||
last = get_fee_last_sync(get_setting)
|
||||
return bool(last) and last[:10] == _today_str()
|
||||
|
||||
|
||||
def mark_fees_synced(set_setting: Callable[[str, str], None]) -> None:
|
||||
set_setting(FEE_SYNC_KEY, datetime.now(TZ).isoformat(timespec="seconds"))
|
||||
|
||||
|
||||
def try_daily_ctp_fee_sync(
|
||||
mode: str,
|
||||
*,
|
||||
get_setting: Callable[[str, str], str],
|
||||
set_setting: Callable[[str, str], None],
|
||||
force: bool = False,
|
||||
) -> tuple[int, str]:
|
||||
"""CTP 已连接且今日未同步时拉取费率入库;force=True 忽略日期限制。"""
|
||||
if not force and fees_synced_today(get_setting):
|
||||
return 0, "今日已从 CTP 同步过,无需重复(可点「立即同步」强制刷新)"
|
||||
|
||||
with _sync_lock:
|
||||
if not force and fees_synced_today(get_setting):
|
||||
return 0, "今日已从 CTP 同步过"
|
||||
|
||||
t0 = time.monotonic()
|
||||
from ctp_fee_sync import sync_fees_from_ctp
|
||||
|
||||
count, msg = sync_fees_from_ctp(mode)
|
||||
elapsed = time.monotonic() - t0
|
||||
if count > 0:
|
||||
mark_fees_synced(set_setting)
|
||||
msg = f"{msg}(耗时 {elapsed:.1f} 秒)"
|
||||
logger.info("CTP 手续费每日同步: %s", msg)
|
||||
elif force:
|
||||
msg = f"{msg}(耗时 {elapsed:.1f} 秒)"
|
||||
logger.warning("CTP 手续费强制同步未写入: %s", msg)
|
||||
return count, msg
|
||||
|
||||
|
||||
def schedule_ctp_fee_sync(
|
||||
mode: str,
|
||||
*,
|
||||
get_setting: Callable[[str, str], str],
|
||||
set_setting: Callable[[str, str], None],
|
||||
force: bool = False,
|
||||
) -> tuple[bool, str]:
|
||||
"""后台线程同步,避免阻塞 Web 请求。"""
|
||||
if _sync_lock.locked():
|
||||
return False, "手续费同步进行中,请稍后再试(约 1~3 分钟)"
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
try_daily_ctp_fee_sync(
|
||||
mode,
|
||||
get_setting=get_setting,
|
||||
set_setting=set_setting,
|
||||
force=force,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("CTP 手续费后台同步失败: %s", exc)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="ctp-fee-sync-run").start()
|
||||
if force:
|
||||
return True, "已在后台开始同步,约 30 秒~2 分钟完成,请稍后刷新本页查看"
|
||||
return True, "已在后台检查同步,请稍后刷新本页"
|
||||
|
||||
|
||||
def start_ctp_fee_worker(
|
||||
*,
|
||||
get_mode_fn: Callable[[], str],
|
||||
get_setting_fn: Callable[[str, str], str],
|
||||
set_setting_fn: Callable[[str, str], None],
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""后台线程:每小时检查,CTP 已连接且当日未同步则自动同步。"""
|
||||
|
||||
def _loop() -> None:
|
||||
time.sleep(20)
|
||||
while True:
|
||||
try:
|
||||
from vnpy_bridge import ctp_status
|
||||
|
||||
mode = get_mode_fn()
|
||||
st = ctp_status(mode)
|
||||
if st.get("connected") and not fees_synced_today(get_setting_fn):
|
||||
try_daily_ctp_fee_sync(
|
||||
mode,
|
||||
get_setting=get_setting_fn,
|
||||
set_setting=set_setting_fn,
|
||||
force=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("CTP fee worker: %s", exc)
|
||||
time.sleep(max(300, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="ctp-fee-worker").start()
|
||||
|
||||
+89
-84
@@ -1,84 +1,89 @@
|
||||
"""CTP tick 聚合 K 线(1 分钟为基础,再合成各周期)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from kline_chart import (
|
||||
PERIOD_MINUTES,
|
||||
_aggregate_bars,
|
||||
_bar_datetime,
|
||||
_merge_bars,
|
||||
_timeshare_session,
|
||||
_weekly_from_daily,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERIOD_AGG = {
|
||||
"2m": 2,
|
||||
"3m": 3,
|
||||
"5m": 5,
|
||||
"15m": 15,
|
||||
"30m": 30,
|
||||
"1h": 60,
|
||||
"2h": 120,
|
||||
"4h": 240,
|
||||
}
|
||||
|
||||
|
||||
def _daily_from_1m(bars_1m: list) -> list:
|
||||
if not bars_1m:
|
||||
return []
|
||||
buckets: dict[str, list] = {}
|
||||
for bar in bars_1m:
|
||||
dt = _bar_datetime(bar)
|
||||
if not dt:
|
||||
continue
|
||||
key = dt.strftime("%Y-%m-%d")
|
||||
buckets.setdefault(key, []).append(bar)
|
||||
out = []
|
||||
for day in sorted(buckets.keys()):
|
||||
chunk = buckets[day]
|
||||
merged = _merge_bars(chunk)
|
||||
merged["d"] = day + " 15:00:00"
|
||||
out.append(merged)
|
||||
return out
|
||||
|
||||
|
||||
def compose_period_bars(bars_1m: list, period: str) -> list:
|
||||
p = (period or "15m").lower()
|
||||
if p == "timeshare":
|
||||
return _timeshare_session(bars_1m)
|
||||
if p in ("1d", "d"):
|
||||
return _daily_from_1m(bars_1m)
|
||||
if p == "w":
|
||||
return _weekly_from_daily(_daily_from_1m(bars_1m))
|
||||
if p == "1m":
|
||||
return list(bars_1m)
|
||||
n = PERIOD_AGG.get(p)
|
||||
if n:
|
||||
return _aggregate_bars(bars_1m, n)
|
||||
if p in PERIOD_MINUTES:
|
||||
try:
|
||||
n = int(PERIOD_MINUTES[p])
|
||||
return _aggregate_bars(bars_1m, n)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return list(bars_1m)
|
||||
|
||||
|
||||
def fetch_ctp_klines(symbol: str, period: str, mode: str) -> Optional[list]:
|
||||
"""CTP 已连接时由 tick 聚合 K 线;失败返回 None。"""
|
||||
try:
|
||||
from vnpy_bridge import ctp_status, get_bridge
|
||||
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return None
|
||||
bars_1m = get_bridge().get_kline_bars_1m(symbol, mode=mode)
|
||||
if not bars_1m:
|
||||
return None
|
||||
return compose_period_bars(bars_1m, period)
|
||||
except Exception as exc:
|
||||
logger.debug("fetch_ctp_klines %s %s: %s", symbol, period, exc)
|
||||
return None
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""CTP tick 聚合 K 线(1 分钟为基础,再合成各周期)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from kline_chart import (
|
||||
PERIOD_MINUTES,
|
||||
_aggregate_bars,
|
||||
_bar_datetime,
|
||||
_merge_bars,
|
||||
_timeshare_session,
|
||||
_weekly_from_daily,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERIOD_AGG = {
|
||||
"2m": 2,
|
||||
"3m": 3,
|
||||
"5m": 5,
|
||||
"15m": 15,
|
||||
"30m": 30,
|
||||
"1h": 60,
|
||||
"2h": 120,
|
||||
"4h": 240,
|
||||
}
|
||||
|
||||
|
||||
def _daily_from_1m(bars_1m: list) -> list:
|
||||
if not bars_1m:
|
||||
return []
|
||||
buckets: dict[str, list] = {}
|
||||
for bar in bars_1m:
|
||||
dt = _bar_datetime(bar)
|
||||
if not dt:
|
||||
continue
|
||||
key = dt.strftime("%Y-%m-%d")
|
||||
buckets.setdefault(key, []).append(bar)
|
||||
out = []
|
||||
for day in sorted(buckets.keys()):
|
||||
chunk = buckets[day]
|
||||
merged = _merge_bars(chunk)
|
||||
merged["d"] = day + " 15:00:00"
|
||||
out.append(merged)
|
||||
return out
|
||||
|
||||
|
||||
def compose_period_bars(bars_1m: list, period: str) -> list:
|
||||
p = (period or "15m").lower()
|
||||
if p == "timeshare":
|
||||
return _timeshare_session(bars_1m)
|
||||
if p in ("1d", "d"):
|
||||
return _daily_from_1m(bars_1m)
|
||||
if p == "w":
|
||||
return _weekly_from_daily(_daily_from_1m(bars_1m))
|
||||
if p == "1m":
|
||||
return list(bars_1m)
|
||||
n = PERIOD_AGG.get(p)
|
||||
if n:
|
||||
return _aggregate_bars(bars_1m, n)
|
||||
if p in PERIOD_MINUTES:
|
||||
try:
|
||||
n = int(PERIOD_MINUTES[p])
|
||||
return _aggregate_bars(bars_1m, n)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return list(bars_1m)
|
||||
|
||||
|
||||
def fetch_ctp_klines(symbol: str, period: str, mode: str) -> Optional[list]:
|
||||
"""CTP 已连接时由 tick 聚合 K 线;失败返回 None。"""
|
||||
try:
|
||||
from vnpy_bridge import ctp_status, get_bridge
|
||||
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return None
|
||||
bars_1m = get_bridge().get_kline_bars_1m(symbol, mode=mode)
|
||||
if not bars_1m:
|
||||
return None
|
||||
return compose_period_bars(bars_1m, period)
|
||||
except Exception as exc:
|
||||
logger.debug("fetch_ctp_klines %s %s: %s", symbol, period, exc)
|
||||
return None
|
||||
|
||||
+71
-66
@@ -1,66 +1,71 @@
|
||||
"""交易前自动连接 CTP(默认开盘前 30 分钟)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from market_sessions import in_premarket_connect_window
|
||||
from vnpy_bridge import ctp_start_connect, ctp_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL_SEC = 60
|
||||
DEFAULT_MINUTES_BEFORE = 30
|
||||
|
||||
|
||||
def _premarket_enabled() -> bool:
|
||||
return (os.getenv("CTP_PREMARKET_CONNECT", "true") or "true").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def _minutes_before_open() -> int:
|
||||
try:
|
||||
return max(5, int(os.getenv("CTP_PREMARKET_MINUTES", str(DEFAULT_MINUTES_BEFORE))))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MINUTES_BEFORE
|
||||
|
||||
|
||||
def start_ctp_premarket_connect_worker(
|
||||
*,
|
||||
get_mode_fn: Callable[[], str],
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""在交易开始前若干分钟自动发起 CTP 连接。"""
|
||||
|
||||
def _loop() -> None:
|
||||
time.sleep(10)
|
||||
while True:
|
||||
try:
|
||||
if _premarket_enabled() and in_premarket_connect_window(
|
||||
minutes_before=_minutes_before_open(),
|
||||
):
|
||||
mode = get_mode_fn()
|
||||
st = ctp_status(mode)
|
||||
if (
|
||||
not st.get("connected")
|
||||
and not st.get("connecting")
|
||||
and int(st.get("login_cooldown_sec") or 0) <= 0
|
||||
):
|
||||
info = ctp_start_connect(mode, force=False)
|
||||
if info.get("started"):
|
||||
logger.info(
|
||||
"盘前自动连接 CTP [%s](开盘前 %d 分钟)",
|
||||
mode,
|
||||
_minutes_before_open(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("CTP premarket connect worker: %s", exc)
|
||||
time.sleep(max(30, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="ctp-premarket-connect").start()
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""交易前自动连接 CTP(默认开盘前 30 分钟)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from market_sessions import in_premarket_connect_window
|
||||
from vnpy_bridge import ctp_start_connect, ctp_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL_SEC = 60
|
||||
DEFAULT_MINUTES_BEFORE = 30
|
||||
|
||||
|
||||
def _premarket_enabled() -> bool:
|
||||
return (os.getenv("CTP_PREMARKET_CONNECT", "true") or "true").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def _minutes_before_open() -> int:
|
||||
try:
|
||||
return max(5, int(os.getenv("CTP_PREMARKET_MINUTES", str(DEFAULT_MINUTES_BEFORE))))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MINUTES_BEFORE
|
||||
|
||||
|
||||
def start_ctp_premarket_connect_worker(
|
||||
*,
|
||||
get_mode_fn: Callable[[], str],
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""在交易开始前若干分钟自动发起 CTP 连接。"""
|
||||
|
||||
def _loop() -> None:
|
||||
time.sleep(10)
|
||||
while True:
|
||||
try:
|
||||
if _premarket_enabled() and in_premarket_connect_window(
|
||||
minutes_before=_minutes_before_open(),
|
||||
):
|
||||
mode = get_mode_fn()
|
||||
st = ctp_status(mode)
|
||||
if (
|
||||
not st.get("connected")
|
||||
and not st.get("connecting")
|
||||
and int(st.get("login_cooldown_sec") or 0) <= 0
|
||||
):
|
||||
info = ctp_start_connect(mode, force=False)
|
||||
if info.get("started"):
|
||||
logger.info(
|
||||
"盘前自动连接 CTP [%s](开盘前 %d 分钟)",
|
||||
mode,
|
||||
_minutes_before_open(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("CTP premarket connect worker: %s", exc)
|
||||
time.sleep(max(30, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="ctp-premarket-connect").start()
|
||||
|
||||
+44
-39
@@ -1,39 +1,44 @@
|
||||
"""CTP 断线自动重连(后台线程)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from vnpy_bridge import ctp_try_auto_reconnect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECONNECT_INTERVAL_SEC = 60
|
||||
|
||||
|
||||
def _auto_reconnect_enabled() -> bool:
|
||||
return (os.getenv("CTP_AUTO_RECONNECT", "true") or "true").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def start_ctp_reconnect_worker(*, get_mode_fn: Callable[[], str], interval: int = RECONNECT_INTERVAL_SEC) -> None:
|
||||
"""定时检测 CTP 连接,断线后自动重连。"""
|
||||
|
||||
def _loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
if _auto_reconnect_enabled():
|
||||
mode = get_mode_fn()
|
||||
if ctp_try_auto_reconnect(mode):
|
||||
logger.debug("CTP 连接正常 [%s]", mode)
|
||||
except Exception as exc:
|
||||
logger.warning("CTP reconnect worker: %s", exc)
|
||||
time.sleep(max(5, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="ctp-reconnect-worker").start()
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""CTP 断线自动重连(后台线程)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from vnpy_bridge import ctp_try_auto_reconnect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECONNECT_INTERVAL_SEC = 60
|
||||
|
||||
|
||||
def _auto_reconnect_enabled() -> bool:
|
||||
return (os.getenv("CTP_AUTO_RECONNECT", "true") or "true").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def start_ctp_reconnect_worker(*, get_mode_fn: Callable[[], str], interval: int = RECONNECT_INTERVAL_SEC) -> None:
|
||||
"""定时检测 CTP 连接,断线后自动重连。"""
|
||||
|
||||
def _loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
if _auto_reconnect_enabled():
|
||||
mode = get_mode_fn()
|
||||
if ctp_try_auto_reconnect(mode):
|
||||
logger.debug("CTP 连接正常 [%s]", mode)
|
||||
except Exception as exc:
|
||||
logger.warning("CTP reconnect worker: %s", exc)
|
||||
time.sleep(max(5, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="ctp-reconnect-worker").start()
|
||||
|
||||
+129
-124
@@ -1,124 +1,129 @@
|
||||
"""CTP / SimNow 配置:系统设置优先,.env 作兜底。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
# (db_key, env_key, vnpy字段名, 默认值)
|
||||
SIMNOW_FIELDS: tuple[tuple[str, str, str, str], ...] = (
|
||||
("simnow_user", "SIMNOW_USER", "用户名", ""),
|
||||
("simnow_password", "SIMNOW_PASSWORD", "密码", ""),
|
||||
("simnow_broker_id", "SIMNOW_BROKER_ID", "经纪商代码", "9999"),
|
||||
("simnow_td_address", "SIMNOW_TD_ADDRESS", "交易服务器", "tcp://180.168.146.187:10201"),
|
||||
("simnow_md_address", "SIMNOW_MD_ADDRESS", "行情服务器", "tcp://180.168.146.187:10211"),
|
||||
("simnow_app_id", "SIMNOW_APP_ID", "产品名称", "simnow_client_test"),
|
||||
("simnow_auth_code", "SIMNOW_AUTH_CODE", "授权编码", "0000000000000000"),
|
||||
("simnow_env", "SIMNOW_ENV", "柜台环境", "实盘"),
|
||||
)
|
||||
|
||||
LIVE_FIELDS: tuple[tuple[str, str, str, str], ...] = (
|
||||
("ctp_live_user", "CTP_LIVE_USER", "用户名", ""),
|
||||
("ctp_live_password", "CTP_LIVE_PASSWORD", "密码", ""),
|
||||
("ctp_live_broker_id", "CTP_LIVE_BROKER_ID", "经纪商代码", ""),
|
||||
("ctp_live_td_address", "CTP_LIVE_TD_ADDRESS", "交易服务器", ""),
|
||||
("ctp_live_md_address", "CTP_LIVE_MD_ADDRESS", "行情服务器", ""),
|
||||
("ctp_live_app_id", "CTP_LIVE_APP_ID", "产品名称", ""),
|
||||
("ctp_live_auth_code", "CTP_LIVE_AUTH_CODE", "授权编码", ""),
|
||||
("ctp_live_env", "CTP_LIVE_ENV", "柜台环境", "实盘"),
|
||||
)
|
||||
|
||||
PASSWORD_DB_KEYS = frozenset({"simnow_password", "ctp_live_password"})
|
||||
|
||||
|
||||
def _get_db_setting(key: str, default: str = "") -> str:
|
||||
from fee_specs import get_setting
|
||||
|
||||
return (get_setting(key, default) or default).strip()
|
||||
|
||||
|
||||
def resolve_ctp_value(db_key: str, env_key: str, default: str = "") -> str:
|
||||
v = _get_db_setting(db_key, "")
|
||||
if v:
|
||||
return v
|
||||
return (os.getenv(env_key) or default).strip()
|
||||
|
||||
|
||||
def _build_setting_dict(fields: tuple[tuple[str, str, str, str], ...]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for db_key, env_key, vnpy_key, default in fields:
|
||||
out[vnpy_key] = resolve_ctp_value(db_key, env_key, default)
|
||||
return out
|
||||
|
||||
|
||||
def simnow_setting_dict() -> dict[str, str]:
|
||||
return _build_setting_dict(SIMNOW_FIELDS)
|
||||
|
||||
|
||||
def live_setting_dict() -> dict[str, str]:
|
||||
return _build_setting_dict(LIVE_FIELDS)
|
||||
|
||||
|
||||
def seed_ctp_settings_from_env(set_setting: Callable[[str, str], None]) -> None:
|
||||
"""首次启动:将 .env 中已有 CTP 配置写入 settings 表。"""
|
||||
for db_key, env_key, _, _ in (*SIMNOW_FIELDS, *LIVE_FIELDS):
|
||||
if _get_db_setting(db_key, ""):
|
||||
continue
|
||||
env_val = (os.getenv(env_key) or "").strip()
|
||||
if env_val:
|
||||
set_setting(db_key, env_val)
|
||||
|
||||
|
||||
def get_ctp_settings_for_ui() -> dict[str, Any]:
|
||||
ui: dict[str, Any] = {}
|
||||
for db_key, env_key, _, default in SIMNOW_FIELDS:
|
||||
ui[db_key] = resolve_ctp_value(db_key, env_key, default)
|
||||
if db_key in PASSWORD_DB_KEYS:
|
||||
ui[f"{db_key}_set"] = bool(ui[db_key])
|
||||
ui[db_key] = ""
|
||||
for db_key, env_key, _, default in LIVE_FIELDS:
|
||||
ui[db_key] = resolve_ctp_value(db_key, env_key, default)
|
||||
if db_key in PASSWORD_DB_KEYS:
|
||||
ui[f"{db_key}_set"] = bool(ui[db_key])
|
||||
ui[db_key] = ""
|
||||
return ui
|
||||
|
||||
|
||||
def save_ctp_settings_from_form(
|
||||
form: Any,
|
||||
set_setting: Callable[[str, str], None],
|
||||
) -> dict[str, Any]:
|
||||
"""保存 CTP 配置;密码留空表示不修改。返回摘要供页面提示。"""
|
||||
passwords_updated: list[str] = []
|
||||
passwords_submitted_empty: list[str] = []
|
||||
|
||||
for db_key, _, _, default in SIMNOW_FIELDS:
|
||||
if db_key in PASSWORD_DB_KEYS:
|
||||
raw = form.get(db_key)
|
||||
val = (raw or "").strip()
|
||||
if val:
|
||||
set_setting(db_key, val)
|
||||
passwords_updated.append(db_key)
|
||||
else:
|
||||
passwords_submitted_empty.append(db_key)
|
||||
continue
|
||||
val = (form.get(db_key) or "").strip()
|
||||
set_setting(db_key, val or default)
|
||||
|
||||
for db_key, _, _, default in LIVE_FIELDS:
|
||||
if db_key in PASSWORD_DB_KEYS:
|
||||
raw = form.get(db_key)
|
||||
val = (raw or "").strip()
|
||||
if val:
|
||||
set_setting(db_key, val)
|
||||
passwords_updated.append(db_key)
|
||||
else:
|
||||
passwords_submitted_empty.append(db_key)
|
||||
continue
|
||||
val = (form.get(db_key) or "").strip()
|
||||
if default or val:
|
||||
set_setting(db_key, val or default)
|
||||
|
||||
return {
|
||||
"passwords_updated": passwords_updated,
|
||||
"passwords_submitted_empty": passwords_submitted_empty,
|
||||
}
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""CTP / SimNow 配置:系统设置优先,.env 作兜底。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
# (db_key, env_key, vnpy字段名, 默认值)
|
||||
SIMNOW_FIELDS: tuple[tuple[str, str, str, str], ...] = (
|
||||
("simnow_user", "SIMNOW_USER", "用户名", ""),
|
||||
("simnow_password", "SIMNOW_PASSWORD", "密码", ""),
|
||||
("simnow_broker_id", "SIMNOW_BROKER_ID", "经纪商代码", "9999"),
|
||||
("simnow_td_address", "SIMNOW_TD_ADDRESS", "交易服务器", "tcp://180.168.146.187:10201"),
|
||||
("simnow_md_address", "SIMNOW_MD_ADDRESS", "行情服务器", "tcp://180.168.146.187:10211"),
|
||||
("simnow_app_id", "SIMNOW_APP_ID", "产品名称", "simnow_client_test"),
|
||||
("simnow_auth_code", "SIMNOW_AUTH_CODE", "授权编码", "0000000000000000"),
|
||||
("simnow_env", "SIMNOW_ENV", "柜台环境", "实盘"),
|
||||
)
|
||||
|
||||
LIVE_FIELDS: tuple[tuple[str, str, str, str], ...] = (
|
||||
("ctp_live_user", "CTP_LIVE_USER", "用户名", ""),
|
||||
("ctp_live_password", "CTP_LIVE_PASSWORD", "密码", ""),
|
||||
("ctp_live_broker_id", "CTP_LIVE_BROKER_ID", "经纪商代码", ""),
|
||||
("ctp_live_td_address", "CTP_LIVE_TD_ADDRESS", "交易服务器", ""),
|
||||
("ctp_live_md_address", "CTP_LIVE_MD_ADDRESS", "行情服务器", ""),
|
||||
("ctp_live_app_id", "CTP_LIVE_APP_ID", "产品名称", ""),
|
||||
("ctp_live_auth_code", "CTP_LIVE_AUTH_CODE", "授权编码", ""),
|
||||
("ctp_live_env", "CTP_LIVE_ENV", "柜台环境", "实盘"),
|
||||
)
|
||||
|
||||
PASSWORD_DB_KEYS = frozenset({"simnow_password", "ctp_live_password"})
|
||||
|
||||
|
||||
def _get_db_setting(key: str, default: str = "") -> str:
|
||||
from fee_specs import get_setting
|
||||
|
||||
return (get_setting(key, default) or default).strip()
|
||||
|
||||
|
||||
def resolve_ctp_value(db_key: str, env_key: str, default: str = "") -> str:
|
||||
v = _get_db_setting(db_key, "")
|
||||
if v:
|
||||
return v
|
||||
return (os.getenv(env_key) or default).strip()
|
||||
|
||||
|
||||
def _build_setting_dict(fields: tuple[tuple[str, str, str, str], ...]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for db_key, env_key, vnpy_key, default in fields:
|
||||
out[vnpy_key] = resolve_ctp_value(db_key, env_key, default)
|
||||
return out
|
||||
|
||||
|
||||
def simnow_setting_dict() -> dict[str, str]:
|
||||
return _build_setting_dict(SIMNOW_FIELDS)
|
||||
|
||||
|
||||
def live_setting_dict() -> dict[str, str]:
|
||||
return _build_setting_dict(LIVE_FIELDS)
|
||||
|
||||
|
||||
def seed_ctp_settings_from_env(set_setting: Callable[[str, str], None]) -> None:
|
||||
"""首次启动:将 .env 中已有 CTP 配置写入 settings 表。"""
|
||||
for db_key, env_key, _, _ in (*SIMNOW_FIELDS, *LIVE_FIELDS):
|
||||
if _get_db_setting(db_key, ""):
|
||||
continue
|
||||
env_val = (os.getenv(env_key) or "").strip()
|
||||
if env_val:
|
||||
set_setting(db_key, env_val)
|
||||
|
||||
|
||||
def get_ctp_settings_for_ui() -> dict[str, Any]:
|
||||
ui: dict[str, Any] = {}
|
||||
for db_key, env_key, _, default in SIMNOW_FIELDS:
|
||||
ui[db_key] = resolve_ctp_value(db_key, env_key, default)
|
||||
if db_key in PASSWORD_DB_KEYS:
|
||||
ui[f"{db_key}_set"] = bool(ui[db_key])
|
||||
ui[db_key] = ""
|
||||
for db_key, env_key, _, default in LIVE_FIELDS:
|
||||
ui[db_key] = resolve_ctp_value(db_key, env_key, default)
|
||||
if db_key in PASSWORD_DB_KEYS:
|
||||
ui[f"{db_key}_set"] = bool(ui[db_key])
|
||||
ui[db_key] = ""
|
||||
return ui
|
||||
|
||||
|
||||
def save_ctp_settings_from_form(
|
||||
form: Any,
|
||||
set_setting: Callable[[str, str], None],
|
||||
) -> dict[str, Any]:
|
||||
"""保存 CTP 配置;密码留空表示不修改。返回摘要供页面提示。"""
|
||||
passwords_updated: list[str] = []
|
||||
passwords_submitted_empty: list[str] = []
|
||||
|
||||
for db_key, _, _, default in SIMNOW_FIELDS:
|
||||
if db_key in PASSWORD_DB_KEYS:
|
||||
raw = form.get(db_key)
|
||||
val = (raw or "").strip()
|
||||
if val:
|
||||
set_setting(db_key, val)
|
||||
passwords_updated.append(db_key)
|
||||
else:
|
||||
passwords_submitted_empty.append(db_key)
|
||||
continue
|
||||
val = (form.get(db_key) or "").strip()
|
||||
set_setting(db_key, val or default)
|
||||
|
||||
for db_key, _, _, default in LIVE_FIELDS:
|
||||
if db_key in PASSWORD_DB_KEYS:
|
||||
raw = form.get(db_key)
|
||||
val = (raw or "").strip()
|
||||
if val:
|
||||
set_setting(db_key, val)
|
||||
passwords_updated.append(db_key)
|
||||
else:
|
||||
passwords_submitted_empty.append(db_key)
|
||||
continue
|
||||
val = (form.get(db_key) or "").strip()
|
||||
if default or val:
|
||||
set_setting(db_key, val or default)
|
||||
|
||||
return {
|
||||
"passwords_updated": passwords_updated,
|
||||
"passwords_submitted_empty": passwords_submitted_empty,
|
||||
}
|
||||
|
||||
+62
-57
@@ -1,57 +1,62 @@
|
||||
"""同花顺合约代码 → vnpy Symbol + Exchange。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from symbols import ths_to_codes
|
||||
|
||||
try:
|
||||
from vnpy.trader.constant import Exchange
|
||||
except ImportError:
|
||||
Exchange = None # type: ignore
|
||||
|
||||
_EX_MAP = {
|
||||
"SHFE": "SHFE",
|
||||
"DCE": "DCE",
|
||||
"CZCE": "CZCE",
|
||||
"CFFEX": "CFFEX",
|
||||
"INE": "INE",
|
||||
}
|
||||
|
||||
|
||||
def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]:
|
||||
"""
|
||||
返回 (symbol, exchange_enum_name)。
|
||||
例:rb2610 → rb2610, SHFE;SR609 → SR609, CZCE
|
||||
"""
|
||||
code = (ths_code or "").strip()
|
||||
codes = ths_to_codes(code)
|
||||
ex = (codes.get("ex") if codes else None) or "SHFE"
|
||||
ex = _EX_MAP.get(ex, "SHFE")
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
|
||||
if not m:
|
||||
return code, ex
|
||||
letters, digits = m.group(1), m.group(2)
|
||||
if ex == "CZCE":
|
||||
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
|
||||
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
|
||||
else:
|
||||
sym = letters.lower() + digits
|
||||
return sym, ex
|
||||
|
||||
|
||||
def to_vnpy_exchange(ex_name: str):
|
||||
if Exchange is None:
|
||||
raise ImportError("vnpy 未安装")
|
||||
mapping = {
|
||||
"SHFE": Exchange.SHFE,
|
||||
"DCE": Exchange.DCE,
|
||||
"CZCE": Exchange.CZCE,
|
||||
"CFFEX": Exchange.CFFEX,
|
||||
"INE": Exchange.INE,
|
||||
}
|
||||
ex = mapping.get((ex_name or "").upper())
|
||||
if ex is None:
|
||||
raise ValueError(f"未知交易所: {ex_name}")
|
||||
return ex
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""同花顺合约代码 → vnpy Symbol + Exchange。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from symbols import ths_to_codes
|
||||
|
||||
try:
|
||||
from vnpy.trader.constant import Exchange
|
||||
except ImportError:
|
||||
Exchange = None # type: ignore
|
||||
|
||||
_EX_MAP = {
|
||||
"SHFE": "SHFE",
|
||||
"DCE": "DCE",
|
||||
"CZCE": "CZCE",
|
||||
"CFFEX": "CFFEX",
|
||||
"INE": "INE",
|
||||
}
|
||||
|
||||
|
||||
def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]:
|
||||
"""
|
||||
返回 (symbol, exchange_enum_name)。
|
||||
例:rb2610 → rb2610, SHFE;SR609 → SR609, CZCE
|
||||
"""
|
||||
code = (ths_code or "").strip()
|
||||
codes = ths_to_codes(code)
|
||||
ex = (codes.get("ex") if codes else None) or "SHFE"
|
||||
ex = _EX_MAP.get(ex, "SHFE")
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
|
||||
if not m:
|
||||
return code, ex
|
||||
letters, digits = m.group(1), m.group(2)
|
||||
if ex == "CZCE":
|
||||
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
|
||||
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
|
||||
else:
|
||||
sym = letters.lower() + digits
|
||||
return sym, ex
|
||||
|
||||
|
||||
def to_vnpy_exchange(ex_name: str):
|
||||
if Exchange is None:
|
||||
raise ImportError("vnpy 未安装")
|
||||
mapping = {
|
||||
"SHFE": Exchange.SHFE,
|
||||
"DCE": Exchange.DCE,
|
||||
"CZCE": Exchange.CZCE,
|
||||
"CFFEX": Exchange.CFFEX,
|
||||
"INE": Exchange.INE,
|
||||
}
|
||||
ex = mapping.get((ex_name or "").upper())
|
||||
if ex is None:
|
||||
raise ValueError(f"未知交易所: {ex_name}")
|
||||
return ex
|
||||
|
||||
+267
-262
@@ -1,262 +1,267 @@
|
||||
"""从 CTP 柜台同步成交,写入 trade_logs(以交易所成交为准)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from contract_specs import calc_position_metrics
|
||||
from ctp_symbol import ths_to_vnpy_symbol
|
||||
from fee_specs import calc_round_trip_fee
|
||||
from symbols import ths_to_codes
|
||||
from trade_log_lib import calc_equity_after, ensure_trade_log_columns
|
||||
from vnpy_bridge import ctp_list_trades, ctp_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _match_symbol(ctp_sym: str, ths: str) -> bool:
|
||||
a = (ctp_sym or "").lower()
|
||||
b = (ths or "").lower()
|
||||
if a == b:
|
||||
return True
|
||||
if a and b and a.split(".")[0] == b.split(".")[0]:
|
||||
return True
|
||||
try:
|
||||
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
|
||||
if a == vnpy_sym.lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _to_ths_code(symbol: str) -> str:
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
return ""
|
||||
codes = ths_to_codes(sym)
|
||||
if codes:
|
||||
return codes.get("ths_code") or sym
|
||||
return sym.lower()
|
||||
|
||||
|
||||
def build_round_trips(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""按 FIFO 将开/平仓成交配对为完整回合。"""
|
||||
stacks: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||
trips: list[dict[str, Any]] = []
|
||||
|
||||
ordered = sorted(
|
||||
trades,
|
||||
key=lambda t: ((t.get("datetime") or ""), str(t.get("trade_id") or "")),
|
||||
)
|
||||
for t in ordered:
|
||||
sym = (t.get("symbol") or "").lower()
|
||||
pos_dir = (t.get("position_direction") or "long").strip().lower()
|
||||
offset = (t.get("offset") or "open").strip().lower()
|
||||
lots = int(t.get("lots") or 0)
|
||||
if not sym or lots <= 0:
|
||||
continue
|
||||
key = (sym, pos_dir)
|
||||
if offset == "open":
|
||||
stacks[key].append({
|
||||
**t,
|
||||
"remaining": lots,
|
||||
})
|
||||
continue
|
||||
|
||||
close_lots_left = lots
|
||||
close_price = float(t.get("price") or 0)
|
||||
close_time = t.get("datetime") or ""
|
||||
close_trade_id = str(t.get("trade_id") or "")
|
||||
while close_lots_left > 0 and stacks[key]:
|
||||
open_t = stacks[key][0]
|
||||
matched = min(close_lots_left, int(open_t.get("remaining") or 0))
|
||||
if matched <= 0:
|
||||
stacks[key].pop(0)
|
||||
continue
|
||||
open_t["remaining"] = int(open_t.get("remaining") or 0) - matched
|
||||
if open_t["remaining"] <= 0:
|
||||
stacks[key].pop(0)
|
||||
close_lots_left -= matched
|
||||
open_trade_id = str(open_t.get("trade_id") or "")
|
||||
ctp_key = f"{open_trade_id}|{close_trade_id}|{sym}|{pos_dir}|{matched}"
|
||||
trips.append({
|
||||
"ctp_trade_key": ctp_key,
|
||||
"symbol": sym,
|
||||
"ths_code": _to_ths_code(sym),
|
||||
"direction": pos_dir,
|
||||
"lots": matched,
|
||||
"entry_price": float(open_t.get("price") or 0),
|
||||
"close_price": close_price,
|
||||
"open_time": open_t.get("datetime") or "",
|
||||
"close_time": close_time,
|
||||
"open_trade_id": open_trade_id,
|
||||
"close_trade_id": close_trade_id,
|
||||
})
|
||||
return trips
|
||||
|
||||
|
||||
def _find_monitor_meta(
|
||||
conn,
|
||||
*,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
open_time: str,
|
||||
match_symbol_fn: Callable[[str, str], bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
match = match_symbol_fn or _match_symbol
|
||||
direction = (direction or "long").strip().lower()
|
||||
best: Optional[dict[str, Any]] = None
|
||||
for r in conn.execute(
|
||||
"SELECT * FROM trade_order_monitors ORDER BY id DESC LIMIT 200"
|
||||
).fetchall():
|
||||
row = dict(r)
|
||||
if (row.get("direction") or "long").strip().lower() != direction:
|
||||
continue
|
||||
if not match(symbol, row.get("symbol") or ""):
|
||||
continue
|
||||
if best is None:
|
||||
best = row
|
||||
continue
|
||||
ot = (row.get("open_time") or "").strip()
|
||||
if open_time and ot and abs(len(ot) - len(open_time)) <= 2 and ot[:16] == open_time[:16]:
|
||||
return row
|
||||
return best or {}
|
||||
|
||||
|
||||
def _holding_minutes(open_time: str, close_time: str) -> int:
|
||||
try:
|
||||
from app import holding_to_minutes
|
||||
return int(holding_to_minutes(open_time, close_time) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def sync_trade_logs_from_ctp(
|
||||
conn,
|
||||
mode: str,
|
||||
*,
|
||||
capital: float = 0.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> dict[str, Any]:
|
||||
"""查询 CTP 成交并 upsert 到 trade_logs。返回同步摘要。"""
|
||||
stats = {"synced": 0, "updated": 0, "skipped": 0, "connected": False}
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return stats
|
||||
stats["connected"] = True
|
||||
ensure_trade_log_columns(conn)
|
||||
try:
|
||||
conn.execute("ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
trades = ctp_list_trades(mode, refresh=True)
|
||||
trips = build_round_trips(trades)
|
||||
for trip in trips:
|
||||
key = trip.get("ctp_trade_key") or ""
|
||||
if not key:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM trade_logs WHERE ctp_trade_key=?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
|
||||
ths = trip.get("ths_code") or trip.get("symbol") or ""
|
||||
codes = ths_to_codes(ths) or {}
|
||||
direction = trip.get("direction") or "long"
|
||||
entry = float(trip.get("entry_price") or 0)
|
||||
close_px = float(trip.get("close_price") or 0)
|
||||
lots = float(trip.get("lots") or 0)
|
||||
open_time = trip.get("open_time") or ""
|
||||
close_time = trip.get("close_time") or datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
|
||||
|
||||
mon = _find_monitor_meta(
|
||||
conn,
|
||||
symbol=trip.get("symbol") or ths,
|
||||
direction=direction,
|
||||
open_time=open_time,
|
||||
)
|
||||
sl = mon.get("stop_loss")
|
||||
tp = mon.get("take_profit")
|
||||
try:
|
||||
sl_f = float(sl) if sl is not None else entry
|
||||
tp_f = float(tp) if tp is not None else entry
|
||||
except (TypeError, ValueError):
|
||||
sl_f, tp_f = entry, entry
|
||||
|
||||
metrics = calc_position_metrics(
|
||||
direction, entry, sl_f, tp_f, lots, close_px, capital, ths,
|
||||
)
|
||||
pnl = float(metrics.get("float_pnl") or 0)
|
||||
fee = calc_round_trip_fee(
|
||||
ths, entry, close_px, lots, open_time, close_time, trading_mode=trading_mode,
|
||||
)
|
||||
pnl_net = round(pnl - fee, 2)
|
||||
margin_pct = metrics.get("position_pct")
|
||||
equity_after = calc_equity_after(capital, pnl_net)
|
||||
minutes = _holding_minutes(open_time, close_time)
|
||||
result = "CTP同步"
|
||||
monitor_type = mon.get("monitor_type") or "CTP同步"
|
||||
|
||||
row_vals = (
|
||||
ths,
|
||||
codes.get("name") or mon.get("symbol_name") or ths,
|
||||
codes.get("market_code") or mon.get("market_code") or "",
|
||||
codes.get("sina_code") or mon.get("sina_code") or "",
|
||||
monitor_type,
|
||||
direction,
|
||||
entry,
|
||||
sl if sl is not None else None,
|
||||
tp if tp is not None else None,
|
||||
close_px,
|
||||
lots,
|
||||
metrics.get("margin"),
|
||||
margin_pct,
|
||||
minutes,
|
||||
open_time,
|
||||
close_time,
|
||||
pnl,
|
||||
fee,
|
||||
pnl_net,
|
||||
equity_after,
|
||||
result,
|
||||
)
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""UPDATE trade_logs SET
|
||||
symbol=?, symbol_name=?, market_code=?, sina_code=?, monitor_type=?,
|
||||
direction=?, entry_price=?, stop_loss=?, take_profit=?, close_price=?,
|
||||
lots=?, margin=?, margin_pct=?, holding_minutes=?, open_time=?, close_time=?,
|
||||
pnl=?, fee=?, pnl_net=?, equity_after=?, result=?, source='ctp', verified=1
|
||||
WHERE ctp_trade_key=?""",
|
||||
row_vals + (key,),
|
||||
)
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO trade_logs
|
||||
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
|
||||
entry_price, stop_loss, take_profit, close_price, lots, margin,
|
||||
margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net,
|
||||
equity_after, result, source, ctp_trade_key, verified)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
row_vals + ("ctp", key, 1),
|
||||
)
|
||||
stats["synced"] += 1
|
||||
|
||||
if stats["synced"] or stats["updated"]:
|
||||
try:
|
||||
from stats_engine import refresh_stats_cache
|
||||
refresh_stats_cache(conn, capital)
|
||||
except Exception as exc:
|
||||
logger.debug("stats refresh after ctp trade sync: %s", exc)
|
||||
return stats
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""从 CTP 柜台同步成交,写入 trade_logs(以交易所成交为准)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from contract_specs import calc_position_metrics
|
||||
from ctp_symbol import ths_to_vnpy_symbol
|
||||
from fee_specs import calc_round_trip_fee
|
||||
from symbols import ths_to_codes
|
||||
from trade_log_lib import calc_equity_after, ensure_trade_log_columns
|
||||
from vnpy_bridge import ctp_list_trades, ctp_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _match_symbol(ctp_sym: str, ths: str) -> bool:
|
||||
a = (ctp_sym or "").lower()
|
||||
b = (ths or "").lower()
|
||||
if a == b:
|
||||
return True
|
||||
if a and b and a.split(".")[0] == b.split(".")[0]:
|
||||
return True
|
||||
try:
|
||||
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
|
||||
if a == vnpy_sym.lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _to_ths_code(symbol: str) -> str:
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
return ""
|
||||
codes = ths_to_codes(sym)
|
||||
if codes:
|
||||
return codes.get("ths_code") or sym
|
||||
return sym.lower()
|
||||
|
||||
|
||||
def build_round_trips(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""按 FIFO 将开/平仓成交配对为完整回合。"""
|
||||
stacks: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||
trips: list[dict[str, Any]] = []
|
||||
|
||||
ordered = sorted(
|
||||
trades,
|
||||
key=lambda t: ((t.get("datetime") or ""), str(t.get("trade_id") or "")),
|
||||
)
|
||||
for t in ordered:
|
||||
sym = (t.get("symbol") or "").lower()
|
||||
pos_dir = (t.get("position_direction") or "long").strip().lower()
|
||||
offset = (t.get("offset") or "open").strip().lower()
|
||||
lots = int(t.get("lots") or 0)
|
||||
if not sym or lots <= 0:
|
||||
continue
|
||||
key = (sym, pos_dir)
|
||||
if offset == "open":
|
||||
stacks[key].append({
|
||||
**t,
|
||||
"remaining": lots,
|
||||
})
|
||||
continue
|
||||
|
||||
close_lots_left = lots
|
||||
close_price = float(t.get("price") or 0)
|
||||
close_time = t.get("datetime") or ""
|
||||
close_trade_id = str(t.get("trade_id") or "")
|
||||
while close_lots_left > 0 and stacks[key]:
|
||||
open_t = stacks[key][0]
|
||||
matched = min(close_lots_left, int(open_t.get("remaining") or 0))
|
||||
if matched <= 0:
|
||||
stacks[key].pop(0)
|
||||
continue
|
||||
open_t["remaining"] = int(open_t.get("remaining") or 0) - matched
|
||||
if open_t["remaining"] <= 0:
|
||||
stacks[key].pop(0)
|
||||
close_lots_left -= matched
|
||||
open_trade_id = str(open_t.get("trade_id") or "")
|
||||
ctp_key = f"{open_trade_id}|{close_trade_id}|{sym}|{pos_dir}|{matched}"
|
||||
trips.append({
|
||||
"ctp_trade_key": ctp_key,
|
||||
"symbol": sym,
|
||||
"ths_code": _to_ths_code(sym),
|
||||
"direction": pos_dir,
|
||||
"lots": matched,
|
||||
"entry_price": float(open_t.get("price") or 0),
|
||||
"close_price": close_price,
|
||||
"open_time": open_t.get("datetime") or "",
|
||||
"close_time": close_time,
|
||||
"open_trade_id": open_trade_id,
|
||||
"close_trade_id": close_trade_id,
|
||||
})
|
||||
return trips
|
||||
|
||||
|
||||
def _find_monitor_meta(
|
||||
conn,
|
||||
*,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
open_time: str,
|
||||
match_symbol_fn: Callable[[str, str], bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
match = match_symbol_fn or _match_symbol
|
||||
direction = (direction or "long").strip().lower()
|
||||
best: Optional[dict[str, Any]] = None
|
||||
for r in conn.execute(
|
||||
"SELECT * FROM trade_order_monitors ORDER BY id DESC LIMIT 200"
|
||||
).fetchall():
|
||||
row = dict(r)
|
||||
if (row.get("direction") or "long").strip().lower() != direction:
|
||||
continue
|
||||
if not match(symbol, row.get("symbol") or ""):
|
||||
continue
|
||||
if best is None:
|
||||
best = row
|
||||
continue
|
||||
ot = (row.get("open_time") or "").strip()
|
||||
if open_time and ot and abs(len(ot) - len(open_time)) <= 2 and ot[:16] == open_time[:16]:
|
||||
return row
|
||||
return best or {}
|
||||
|
||||
|
||||
def _holding_minutes(open_time: str, close_time: str) -> int:
|
||||
try:
|
||||
from app import holding_to_minutes
|
||||
return int(holding_to_minutes(open_time, close_time) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def sync_trade_logs_from_ctp(
|
||||
conn,
|
||||
mode: str,
|
||||
*,
|
||||
capital: float = 0.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> dict[str, Any]:
|
||||
"""查询 CTP 成交并 upsert 到 trade_logs。返回同步摘要。"""
|
||||
stats = {"synced": 0, "updated": 0, "skipped": 0, "connected": False}
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return stats
|
||||
stats["connected"] = True
|
||||
ensure_trade_log_columns(conn)
|
||||
try:
|
||||
conn.execute("ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
trades = ctp_list_trades(mode, refresh=True)
|
||||
trips = build_round_trips(trades)
|
||||
for trip in trips:
|
||||
key = trip.get("ctp_trade_key") or ""
|
||||
if not key:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM trade_logs WHERE ctp_trade_key=?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
|
||||
ths = trip.get("ths_code") or trip.get("symbol") or ""
|
||||
codes = ths_to_codes(ths) or {}
|
||||
direction = trip.get("direction") or "long"
|
||||
entry = float(trip.get("entry_price") or 0)
|
||||
close_px = float(trip.get("close_price") or 0)
|
||||
lots = float(trip.get("lots") or 0)
|
||||
open_time = trip.get("open_time") or ""
|
||||
close_time = trip.get("close_time") or datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
|
||||
|
||||
mon = _find_monitor_meta(
|
||||
conn,
|
||||
symbol=trip.get("symbol") or ths,
|
||||
direction=direction,
|
||||
open_time=open_time,
|
||||
)
|
||||
sl = mon.get("stop_loss")
|
||||
tp = mon.get("take_profit")
|
||||
try:
|
||||
sl_f = float(sl) if sl is not None else entry
|
||||
tp_f = float(tp) if tp is not None else entry
|
||||
except (TypeError, ValueError):
|
||||
sl_f, tp_f = entry, entry
|
||||
|
||||
metrics = calc_position_metrics(
|
||||
direction, entry, sl_f, tp_f, lots, close_px, capital, ths,
|
||||
)
|
||||
pnl = float(metrics.get("float_pnl") or 0)
|
||||
fee = calc_round_trip_fee(
|
||||
ths, entry, close_px, lots, open_time, close_time, trading_mode=trading_mode,
|
||||
)
|
||||
pnl_net = round(pnl - fee, 2)
|
||||
margin_pct = metrics.get("position_pct")
|
||||
equity_after = calc_equity_after(capital, pnl_net)
|
||||
minutes = _holding_minutes(open_time, close_time)
|
||||
result = "CTP同步"
|
||||
monitor_type = mon.get("monitor_type") or "CTP同步"
|
||||
|
||||
row_vals = (
|
||||
ths,
|
||||
codes.get("name") or mon.get("symbol_name") or ths,
|
||||
codes.get("market_code") or mon.get("market_code") or "",
|
||||
codes.get("sina_code") or mon.get("sina_code") or "",
|
||||
monitor_type,
|
||||
direction,
|
||||
entry,
|
||||
sl if sl is not None else None,
|
||||
tp if tp is not None else None,
|
||||
close_px,
|
||||
lots,
|
||||
metrics.get("margin"),
|
||||
margin_pct,
|
||||
minutes,
|
||||
open_time,
|
||||
close_time,
|
||||
pnl,
|
||||
fee,
|
||||
pnl_net,
|
||||
equity_after,
|
||||
result,
|
||||
)
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""UPDATE trade_logs SET
|
||||
symbol=?, symbol_name=?, market_code=?, sina_code=?, monitor_type=?,
|
||||
direction=?, entry_price=?, stop_loss=?, take_profit=?, close_price=?,
|
||||
lots=?, margin=?, margin_pct=?, holding_minutes=?, open_time=?, close_time=?,
|
||||
pnl=?, fee=?, pnl_net=?, equity_after=?, result=?, source='ctp', verified=1
|
||||
WHERE ctp_trade_key=?""",
|
||||
row_vals + (key,),
|
||||
)
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO trade_logs
|
||||
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
|
||||
entry_price, stop_loss, take_profit, close_price, lots, margin,
|
||||
margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net,
|
||||
equity_after, result, source, ctp_trade_key, verified)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
row_vals + ("ctp", key, 1),
|
||||
)
|
||||
stats["synced"] += 1
|
||||
|
||||
if stats["synced"] or stats["updated"]:
|
||||
try:
|
||||
from stats_engine import refresh_stats_cache
|
||||
refresh_stats_cache(conn, capital)
|
||||
except Exception as exc:
|
||||
logger.debug("stats refresh after ctp trade sync: %s", exc)
|
||||
return stats
|
||||
|
||||
+49
-44
@@ -1,44 +1,49 @@
|
||||
"""SQLite 连接统一配置(WAL + busy_timeout,降低并发锁冲突)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db")
|
||||
|
||||
|
||||
def connect_db(path: str | None = None) -> sqlite3.Connection:
|
||||
db_path = path or DB_PATH
|
||||
conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
return conn
|
||||
|
||||
|
||||
def execute_retry(
|
||||
conn: sqlite3.Connection,
|
||||
sql: str,
|
||||
params: tuple = (),
|
||||
*,
|
||||
retries: int = 6,
|
||||
base_delay: float = 0.05,
|
||||
) -> sqlite3.Cursor:
|
||||
"""遇 database is locked 时短暂退避重试。"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return conn.execute(sql, params)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
last_exc = exc
|
||||
if attempt < retries - 1:
|
||||
time.sleep(base_delay * (attempt + 1))
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""SQLite 连接统一配置(WAL + busy_timeout,降低并发锁冲突)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db")
|
||||
|
||||
|
||||
def connect_db(path: str | None = None) -> sqlite3.Connection:
|
||||
db_path = path or DB_PATH
|
||||
conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
return conn
|
||||
|
||||
|
||||
def execute_retry(
|
||||
conn: sqlite3.Connection,
|
||||
sql: str,
|
||||
params: tuple = (),
|
||||
*,
|
||||
retries: int = 6,
|
||||
base_delay: float = 0.05,
|
||||
) -> sqlite3.Cursor:
|
||||
"""遇 database is locked 时短暂退避重试。"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return conn.execute(sql, params)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
last_exc = exc
|
||||
if attempt < retries - 1:
|
||||
time.sleep(base_delay * (attempt + 1))
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
+3
-3
@@ -199,7 +199,7 @@ pm2 restart qihuo
|
||||
|
||||
1. 浏览器登录 → **系统设置** 确认 **模拟盘 · SimNow**
|
||||
2. 打开 **下单监控** 页 → 点击 **连接 CTP**
|
||||
3. 连接成功后:权益来自柜台、显示 CTP 持仓、可报单与品种推荐
|
||||
3. 连接成功后:权益来自柜台、显示 CTP 持仓、可报单与可开仓品种筛选
|
||||
|
||||
详见 [TRADING.md](./TRADING.md)。
|
||||
|
||||
@@ -399,8 +399,8 @@ pm2 restart qihuo
|
||||
/opt/qihuo/
|
||||
├── app.py
|
||||
├── vnpy_bridge.py # CTP 执行层
|
||||
├── recommend_store.py # 品种推荐缓存
|
||||
├── recommend_stream.py # 品种推荐 SSE 推送
|
||||
├── recommend_store.py # 可开仓品种缓存
|
||||
├── recommend_stream.py # 可开仓品种 SSE 推送
|
||||
├── venv/
|
||||
├── futures.db
|
||||
├── .env
|
||||
|
||||
+8
-8
@@ -46,7 +46,7 @@
|
||||
|
||||
### 期货下单
|
||||
|
||||
- 品种联想(仅推荐可开品种或全部主力,取决于计仓模式)
|
||||
- 品种联想(仅列出可开仓品种或全部主力,取决于计仓模式)
|
||||
- 方向、手数(固定手数 / 固定金额计仓)
|
||||
- 限价 / 市价(FAK)、止盈、止损
|
||||
- 非交易时段禁止报单
|
||||
@@ -58,9 +58,9 @@
|
||||
- 持仓卡片:浮盈亏、保证金、止盈止损、平仓等
|
||||
- 数据经 SSE 推送,无需整页刷新
|
||||
|
||||
### 品种推荐
|
||||
### 可开仓品种
|
||||
|
||||
- 按当前权益与保证金上限筛选可开品种
|
||||
- 按当前权益与保证金上限筛选可开品种,养成开仓纪律、限制仓位
|
||||
- **行业分类**、走势(多头/空头/震荡/转多/转空)、跳空、昨日成交量(手)、成交额
|
||||
- 支持行业筛选与多字段排序
|
||||
- 每日后台刷新缓存
|
||||
@@ -184,7 +184,7 @@
|
||||
| 计仓模式 | 固定手数、固定金额 |
|
||||
| 保证金上限、移动保本、挂单超时 | 见表单说明 |
|
||||
| CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env`) |
|
||||
| 参考资金 | CTP 未连接时用于推荐与估算 |
|
||||
| 参考资金 | CTP 未连接时用于可开仓筛选与估算 |
|
||||
| 企业微信 Webhook | 计划/关键位推送 |
|
||||
| 修改密码 | 管理员密码 |
|
||||
| 深色/浅色主题 | 页头切换 |
|
||||
@@ -224,7 +224,7 @@
|
||||
| `review_records` | 复盘 |
|
||||
| `trade_records` | 计划自动止盈止损记录 |
|
||||
| `fee_rates` | 手续费缓存 |
|
||||
| `product_recommend_cache` | 品种推荐缓存 |
|
||||
| `product_recommend_cache` | 可开仓品种缓存 |
|
||||
| `stats_cache` | 统计缓存 |
|
||||
|
||||
数据库文件:项目根目录 `futures.db`。
|
||||
@@ -236,7 +236,7 @@
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| 计划/关键位轮询 | 约 3 秒,触发判断与微信推送 |
|
||||
| 品种推荐刷新 | 每日 + 按需 |
|
||||
| 可开仓品种刷新 | 每日 + 按需 |
|
||||
| 持仓 SSE | 前端订阅 `/api/trading/stream` |
|
||||
| CTP 开盘前连接 | 默认开盘前 30 分钟 |
|
||||
| 挂单超时撤单 | 可配置分钟数 |
|
||||
@@ -249,10 +249,10 @@
|
||||
```
|
||||
qihuo/
|
||||
├── app.py # 主路由、计划/关键位/记录/统计
|
||||
├── install_trading.py # 下单、推荐、策略路由
|
||||
├── install_trading.py # 下单、可开仓品种、策略路由
|
||||
├── vnpy_bridge.py # CTP 连接、报单、持仓
|
||||
├── ctp_trade_sync.py # 柜台成交同步到 trade_logs
|
||||
├── product_recommend.py # 品种推荐计算
|
||||
├── product_recommend.py # 可开仓品种计算
|
||||
├── stats_engine.py # 统计分析
|
||||
├── fee_specs.py / ctp_fee_sync.py
|
||||
├── market.py / kline_chart.py
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ pm2 restart qihuo
|
||||
4. 点击 **连接 CTP**
|
||||
5. 顶栏显示 **CTP 已连接**,权益变为 SimNow 账户资金即成功
|
||||
|
||||
连接成功后:下单、持仓、浮盈均来自 SimNow 柜台;**系统设置里的「参考资金」不再用于交易**,仅 CTP 未连接时用于品种推荐与以损定仓估算。
|
||||
连接成功后:下单、持仓、浮盈均来自 SimNow 柜台;**系统设置里的「参考资金」不再用于交易**,仅 CTP 未连接时用于可开仓品种筛选与以损定仓估算。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-7
@@ -9,9 +9,9 @@
|
||||
| 顶栏 | 交易模式、CTP 状态、权益/可用、连接 CTP |
|
||||
| 期货下单 | 限价/市价报单、止盈止损、以损定仓/固定手数 |
|
||||
| 当前持仓 | CTP 持仓卡片、挂单中、撤单、平仓 |
|
||||
| 品种推荐 | 按权益筛选、行业分类、走势/跳空/成交量排序 |
|
||||
| 可开仓品种 | 按权益与保证金上限筛选、行业分类、走势/跳空/成交量排序 |
|
||||
|
||||
`/trade`、`/recommend` 均重定向到 `/positions`(推荐锚点 `#recommend`)。
|
||||
`/trade`、`/recommend` 均重定向到 `/positions`(可开仓品种锚点 `#recommend`)。
|
||||
|
||||
## 两种交易通道
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
- **平仓**:程序平仓写入 `trade_logs`(来源「本地」)
|
||||
- **持仓数据**:SSE `/api/trading/stream` 推送,约 1 秒刷新
|
||||
|
||||
## 品种推荐
|
||||
## 可开仓品种
|
||||
|
||||
- 每日后台刷新可开品种列表(`/api/recommend/stream`)
|
||||
- 用于开仓纪律与仓位限制:按保证金上限计算最大手数,仅展示当前权益下可开的品种
|
||||
- 每日后台刷新列表(`/api/recommend/stream`)
|
||||
- 最大手数 = floor(权益 × 保证金上限 ÷ 1 手保证金)
|
||||
- 展示近一周日线走势、跳空、昨日成交量(手)、成交额
|
||||
- 可按 **行业** 筛选,支持多字段排序
|
||||
@@ -47,7 +48,7 @@
|
||||
|
||||
## 参考资金
|
||||
|
||||
系统设置中的「参考资金」仅在 **CTP 未连接** 时用于品种推荐与以损定仓估算;连接后自动改用柜台权益。
|
||||
系统设置中的「参考资金」仅在 **CTP 未连接** 时用于可开仓品种筛选与以损定仓估算;连接后自动改用柜台权益。
|
||||
|
||||
## 首次使用 SimNow
|
||||
|
||||
@@ -65,8 +66,8 @@
|
||||
| `POST /api/trading/order/cancel` | 撤单(交易时段) |
|
||||
| `POST /api/trading/close` | 平仓 |
|
||||
| `GET /api/trading/stream` | 持仓 SSE |
|
||||
| `GET /api/recommend/list` | 品种推荐 JSON |
|
||||
| `GET /api/recommend/stream` | 品种推荐 SSE |
|
||||
| `GET /api/recommend/list` | 可开仓品种 JSON |
|
||||
| `GET /api/recommend/stream` | 可开仓品种 SSE |
|
||||
| `POST /api/strategy/trend/execute` | 执行趋势策略 |
|
||||
|
||||
详见 [DEPLOY.md](./DEPLOY.md) 中 CTP 故障排查。
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# 软件购买与使用协议(个人版)
|
||||
|
||||
> **说明**:本协议为个人购买者使用模板。正式交付时可打印或转为 PDF,由双方签字/确认。
|
||||
> 本模板不构成法律意见;金额较大或机构/共享交易室合作,建议由执业律师审阅后使用。
|
||||
|
||||
---
|
||||
|
||||
**协议编号**:_______________
|
||||
**签订日期**:_______________
|
||||
|
||||
---
|
||||
|
||||
## 甲方(著作权人 / 许可方)
|
||||
|
||||
- **姓名**:马建军
|
||||
- **联系电话**:18364911125
|
||||
- **微信**:dekun03
|
||||
|
||||
## 乙方(被许可方 / 购买方)
|
||||
|
||||
- **姓名**:_______________
|
||||
- **联系电话**:_______________
|
||||
- **微信/邮箱**:_______________
|
||||
|
||||
---
|
||||
|
||||
## 第一条 软件与交付内容
|
||||
|
||||
1.1 甲方向乙方提供的软件名称为 **「国内期货交易监控复盘系统」**(以下简称「本软件」),包括甲方交付时约定版本的源代码、部署说明及必要配置指导。
|
||||
|
||||
1.2 **交付方式**(勾选适用项):
|
||||
|
||||
- [ ] 部署服务:甲方协助乙方在乙方指定服务器完成安装与基础配置
|
||||
- [ ] 源代码:甲方提供约定版本源代码(Git 归档 / 压缩包 / 私有仓库只读权限,择一填写:_______________)
|
||||
- [ ] 其他:_______________
|
||||
|
||||
1.3 **交付版本标识**(建议填写 Git 提交号或日期):_______________
|
||||
|
||||
---
|
||||
|
||||
## 第二条 授权范围
|
||||
|
||||
2.1 甲方授予乙方 **非独占、不可转让、不可再许可** 的个人使用许可。
|
||||
|
||||
2.2 乙方仅可将本软件部署在 **乙方本人名下单一实例**(一台 VPS 或一台个人电脑服务器,二选一或填写:_______________),供 **乙方本人** 用于个人期货交易的纪律管理、记录与复盘。
|
||||
|
||||
2.3 本授权 **不包括** 以下权利(须另行书面协议并支付费用):
|
||||
|
||||
- 共享交易室、培训室、跟单室等多人共用或对外经营
|
||||
- 白标、OEM、二次分发、转售源码
|
||||
- 将本软件作为带单、荐品种、配资等业务的工具或平台
|
||||
|
||||
---
|
||||
|
||||
## 第三条 严禁用途(乙方承诺)
|
||||
|
||||
乙方承诺 **不得** 利用本软件从事以下行为:
|
||||
|
||||
1. **带单、代客理财、代客下单、信号群喊单、跟单服务** 等可能违反期货监管及咨询资质要求的行为;
|
||||
2. **向他人推荐、介绍特定期货品种、合约或具体买卖方向**,并以此向他人收费或获利;
|
||||
3. **融资、配资、分仓、对赌、非法吸收资金** 等资金融通或变相配资行为;
|
||||
4. **复制、传播、转售、出租、出借** 源代码或部署包给任何第三方;
|
||||
5. **删除、篡改** 软件内或文档中的版权声明与许可说明;
|
||||
6. 其他违反中国法律法规及期货监管规定的行为。
|
||||
|
||||
乙方违反本条,甲方有权 **立即终止许可**;乙方已付费用 **不予退还**(法律另有强制性规定的除外)。因乙方违规导致甲方损失的,乙方应依法赔偿。
|
||||
|
||||
---
|
||||
|
||||
## 第四条 费用与支付
|
||||
|
||||
4.1 乙方应向甲方支付:
|
||||
|
||||
| 项目 | 金额(元) | 备注 |
|
||||
|------|------------|------|
|
||||
| 部署服务费 | | |
|
||||
| 源代码许可费 | | |
|
||||
| 其他 | | |
|
||||
| **合计** | | |
|
||||
|
||||
4.2 支付方式:_______________
|
||||
4.3 甲方收到约定款项后 ___ 个工作日内完成交付(或双方另行约定)。
|
||||
|
||||
---
|
||||
|
||||
## 第五条 更新、维护与支持
|
||||
|
||||
5.1 **版本更新**(勾选):
|
||||
|
||||
- [ ] 本次交付为固定版本,后续大版本更新需 **另行付费**
|
||||
- [ ] 含 ___ 个月内的缺陷修复与小版本更新(不含新功能模块)
|
||||
- [ ] 其他:_______________
|
||||
|
||||
5.2 支持方式与范围:_______________(如:微信答疑、远程协助次数等)。
|
||||
5.3 超出约定范围的支持,双方可 **另行协商费用**。
|
||||
|
||||
---
|
||||
|
||||
## 第六条 知识产权
|
||||
|
||||
6.1 本软件之著作权及其他知识产权 **均归甲方所有**。乙方仅获得本协议第二条约定之 **有限使用权**,不取得著作权转让或共有。
|
||||
|
||||
6.2 乙方可在本协议授权范围内备份源代码供 **自用**,不得用于再分发。
|
||||
|
||||
---
|
||||
|
||||
## 第七条 免责声明与风险提示
|
||||
|
||||
7.1 本软件为 **交易纪律与记录辅助工具**,不提供投资咨询,不构成任何 **投资建议、收益承诺或交易信号**。
|
||||
|
||||
7.2 **期货交易风险极大**,乙方须具备相应风险承受能力,独立作出交易决策,盈亏由乙方 **自行承担**。
|
||||
|
||||
7.3 因 CTP/SimNow/网络/服务器/第三方接口故障、断线、延迟等导致的数据偏差、下单失败或损失,甲方在已尽合理交付与说明义务的前提下, **不承担** 由此产生的交易损失(法律强制性规定除外)。
|
||||
|
||||
7.4 甲方不保证软件持续符合某一交易所或期货公司的全部最新规则;监管或接口变化时,乙方应配合升级或调整配置。
|
||||
|
||||
---
|
||||
|
||||
## 第八条 责任限制
|
||||
|
||||
8.1 除因甲方 **故意或重大过失** 直接导致乙方人身或财产损害的情形外,甲方对乙方因使用或无法使用本软件产生的 **间接损失、交易亏损、数据丢失、业务中断** 等不承担责任。
|
||||
|
||||
8.2 在任何情况下,甲方对乙方的 **累计赔偿责任** 不超过乙方就本协议 **实际已支付给甲方的费用总额**(法律强制性规定除外)。
|
||||
|
||||
---
|
||||
|
||||
## 第九条 保密
|
||||
|
||||
9.1 乙方对交付的 **未公开源代码、部署文档、配置信息** 负有保密义务,不得向无关第三方披露,法律法规或监管要求除外。
|
||||
|
||||
9.2 保密期限:许可终止后 **三(3)年** 内仍有效(源代码本身仍不得非法传播)。
|
||||
|
||||
---
|
||||
|
||||
## 第十条 协议期限与终止
|
||||
|
||||
10.1 本协议自双方签字/确认之日起生效。个人使用许可为 **长期有效**,直至依本条终止。
|
||||
|
||||
10.2 有下列情形之一的,甲方有权终止许可,乙方应停止使用并销毁多余副本(保留一份备份法律允许的范围内自用备份除外):
|
||||
|
||||
- 乙方违反第三条严禁用途或第二条授权范围;
|
||||
- 乙方非法转售、传播源码;
|
||||
- 乙方从事违法经营活动并使用本软件。
|
||||
|
||||
10.3 终止后,乙方 **不得** 继续使用本软件开展新业务;已产生的法律责任不因终止而免除。
|
||||
|
||||
---
|
||||
|
||||
## 第十一条 争议解决
|
||||
|
||||
11.1 本协议之订立、效力、解释、履行及争议解决均适用 **中华人民共和国法律**。
|
||||
|
||||
11.2 双方因本协议发生争议,应先友好协商;协商不成的,任一方可向 **甲方住所地有管辖权的人民法院** 提起诉讼。
|
||||
|
||||
---
|
||||
|
||||
## 第十二条 其他
|
||||
|
||||
12.1 本协议与仓库根目录 `LICENSE.zh-CN.txt` 内容不一致的, **以本协议为准**(仅针对甲乙双方之间)。
|
||||
|
||||
12.2 本协议一式两份,甲乙双方各执一份,具有同等效力(电子确认、微信确认截图与纸质同等有效,双方认可时)。
|
||||
|
||||
12.3 未尽事宜,双方可签订 **补充协议**;补充协议与本协议具有同等效力。
|
||||
|
||||
---
|
||||
|
||||
## 签署栏
|
||||
|
||||
**甲方(许可方)**
|
||||
|
||||
签名:_______________
|
||||
日期:_______________
|
||||
|
||||
**乙方(被许可方)**
|
||||
|
||||
签名:_______________
|
||||
日期:_______________
|
||||
|
||||
---
|
||||
|
||||
## 附件(可选)
|
||||
|
||||
- [ ] 交付清单(版本号、文件列表、服务器信息)
|
||||
- [ ] 部署完成确认单
|
||||
- [ ] 乙方身份证复印件(线下签约时)
|
||||
+385
-380
@@ -1,380 +1,385 @@
|
||||
"""期货手续费:仅 CTP 柜台同步入库,前端只读展示。"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
|
||||
from db_conn import connect_db
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db")
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
||||
DEFAULT_JSON = os.path.join(DATA_DIR, "fee_rates.json")
|
||||
|
||||
# 无配置时的兜底(已为交易所标准约 2 倍)
|
||||
DEFAULT_FEE = {
|
||||
"open_fixed": 2.0,
|
||||
"open_ratio": 0.0,
|
||||
"close_yesterday_fixed": 2.0,
|
||||
"close_yesterday_ratio": 0.0,
|
||||
"close_today_fixed": 4.0,
|
||||
"close_today_ratio": 0.0,
|
||||
}
|
||||
|
||||
_INDEX_PRODUCTS = {"if", "ih", "ic", "im"}
|
||||
|
||||
|
||||
def product_from_code(ths_code: str) -> str:
|
||||
code = (ths_code or "").strip()
|
||||
m = re.match(r"^([A-Za-z]+)", code)
|
||||
return m.group(1).lower() if m else ""
|
||||
|
||||
|
||||
def _get_db():
|
||||
return connect_db()
|
||||
|
||||
|
||||
def ensure_fee_rates_schema(conn=None) -> None:
|
||||
"""补齐 fee_rates 表结构(旧库可能缺少 source 列)。"""
|
||||
close = False
|
||||
if conn is None:
|
||||
conn = _get_db()
|
||||
close = True
|
||||
try:
|
||||
for sql in (
|
||||
"ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'",
|
||||
):
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
finally:
|
||||
if close:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_setting(key: str, default: str = "") -> str:
|
||||
conn = _get_db()
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return default
|
||||
return (row["value"] or default) if row["value"] is not None else default
|
||||
|
||||
|
||||
def set_setting(key: str, value: str) -> None:
|
||||
conn = _get_db()
|
||||
conn.execute(
|
||||
"""INSERT INTO settings (key, value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value""",
|
||||
(key, value),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_fee_multiplier() -> float:
|
||||
conn = _get_db()
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key='fee_multiplier'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row and row["value"]:
|
||||
try:
|
||||
return max(0.0, float(row["value"]))
|
||||
except ValueError:
|
||||
pass
|
||||
return 2.0
|
||||
|
||||
|
||||
def get_fee_source_mode() -> str:
|
||||
"""固定 CTP 柜台。"""
|
||||
return "ctp"
|
||||
|
||||
|
||||
def purge_non_ctp_fee_rates() -> int:
|
||||
"""删除非 CTP 来源的费率缓存。"""
|
||||
conn = _get_db()
|
||||
cur = conn.execute(
|
||||
"DELETE FROM fee_rates WHERE COALESCE(source, '') != 'ctp'"
|
||||
)
|
||||
n = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return n
|
||||
|
||||
|
||||
def _row_to_spec(row, mult: int) -> dict:
|
||||
return {
|
||||
"product": row["product"],
|
||||
"exchange": row["exchange"] or "",
|
||||
"mult": int(row["mult"] or mult),
|
||||
"open_fixed": float(row["open_fixed"] or 0),
|
||||
"open_ratio": float(row["open_ratio"] or 0),
|
||||
"close_yesterday_fixed": float(row["close_yesterday_fixed"] or 0),
|
||||
"close_yesterday_ratio": float(row["close_yesterday_ratio"] or 0),
|
||||
"close_today_fixed": float(row["close_today_fixed"] or 0),
|
||||
"close_today_ratio": float(row["close_today_ratio"] or 0),
|
||||
"source": row["source"] if "source" in row.keys() else "local",
|
||||
}
|
||||
|
||||
|
||||
def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict:
|
||||
product = product_from_code(ths_code)
|
||||
if not product:
|
||||
spec = get_contract_spec(ths_code)
|
||||
return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": "", "source": "default"}
|
||||
|
||||
mult = get_contract_spec(ths_code)["mult"]
|
||||
conn = _get_db()
|
||||
ensure_fee_rates_schema(conn)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM fee_rates WHERE product=? AND source='ctp'",
|
||||
(product,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return _row_to_spec(row, mult)
|
||||
try:
|
||||
from ctp_fee_sync import sync_fee_for_symbol
|
||||
fields = sync_fee_for_symbol(trading_mode, ths_code)
|
||||
if fields:
|
||||
return {"product": product, **fields}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if product in _INDEX_PRODUCTS:
|
||||
return {
|
||||
"product": product,
|
||||
"exchange": "CFFEX",
|
||||
"mult": mult,
|
||||
"open_fixed": 0.0,
|
||||
"open_ratio": 0.000092,
|
||||
"close_yesterday_fixed": 0.0,
|
||||
"close_yesterday_ratio": 0.000092,
|
||||
"close_today_fixed": 0.0,
|
||||
"close_today_ratio": 0.000276,
|
||||
}
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"exchange": "",
|
||||
"mult": mult,
|
||||
**DEFAULT_FEE,
|
||||
"source": "default",
|
||||
}
|
||||
|
||||
|
||||
def calc_side_fee(
|
||||
price: float,
|
||||
lots: float,
|
||||
mult: int,
|
||||
fixed: float,
|
||||
ratio: float,
|
||||
) -> float:
|
||||
lots = lots or 1.0
|
||||
fixed = fixed or 0.0
|
||||
ratio = ratio or 0.0
|
||||
return fixed * lots + ratio * price * mult * lots
|
||||
|
||||
|
||||
def is_same_day(open_time: str, close_time: str) -> bool:
|
||||
if not open_time or not close_time:
|
||||
return True
|
||||
o = open_time.strip().replace(" ", "T")[:10]
|
||||
c = close_time.strip().replace(" ", "T")[:10]
|
||||
return o == c
|
||||
|
||||
|
||||
def calc_round_trip_fee(
|
||||
ths_code: str,
|
||||
entry_price: float,
|
||||
close_price: float,
|
||||
lots: float,
|
||||
open_time: str = "",
|
||||
close_time: str = "",
|
||||
trading_mode: str = "simulation",
|
||||
) -> float:
|
||||
if not entry_price or not close_price:
|
||||
return 0.0
|
||||
spec = get_fee_spec(ths_code, trading_mode=trading_mode)
|
||||
mult = spec["mult"]
|
||||
lots = lots or 1.0
|
||||
|
||||
open_fee = calc_side_fee(
|
||||
entry_price, lots, mult,
|
||||
spec["open_fixed"], spec["open_ratio"],
|
||||
)
|
||||
if is_same_day(open_time, close_time):
|
||||
close_fee = calc_side_fee(
|
||||
close_price, lots, mult,
|
||||
spec["close_today_fixed"], spec["close_today_ratio"],
|
||||
)
|
||||
else:
|
||||
close_fee = calc_side_fee(
|
||||
close_price, lots, mult,
|
||||
spec["close_yesterday_fixed"], spec["close_yesterday_ratio"],
|
||||
)
|
||||
return round(open_fee + close_fee, 2)
|
||||
|
||||
|
||||
def calc_fee_breakdown(
|
||||
ths_code: str,
|
||||
entry_price: float,
|
||||
close_price: float,
|
||||
lots: float,
|
||||
open_time: str = "",
|
||||
close_time: str = "",
|
||||
trading_mode: str = "simulation",
|
||||
) -> dict:
|
||||
spec = get_fee_spec(ths_code, trading_mode=trading_mode)
|
||||
mult = spec["mult"]
|
||||
lots = lots or 1.0
|
||||
open_fee = calc_side_fee(
|
||||
entry_price, lots, mult, spec["open_fixed"], spec["open_ratio"],
|
||||
)
|
||||
same_day = is_same_day(open_time, close_time)
|
||||
if same_day:
|
||||
close_fee = calc_side_fee(
|
||||
close_price, lots, mult,
|
||||
spec["close_today_fixed"], spec["close_today_ratio"],
|
||||
)
|
||||
close_type = "平今"
|
||||
else:
|
||||
close_fee = calc_side_fee(
|
||||
close_price, lots, mult,
|
||||
spec["close_yesterday_fixed"], spec["close_yesterday_ratio"],
|
||||
)
|
||||
close_type = "平昨"
|
||||
total = round(open_fee + close_fee, 2)
|
||||
return {
|
||||
"open_fee": round(open_fee, 2),
|
||||
"close_fee": round(close_fee, 2),
|
||||
"close_type": close_type,
|
||||
"total_fee": total,
|
||||
"same_day": same_day,
|
||||
"fee_source": spec.get("source", "local"),
|
||||
}
|
||||
|
||||
|
||||
def load_fee_rates_from_json(path: Optional[str] = None) -> int:
|
||||
path = path or DEFAULT_JSON
|
||||
if not os.path.isfile(path):
|
||||
return 0
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
conn = _get_db()
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
count = 0
|
||||
for product, item in data.items():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
conn.execute(
|
||||
"""INSERT INTO fee_rates
|
||||
(product, exchange, mult,
|
||||
open_fixed, open_ratio,
|
||||
close_yesterday_fixed, close_yesterday_ratio,
|
||||
close_today_fixed, close_today_ratio, updated_at, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(product) DO UPDATE SET
|
||||
exchange=excluded.exchange, mult=excluded.mult,
|
||||
open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio,
|
||||
close_yesterday_fixed=excluded.close_yesterday_fixed,
|
||||
close_yesterday_ratio=excluded.close_yesterday_ratio,
|
||||
close_today_fixed=excluded.close_today_fixed,
|
||||
close_today_ratio=excluded.close_today_ratio,
|
||||
updated_at=excluded.updated_at,
|
||||
source=excluded.source""",
|
||||
(
|
||||
product.lower(),
|
||||
item.get("exchange", ""),
|
||||
int(item.get("mult") or get_contract_spec(product)["mult"]),
|
||||
float(item.get("open_fixed") or 0),
|
||||
float(item.get("open_ratio") or 0),
|
||||
float(item.get("close_yesterday_fixed") or 0),
|
||||
float(item.get("close_yesterday_ratio") or 0),
|
||||
float(item.get("close_today_fixed") or 0),
|
||||
float(item.get("close_today_ratio") or 0),
|
||||
now,
|
||||
item.get("source", "json"),
|
||||
),
|
||||
)
|
||||
count += 1
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
def list_ctp_fee_rates() -> list:
|
||||
"""手续费页:仅展示 CTP 同步结果。"""
|
||||
conn = _get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM fee_rates WHERE source='ctp' ORDER BY product"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def list_all_fee_rates() -> list:
|
||||
conn = _get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM fee_rates ORDER BY product"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def list_fee_rates_for_ui() -> list:
|
||||
return list_ctp_fee_rates()
|
||||
|
||||
|
||||
def count_fee_rates_by_source() -> dict[str, int]:
|
||||
conn = _get_db()
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM fee_rates WHERE source='ctp'"
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
return {"ctp": int(n or 0)}
|
||||
|
||||
|
||||
def upsert_fee_rate(product: str, fields: dict) -> None:
|
||||
product = product.lower().strip()
|
||||
conn = _get_db()
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
source = fields.get("source", "manual")
|
||||
conn.execute(
|
||||
"""INSERT INTO fee_rates
|
||||
(product, exchange, mult,
|
||||
open_fixed, open_ratio,
|
||||
close_yesterday_fixed, close_yesterday_ratio,
|
||||
close_today_fixed, close_today_ratio, updated_at, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(product) DO UPDATE SET
|
||||
exchange=excluded.exchange, mult=excluded.mult,
|
||||
open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio,
|
||||
close_yesterday_fixed=excluded.close_yesterday_fixed,
|
||||
close_yesterday_ratio=excluded.close_yesterday_ratio,
|
||||
close_today_fixed=excluded.close_today_fixed,
|
||||
close_today_ratio=excluded.close_today_ratio,
|
||||
updated_at=excluded.updated_at,
|
||||
source=excluded.source""",
|
||||
(
|
||||
product,
|
||||
fields.get("exchange", ""),
|
||||
int(fields.get("mult") or 10),
|
||||
float(fields.get("open_fixed") or 0),
|
||||
float(fields.get("open_ratio") or 0),
|
||||
float(fields.get("close_yesterday_fixed") or 0),
|
||||
float(fields.get("close_yesterday_ratio") or 0),
|
||||
float(fields.get("close_today_fixed") or 0),
|
||||
float(fields.get("close_today_ratio") or 0),
|
||||
now,
|
||||
source,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""期货手续费:仅 CTP 柜台同步入库,前端只读展示。"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
|
||||
from db_conn import connect_db
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db")
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
||||
DEFAULT_JSON = os.path.join(DATA_DIR, "fee_rates.json")
|
||||
|
||||
# 无配置时的兜底(已为交易所标准约 2 倍)
|
||||
DEFAULT_FEE = {
|
||||
"open_fixed": 2.0,
|
||||
"open_ratio": 0.0,
|
||||
"close_yesterday_fixed": 2.0,
|
||||
"close_yesterday_ratio": 0.0,
|
||||
"close_today_fixed": 4.0,
|
||||
"close_today_ratio": 0.0,
|
||||
}
|
||||
|
||||
_INDEX_PRODUCTS = {"if", "ih", "ic", "im"}
|
||||
|
||||
|
||||
def product_from_code(ths_code: str) -> str:
|
||||
code = (ths_code or "").strip()
|
||||
m = re.match(r"^([A-Za-z]+)", code)
|
||||
return m.group(1).lower() if m else ""
|
||||
|
||||
|
||||
def _get_db():
|
||||
return connect_db()
|
||||
|
||||
|
||||
def ensure_fee_rates_schema(conn=None) -> None:
|
||||
"""补齐 fee_rates 表结构(旧库可能缺少 source 列)。"""
|
||||
close = False
|
||||
if conn is None:
|
||||
conn = _get_db()
|
||||
close = True
|
||||
try:
|
||||
for sql in (
|
||||
"ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'",
|
||||
):
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
finally:
|
||||
if close:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_setting(key: str, default: str = "") -> str:
|
||||
conn = _get_db()
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return default
|
||||
return (row["value"] or default) if row["value"] is not None else default
|
||||
|
||||
|
||||
def set_setting(key: str, value: str) -> None:
|
||||
conn = _get_db()
|
||||
conn.execute(
|
||||
"""INSERT INTO settings (key, value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value""",
|
||||
(key, value),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_fee_multiplier() -> float:
|
||||
conn = _get_db()
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key='fee_multiplier'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row and row["value"]:
|
||||
try:
|
||||
return max(0.0, float(row["value"]))
|
||||
except ValueError:
|
||||
pass
|
||||
return 2.0
|
||||
|
||||
|
||||
def get_fee_source_mode() -> str:
|
||||
"""固定 CTP 柜台。"""
|
||||
return "ctp"
|
||||
|
||||
|
||||
def purge_non_ctp_fee_rates() -> int:
|
||||
"""删除非 CTP 来源的费率缓存。"""
|
||||
conn = _get_db()
|
||||
cur = conn.execute(
|
||||
"DELETE FROM fee_rates WHERE COALESCE(source, '') != 'ctp'"
|
||||
)
|
||||
n = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return n
|
||||
|
||||
|
||||
def _row_to_spec(row, mult: int) -> dict:
|
||||
return {
|
||||
"product": row["product"],
|
||||
"exchange": row["exchange"] or "",
|
||||
"mult": int(row["mult"] or mult),
|
||||
"open_fixed": float(row["open_fixed"] or 0),
|
||||
"open_ratio": float(row["open_ratio"] or 0),
|
||||
"close_yesterday_fixed": float(row["close_yesterday_fixed"] or 0),
|
||||
"close_yesterday_ratio": float(row["close_yesterday_ratio"] or 0),
|
||||
"close_today_fixed": float(row["close_today_fixed"] or 0),
|
||||
"close_today_ratio": float(row["close_today_ratio"] or 0),
|
||||
"source": row["source"] if "source" in row.keys() else "local",
|
||||
}
|
||||
|
||||
|
||||
def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict:
|
||||
product = product_from_code(ths_code)
|
||||
if not product:
|
||||
spec = get_contract_spec(ths_code)
|
||||
return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": "", "source": "default"}
|
||||
|
||||
mult = get_contract_spec(ths_code)["mult"]
|
||||
conn = _get_db()
|
||||
ensure_fee_rates_schema(conn)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM fee_rates WHERE product=? AND source='ctp'",
|
||||
(product,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return _row_to_spec(row, mult)
|
||||
try:
|
||||
from ctp_fee_sync import sync_fee_for_symbol
|
||||
fields = sync_fee_for_symbol(trading_mode, ths_code)
|
||||
if fields:
|
||||
return {"product": product, **fields}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if product in _INDEX_PRODUCTS:
|
||||
return {
|
||||
"product": product,
|
||||
"exchange": "CFFEX",
|
||||
"mult": mult,
|
||||
"open_fixed": 0.0,
|
||||
"open_ratio": 0.000092,
|
||||
"close_yesterday_fixed": 0.0,
|
||||
"close_yesterday_ratio": 0.000092,
|
||||
"close_today_fixed": 0.0,
|
||||
"close_today_ratio": 0.000276,
|
||||
}
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"exchange": "",
|
||||
"mult": mult,
|
||||
**DEFAULT_FEE,
|
||||
"source": "default",
|
||||
}
|
||||
|
||||
|
||||
def calc_side_fee(
|
||||
price: float,
|
||||
lots: float,
|
||||
mult: int,
|
||||
fixed: float,
|
||||
ratio: float,
|
||||
) -> float:
|
||||
lots = lots or 1.0
|
||||
fixed = fixed or 0.0
|
||||
ratio = ratio or 0.0
|
||||
return fixed * lots + ratio * price * mult * lots
|
||||
|
||||
|
||||
def is_same_day(open_time: str, close_time: str) -> bool:
|
||||
if not open_time or not close_time:
|
||||
return True
|
||||
o = open_time.strip().replace(" ", "T")[:10]
|
||||
c = close_time.strip().replace(" ", "T")[:10]
|
||||
return o == c
|
||||
|
||||
|
||||
def calc_round_trip_fee(
|
||||
ths_code: str,
|
||||
entry_price: float,
|
||||
close_price: float,
|
||||
lots: float,
|
||||
open_time: str = "",
|
||||
close_time: str = "",
|
||||
trading_mode: str = "simulation",
|
||||
) -> float:
|
||||
if not entry_price or not close_price:
|
||||
return 0.0
|
||||
spec = get_fee_spec(ths_code, trading_mode=trading_mode)
|
||||
mult = spec["mult"]
|
||||
lots = lots or 1.0
|
||||
|
||||
open_fee = calc_side_fee(
|
||||
entry_price, lots, mult,
|
||||
spec["open_fixed"], spec["open_ratio"],
|
||||
)
|
||||
if is_same_day(open_time, close_time):
|
||||
close_fee = calc_side_fee(
|
||||
close_price, lots, mult,
|
||||
spec["close_today_fixed"], spec["close_today_ratio"],
|
||||
)
|
||||
else:
|
||||
close_fee = calc_side_fee(
|
||||
close_price, lots, mult,
|
||||
spec["close_yesterday_fixed"], spec["close_yesterday_ratio"],
|
||||
)
|
||||
return round(open_fee + close_fee, 2)
|
||||
|
||||
|
||||
def calc_fee_breakdown(
|
||||
ths_code: str,
|
||||
entry_price: float,
|
||||
close_price: float,
|
||||
lots: float,
|
||||
open_time: str = "",
|
||||
close_time: str = "",
|
||||
trading_mode: str = "simulation",
|
||||
) -> dict:
|
||||
spec = get_fee_spec(ths_code, trading_mode=trading_mode)
|
||||
mult = spec["mult"]
|
||||
lots = lots or 1.0
|
||||
open_fee = calc_side_fee(
|
||||
entry_price, lots, mult, spec["open_fixed"], spec["open_ratio"],
|
||||
)
|
||||
same_day = is_same_day(open_time, close_time)
|
||||
if same_day:
|
||||
close_fee = calc_side_fee(
|
||||
close_price, lots, mult,
|
||||
spec["close_today_fixed"], spec["close_today_ratio"],
|
||||
)
|
||||
close_type = "平今"
|
||||
else:
|
||||
close_fee = calc_side_fee(
|
||||
close_price, lots, mult,
|
||||
spec["close_yesterday_fixed"], spec["close_yesterday_ratio"],
|
||||
)
|
||||
close_type = "平昨"
|
||||
total = round(open_fee + close_fee, 2)
|
||||
return {
|
||||
"open_fee": round(open_fee, 2),
|
||||
"close_fee": round(close_fee, 2),
|
||||
"close_type": close_type,
|
||||
"total_fee": total,
|
||||
"same_day": same_day,
|
||||
"fee_source": spec.get("source", "local"),
|
||||
}
|
||||
|
||||
|
||||
def load_fee_rates_from_json(path: Optional[str] = None) -> int:
|
||||
path = path or DEFAULT_JSON
|
||||
if not os.path.isfile(path):
|
||||
return 0
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
conn = _get_db()
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
count = 0
|
||||
for product, item in data.items():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
conn.execute(
|
||||
"""INSERT INTO fee_rates
|
||||
(product, exchange, mult,
|
||||
open_fixed, open_ratio,
|
||||
close_yesterday_fixed, close_yesterday_ratio,
|
||||
close_today_fixed, close_today_ratio, updated_at, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(product) DO UPDATE SET
|
||||
exchange=excluded.exchange, mult=excluded.mult,
|
||||
open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio,
|
||||
close_yesterday_fixed=excluded.close_yesterday_fixed,
|
||||
close_yesterday_ratio=excluded.close_yesterday_ratio,
|
||||
close_today_fixed=excluded.close_today_fixed,
|
||||
close_today_ratio=excluded.close_today_ratio,
|
||||
updated_at=excluded.updated_at,
|
||||
source=excluded.source""",
|
||||
(
|
||||
product.lower(),
|
||||
item.get("exchange", ""),
|
||||
int(item.get("mult") or get_contract_spec(product)["mult"]),
|
||||
float(item.get("open_fixed") or 0),
|
||||
float(item.get("open_ratio") or 0),
|
||||
float(item.get("close_yesterday_fixed") or 0),
|
||||
float(item.get("close_yesterday_ratio") or 0),
|
||||
float(item.get("close_today_fixed") or 0),
|
||||
float(item.get("close_today_ratio") or 0),
|
||||
now,
|
||||
item.get("source", "json"),
|
||||
),
|
||||
)
|
||||
count += 1
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
def list_ctp_fee_rates() -> list:
|
||||
"""手续费页:仅展示 CTP 同步结果。"""
|
||||
conn = _get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM fee_rates WHERE source='ctp' ORDER BY product"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def list_all_fee_rates() -> list:
|
||||
conn = _get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM fee_rates ORDER BY product"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def list_fee_rates_for_ui() -> list:
|
||||
return list_ctp_fee_rates()
|
||||
|
||||
|
||||
def count_fee_rates_by_source() -> dict[str, int]:
|
||||
conn = _get_db()
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM fee_rates WHERE source='ctp'"
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
return {"ctp": int(n or 0)}
|
||||
|
||||
|
||||
def upsert_fee_rate(product: str, fields: dict) -> None:
|
||||
product = product.lower().strip()
|
||||
conn = _get_db()
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
source = fields.get("source", "manual")
|
||||
conn.execute(
|
||||
"""INSERT INTO fee_rates
|
||||
(product, exchange, mult,
|
||||
open_fixed, open_ratio,
|
||||
close_yesterday_fixed, close_yesterday_ratio,
|
||||
close_today_fixed, close_today_ratio, updated_at, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(product) DO UPDATE SET
|
||||
exchange=excluded.exchange, mult=excluded.mult,
|
||||
open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio,
|
||||
close_yesterday_fixed=excluded.close_yesterday_fixed,
|
||||
close_yesterday_ratio=excluded.close_yesterday_ratio,
|
||||
close_today_fixed=excluded.close_today_fixed,
|
||||
close_today_ratio=excluded.close_today_ratio,
|
||||
updated_at=excluded.updated_at,
|
||||
source=excluded.source""",
|
||||
(
|
||||
product,
|
||||
fields.get("exchange", ""),
|
||||
int(fields.get("mult") or 10),
|
||||
float(fields.get("open_fixed") or 0),
|
||||
float(fields.get("open_ratio") or 0),
|
||||
float(fields.get("close_yesterday_fixed") or 0),
|
||||
float(fields.get("close_yesterday_ratio") or 0),
|
||||
float(fields.get("close_today_fixed") or 0),
|
||||
float(fields.get("close_today_ratio") or 0),
|
||||
now,
|
||||
source,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
+91
-86
@@ -1,86 +1,91 @@
|
||||
"""从第三方(AKShare)同步交易所参考手续费,并按倍率写入本地表。"""
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
from fee_specs import get_fee_multiplier, upsert_fee_rate
|
||||
|
||||
|
||||
def _to_float(val: Any) -> float:
|
||||
if val is None:
|
||||
return 0.0
|
||||
s = str(val).strip().replace(",", "")
|
||||
if not s or s in ("-", "None", "nan"):
|
||||
return 0.0
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _parse_akshare_row(row: dict, multiplier: float) -> Optional[dict]:
|
||||
code = str(row.get("合约代码") or row.get("代码") or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
m = re.match(r"^([A-Za-z]+)", code)
|
||||
if not m:
|
||||
return None
|
||||
product = m.group(1).lower()
|
||||
|
||||
open_ratio = _to_float(row.get("手续费标准-开仓-万分之")) / 10000.0
|
||||
open_fixed = _to_float(row.get("手续费标准-开仓-元"))
|
||||
if open_fixed == 0 and row.get("开仓"):
|
||||
open_fixed = _to_float(row.get("开仓"))
|
||||
close_y_ratio = _to_float(row.get("手续费标准-平昨-万分之")) / 10000.0
|
||||
close_y_fixed = _to_float(row.get("手续费标准-平昨-元"))
|
||||
if close_y_fixed == 0 and row.get("平昨"):
|
||||
close_y_fixed = _to_float(row.get("平昨"))
|
||||
close_t_ratio = _to_float(row.get("手续费标准-平今-万分之")) / 10000.0
|
||||
close_t_fixed = _to_float(row.get("手续费标准-平今-元"))
|
||||
if close_t_fixed == 0 and row.get("平今"):
|
||||
close_t_fixed = _to_float(row.get("平今"))
|
||||
|
||||
mult = int(get_contract_spec(code)["mult"])
|
||||
exchange = str(row.get("交易所名称") or row.get("交易所") or "").strip()
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"exchange": exchange,
|
||||
"mult": mult,
|
||||
"open_fixed": round(open_fixed * multiplier, 6),
|
||||
"open_ratio": round(open_ratio * multiplier, 8),
|
||||
"close_yesterday_fixed": round(close_y_fixed * multiplier, 6),
|
||||
"close_yesterday_ratio": round(close_y_ratio * multiplier, 8),
|
||||
"close_today_fixed": round(close_t_fixed * multiplier, 6),
|
||||
"close_today_ratio": round(close_t_ratio * multiplier, 8),
|
||||
"source": "akshare",
|
||||
}
|
||||
|
||||
|
||||
def sync_fees_from_akshare(multiplier: Optional[float] = None) -> tuple[int, str]:
|
||||
multiplier = multiplier if multiplier is not None else get_fee_multiplier()
|
||||
try:
|
||||
import akshare as ak
|
||||
except ImportError:
|
||||
return 0, "未安装 akshare,请执行 pip install akshare 后重试,或使用默认费率表"
|
||||
|
||||
try:
|
||||
df = ak.futures_comm_info(symbol="所有")
|
||||
except Exception as exc:
|
||||
return 0, f"拉取第三方数据失败: {exc}"
|
||||
|
||||
if df is None or df.empty:
|
||||
return 0, "第三方返回空数据"
|
||||
|
||||
seen: set[str] = set()
|
||||
count = 0
|
||||
for _, series in df.iterrows():
|
||||
row = series.to_dict()
|
||||
parsed = _parse_akshare_row(row, multiplier)
|
||||
if not parsed or parsed["product"] in seen:
|
||||
continue
|
||||
seen.add(parsed["product"])
|
||||
upsert_fee_rate(parsed["product"], parsed)
|
||||
count += 1
|
||||
|
||||
return count, f"已同步 {count} 个品种(标准费率 × {multiplier})"
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""从第三方(AKShare)同步交易所参考手续费,并按倍率写入本地表。"""
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
from fee_specs import get_fee_multiplier, upsert_fee_rate
|
||||
|
||||
|
||||
def _to_float(val: Any) -> float:
|
||||
if val is None:
|
||||
return 0.0
|
||||
s = str(val).strip().replace(",", "")
|
||||
if not s or s in ("-", "None", "nan"):
|
||||
return 0.0
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _parse_akshare_row(row: dict, multiplier: float) -> Optional[dict]:
|
||||
code = str(row.get("合约代码") or row.get("代码") or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
m = re.match(r"^([A-Za-z]+)", code)
|
||||
if not m:
|
||||
return None
|
||||
product = m.group(1).lower()
|
||||
|
||||
open_ratio = _to_float(row.get("手续费标准-开仓-万分之")) / 10000.0
|
||||
open_fixed = _to_float(row.get("手续费标准-开仓-元"))
|
||||
if open_fixed == 0 and row.get("开仓"):
|
||||
open_fixed = _to_float(row.get("开仓"))
|
||||
close_y_ratio = _to_float(row.get("手续费标准-平昨-万分之")) / 10000.0
|
||||
close_y_fixed = _to_float(row.get("手续费标准-平昨-元"))
|
||||
if close_y_fixed == 0 and row.get("平昨"):
|
||||
close_y_fixed = _to_float(row.get("平昨"))
|
||||
close_t_ratio = _to_float(row.get("手续费标准-平今-万分之")) / 10000.0
|
||||
close_t_fixed = _to_float(row.get("手续费标准-平今-元"))
|
||||
if close_t_fixed == 0 and row.get("平今"):
|
||||
close_t_fixed = _to_float(row.get("平今"))
|
||||
|
||||
mult = int(get_contract_spec(code)["mult"])
|
||||
exchange = str(row.get("交易所名称") or row.get("交易所") or "").strip()
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"exchange": exchange,
|
||||
"mult": mult,
|
||||
"open_fixed": round(open_fixed * multiplier, 6),
|
||||
"open_ratio": round(open_ratio * multiplier, 8),
|
||||
"close_yesterday_fixed": round(close_y_fixed * multiplier, 6),
|
||||
"close_yesterday_ratio": round(close_y_ratio * multiplier, 8),
|
||||
"close_today_fixed": round(close_t_fixed * multiplier, 6),
|
||||
"close_today_ratio": round(close_t_ratio * multiplier, 8),
|
||||
"source": "akshare",
|
||||
}
|
||||
|
||||
|
||||
def sync_fees_from_akshare(multiplier: Optional[float] = None) -> tuple[int, str]:
|
||||
multiplier = multiplier if multiplier is not None else get_fee_multiplier()
|
||||
try:
|
||||
import akshare as ak
|
||||
except ImportError:
|
||||
return 0, "未安装 akshare,请执行 pip install akshare 后重试,或使用默认费率表"
|
||||
|
||||
try:
|
||||
df = ak.futures_comm_info(symbol="所有")
|
||||
except Exception as exc:
|
||||
return 0, f"拉取第三方数据失败: {exc}"
|
||||
|
||||
if df is None or df.empty:
|
||||
return 0, "第三方返回空数据"
|
||||
|
||||
seen: set[str] = set()
|
||||
count = 0
|
||||
for _, series in df.iterrows():
|
||||
row = series.to_dict()
|
||||
parsed = _parse_akshare_row(row, multiplier)
|
||||
if not parsed or parsed["product"] in seen:
|
||||
continue
|
||||
seen.add(parsed["product"])
|
||||
upsert_fee_rate(parsed["product"], parsed)
|
||||
count += 1
|
||||
|
||||
return count, f"已同步 {count} 个品种(标准费率 × {multiplier})"
|
||||
|
||||
+2267
-2262
File diff suppressed because it is too large
Load Diff
+562
-557
File diff suppressed because it is too large
Load Diff
+175
-170
@@ -1,170 +1,175 @@
|
||||
"""K 线本地 SQLite 缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
REFRESH_SECONDS = {
|
||||
"timeshare": 30,
|
||||
"1m": 30,
|
||||
"2m": 30,
|
||||
"5m": 60,
|
||||
"15m": 60,
|
||||
"1h": 120,
|
||||
"2h": 120,
|
||||
"4h": 180,
|
||||
"d": 300,
|
||||
"w": 600,
|
||||
}
|
||||
|
||||
|
||||
def ensure_kline_tables(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS kline_bars (
|
||||
chart_symbol TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
bar_time TEXT NOT NULL,
|
||||
open REAL NOT NULL,
|
||||
high REAL NOT NULL,
|
||||
low REAL NOT NULL,
|
||||
close REAL NOT NULL,
|
||||
volume REAL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chart_symbol, period, bar_time)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS kline_meta (
|
||||
chart_symbol TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
bar_count INTEGER DEFAULT 0,
|
||||
last_bar_time TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chart_symbol, period)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period "
|
||||
"ON kline_bars(chart_symbol, period, bar_time)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _parse_updated_at(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_cache_fresh(period: str, updated_at: str) -> bool:
|
||||
dt = _parse_updated_at(updated_at)
|
||||
if not dt:
|
||||
return False
|
||||
ttl = REFRESH_SECONDS.get((period or "").lower(), 60)
|
||||
return datetime.now(TZ) - dt < timedelta(seconds=ttl)
|
||||
|
||||
|
||||
def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""SELECT bar_time, open, high, low, close, volume
|
||||
FROM kline_bars
|
||||
WHERE chart_symbol=? AND period=?
|
||||
ORDER BY bar_time ASC""",
|
||||
(chart_symbol, period),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"d": row[0],
|
||||
"o": float(row[1]),
|
||||
"h": float(row[2]),
|
||||
"l": float(row[3]),
|
||||
"c": float(row[4]),
|
||||
"v": float(row[5] or 0),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]:
|
||||
row = conn.execute(
|
||||
"SELECT bar_count, last_bar_time, updated_at FROM kline_meta "
|
||||
"WHERE chart_symbol=? AND period=?",
|
||||
(chart_symbol, period),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"bar_count": row[0],
|
||||
"last_bar_time": row[1],
|
||||
"updated_at": row[2],
|
||||
}
|
||||
|
||||
|
||||
def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int:
|
||||
if not bars:
|
||||
return 0
|
||||
ensure_kline_tables(conn)
|
||||
now = datetime.now(TZ).isoformat(timespec="seconds")
|
||||
for bar in bars:
|
||||
conn.execute(
|
||||
"""INSERT INTO kline_bars
|
||||
(chart_symbol, period, bar_time, open, high, low, close, volume, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET
|
||||
open=excluded.open,
|
||||
high=excluded.high,
|
||||
low=excluded.low,
|
||||
close=excluded.close,
|
||||
volume=excluded.volume,
|
||||
updated_at=excluded.updated_at""",
|
||||
(
|
||||
chart_symbol,
|
||||
period,
|
||||
str(bar["d"]),
|
||||
float(bar["o"]),
|
||||
float(bar["h"]),
|
||||
float(bar["l"]),
|
||||
float(bar["c"]),
|
||||
float(bar.get("v") or 0),
|
||||
now,
|
||||
),
|
||||
)
|
||||
last_time = str(bars[-1]["d"])
|
||||
conn.execute(
|
||||
"""INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(chart_symbol, period) DO UPDATE SET
|
||||
bar_count=excluded.bar_count,
|
||||
last_bar_time=excluded.last_bar_time,
|
||||
updated_at=excluded.updated_at""",
|
||||
(chart_symbol, period, len(bars), last_time, now),
|
||||
)
|
||||
conn.commit()
|
||||
return len(bars)
|
||||
|
||||
|
||||
def get_cached_entry(
|
||||
conn: sqlite3.Connection,
|
||||
chart_symbol: str,
|
||||
period: str,
|
||||
) -> Optional[dict]:
|
||||
if not chart_symbol:
|
||||
return None
|
||||
ensure_kline_tables(conn)
|
||||
meta = load_meta(conn, chart_symbol, period)
|
||||
bars = load_bars(conn, chart_symbol, period)
|
||||
if not bars:
|
||||
return None
|
||||
updated_at = meta["updated_at"] if meta else ""
|
||||
return {
|
||||
"bars": bars,
|
||||
"updated_at": updated_at,
|
||||
"fresh": is_cache_fresh(period, updated_at),
|
||||
}
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""K 线本地 SQLite 缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
REFRESH_SECONDS = {
|
||||
"timeshare": 30,
|
||||
"1m": 30,
|
||||
"2m": 30,
|
||||
"5m": 60,
|
||||
"15m": 60,
|
||||
"1h": 120,
|
||||
"2h": 120,
|
||||
"4h": 180,
|
||||
"d": 300,
|
||||
"w": 600,
|
||||
}
|
||||
|
||||
|
||||
def ensure_kline_tables(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS kline_bars (
|
||||
chart_symbol TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
bar_time TEXT NOT NULL,
|
||||
open REAL NOT NULL,
|
||||
high REAL NOT NULL,
|
||||
low REAL NOT NULL,
|
||||
close REAL NOT NULL,
|
||||
volume REAL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chart_symbol, period, bar_time)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS kline_meta (
|
||||
chart_symbol TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
bar_count INTEGER DEFAULT 0,
|
||||
last_bar_time TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chart_symbol, period)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period "
|
||||
"ON kline_bars(chart_symbol, period, bar_time)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _parse_updated_at(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_cache_fresh(period: str, updated_at: str) -> bool:
|
||||
dt = _parse_updated_at(updated_at)
|
||||
if not dt:
|
||||
return False
|
||||
ttl = REFRESH_SECONDS.get((period or "").lower(), 60)
|
||||
return datetime.now(TZ) - dt < timedelta(seconds=ttl)
|
||||
|
||||
|
||||
def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""SELECT bar_time, open, high, low, close, volume
|
||||
FROM kline_bars
|
||||
WHERE chart_symbol=? AND period=?
|
||||
ORDER BY bar_time ASC""",
|
||||
(chart_symbol, period),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"d": row[0],
|
||||
"o": float(row[1]),
|
||||
"h": float(row[2]),
|
||||
"l": float(row[3]),
|
||||
"c": float(row[4]),
|
||||
"v": float(row[5] or 0),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]:
|
||||
row = conn.execute(
|
||||
"SELECT bar_count, last_bar_time, updated_at FROM kline_meta "
|
||||
"WHERE chart_symbol=? AND period=?",
|
||||
(chart_symbol, period),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"bar_count": row[0],
|
||||
"last_bar_time": row[1],
|
||||
"updated_at": row[2],
|
||||
}
|
||||
|
||||
|
||||
def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int:
|
||||
if not bars:
|
||||
return 0
|
||||
ensure_kline_tables(conn)
|
||||
now = datetime.now(TZ).isoformat(timespec="seconds")
|
||||
for bar in bars:
|
||||
conn.execute(
|
||||
"""INSERT INTO kline_bars
|
||||
(chart_symbol, period, bar_time, open, high, low, close, volume, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET
|
||||
open=excluded.open,
|
||||
high=excluded.high,
|
||||
low=excluded.low,
|
||||
close=excluded.close,
|
||||
volume=excluded.volume,
|
||||
updated_at=excluded.updated_at""",
|
||||
(
|
||||
chart_symbol,
|
||||
period,
|
||||
str(bar["d"]),
|
||||
float(bar["o"]),
|
||||
float(bar["h"]),
|
||||
float(bar["l"]),
|
||||
float(bar["c"]),
|
||||
float(bar.get("v") or 0),
|
||||
now,
|
||||
),
|
||||
)
|
||||
last_time = str(bars[-1]["d"])
|
||||
conn.execute(
|
||||
"""INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(chart_symbol, period) DO UPDATE SET
|
||||
bar_count=excluded.bar_count,
|
||||
last_bar_time=excluded.last_bar_time,
|
||||
updated_at=excluded.updated_at""",
|
||||
(chart_symbol, period, len(bars), last_time, now),
|
||||
)
|
||||
conn.commit()
|
||||
return len(bars)
|
||||
|
||||
|
||||
def get_cached_entry(
|
||||
conn: sqlite3.Connection,
|
||||
chart_symbol: str,
|
||||
period: str,
|
||||
) -> Optional[dict]:
|
||||
if not chart_symbol:
|
||||
return None
|
||||
ensure_kline_tables(conn)
|
||||
meta = load_meta(conn, chart_symbol, period)
|
||||
bars = load_bars(conn, chart_symbol, period)
|
||||
if not bars:
|
||||
return None
|
||||
updated_at = meta["updated_at"] if meta else ""
|
||||
return {
|
||||
"bars": bars,
|
||||
"updated_at": updated_at,
|
||||
"fresh": is_cache_fresh(period, updated_at),
|
||||
}
|
||||
|
||||
+159
-154
@@ -1,154 +1,159 @@
|
||||
"""K 线 SSE 推送与后台刷新。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from kline_chart import fetch_market_klines, ths_to_sina_chart_symbol
|
||||
from kline_store import is_cache_fresh, load_meta, ensure_kline_tables
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
FAST_PERIODS = frozenset({
|
||||
"timeshare", "1m", "2m", "5m", "15m", "1h", "2h", "4h",
|
||||
})
|
||||
|
||||
|
||||
def is_trading_session() -> bool:
|
||||
d = datetime.now(TZ)
|
||||
wd = d.weekday()
|
||||
if wd == 6:
|
||||
return False
|
||||
if wd == 5 and d.hour < 21:
|
||||
return False
|
||||
t = d.hour * 60 + d.minute
|
||||
def in_range(sh: int, sm: int, eh: int, em: int) -> bool:
|
||||
return t >= sh * 60 + sm and t < eh * 60 + em
|
||||
if in_range(9, 0, 11, 30):
|
||||
return True
|
||||
if in_range(13, 30, 15, 0):
|
||||
return True
|
||||
if in_range(21, 0, 24, 0):
|
||||
return True
|
||||
if in_range(0, 0, 2, 30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sse_format(event: str, data: dict) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@dataclass
|
||||
class KlineSubscription:
|
||||
symbol: str
|
||||
period: str
|
||||
market_code: str = ""
|
||||
sina_code: str = ""
|
||||
queue: queue.Queue = field(default_factory=queue.Queue)
|
||||
|
||||
|
||||
class KlineStreamHub:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list[KlineSubscription] = []
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
symbol: str,
|
||||
period: str,
|
||||
market_code: str = "",
|
||||
sina_code: str = "",
|
||||
) -> KlineSubscription:
|
||||
sub = KlineSubscription(
|
||||
symbol=symbol.strip(),
|
||||
period=(period or "15m").strip().lower(),
|
||||
market_code=market_code.strip(),
|
||||
sina_code=sina_code.strip(),
|
||||
)
|
||||
with self._lock:
|
||||
self._subs.append(sub)
|
||||
return sub
|
||||
|
||||
def unsubscribe(self, sub: KlineSubscription) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subs.remove(sub)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _snapshot_subs(self) -> list[KlineSubscription]:
|
||||
with self._lock:
|
||||
return list(self._subs)
|
||||
|
||||
def publish(self, sub: KlineSubscription, event: str, data: dict) -> None:
|
||||
try:
|
||||
sub.queue.put_nowait({"event": event, "data": data})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def _should_refresh(self, sub: KlineSubscription, db_path: str) -> bool:
|
||||
chart_sym = ths_to_sina_chart_symbol(sub.symbol)
|
||||
if not chart_sym:
|
||||
return False
|
||||
if is_trading_session() and sub.period in FAST_PERIODS:
|
||||
return True
|
||||
try:
|
||||
from db_conn import connect_db
|
||||
conn = connect_db(db_path)
|
||||
ensure_kline_tables(conn)
|
||||
meta = load_meta(conn, chart_sym, sub.period)
|
||||
conn.close()
|
||||
if not meta:
|
||||
return True
|
||||
return not is_cache_fresh(sub.period, meta.get("updated_at", ""))
|
||||
except Exception as exc:
|
||||
logger.warning("kline refresh check failed: %s", exc)
|
||||
return True
|
||||
|
||||
def worker_loop(
|
||||
self,
|
||||
db_path: str,
|
||||
quote_fn: Callable[..., dict],
|
||||
get_mode_fn: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
subs = self._snapshot_subs()
|
||||
for sub in subs:
|
||||
if not self._should_refresh(sub, db_path):
|
||||
continue
|
||||
try:
|
||||
kline_data = fetch_market_klines(
|
||||
sub.symbol,
|
||||
sub.period,
|
||||
db_path,
|
||||
force_remote=True,
|
||||
trading_mode=get_mode_fn() if get_mode_fn else None,
|
||||
)
|
||||
if kline_data.get("bars"):
|
||||
self.publish(sub, "kline", kline_data)
|
||||
quote_data = quote_fn(
|
||||
sub.symbol, sub.market_code, sub.sina_code,
|
||||
)
|
||||
if quote_data:
|
||||
self.publish(sub, "quote", quote_data)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"kline stream refresh %s %s: %s",
|
||||
sub.symbol, sub.period, exc,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("kline stream worker: %s", exc)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
kline_hub = KlineStreamHub()
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""K 线 SSE 推送与后台刷新。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from kline_chart import fetch_market_klines, ths_to_sina_chart_symbol
|
||||
from kline_store import is_cache_fresh, load_meta, ensure_kline_tables
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
FAST_PERIODS = frozenset({
|
||||
"timeshare", "1m", "2m", "5m", "15m", "1h", "2h", "4h",
|
||||
})
|
||||
|
||||
|
||||
def is_trading_session() -> bool:
|
||||
d = datetime.now(TZ)
|
||||
wd = d.weekday()
|
||||
if wd == 6:
|
||||
return False
|
||||
if wd == 5 and d.hour < 21:
|
||||
return False
|
||||
t = d.hour * 60 + d.minute
|
||||
def in_range(sh: int, sm: int, eh: int, em: int) -> bool:
|
||||
return t >= sh * 60 + sm and t < eh * 60 + em
|
||||
if in_range(9, 0, 11, 30):
|
||||
return True
|
||||
if in_range(13, 30, 15, 0):
|
||||
return True
|
||||
if in_range(21, 0, 24, 0):
|
||||
return True
|
||||
if in_range(0, 0, 2, 30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sse_format(event: str, data: dict) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@dataclass
|
||||
class KlineSubscription:
|
||||
symbol: str
|
||||
period: str
|
||||
market_code: str = ""
|
||||
sina_code: str = ""
|
||||
queue: queue.Queue = field(default_factory=queue.Queue)
|
||||
|
||||
|
||||
class KlineStreamHub:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list[KlineSubscription] = []
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
symbol: str,
|
||||
period: str,
|
||||
market_code: str = "",
|
||||
sina_code: str = "",
|
||||
) -> KlineSubscription:
|
||||
sub = KlineSubscription(
|
||||
symbol=symbol.strip(),
|
||||
period=(period or "15m").strip().lower(),
|
||||
market_code=market_code.strip(),
|
||||
sina_code=sina_code.strip(),
|
||||
)
|
||||
with self._lock:
|
||||
self._subs.append(sub)
|
||||
return sub
|
||||
|
||||
def unsubscribe(self, sub: KlineSubscription) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subs.remove(sub)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _snapshot_subs(self) -> list[KlineSubscription]:
|
||||
with self._lock:
|
||||
return list(self._subs)
|
||||
|
||||
def publish(self, sub: KlineSubscription, event: str, data: dict) -> None:
|
||||
try:
|
||||
sub.queue.put_nowait({"event": event, "data": data})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def _should_refresh(self, sub: KlineSubscription, db_path: str) -> bool:
|
||||
chart_sym = ths_to_sina_chart_symbol(sub.symbol)
|
||||
if not chart_sym:
|
||||
return False
|
||||
if is_trading_session() and sub.period in FAST_PERIODS:
|
||||
return True
|
||||
try:
|
||||
from db_conn import connect_db
|
||||
conn = connect_db(db_path)
|
||||
ensure_kline_tables(conn)
|
||||
meta = load_meta(conn, chart_sym, sub.period)
|
||||
conn.close()
|
||||
if not meta:
|
||||
return True
|
||||
return not is_cache_fresh(sub.period, meta.get("updated_at", ""))
|
||||
except Exception as exc:
|
||||
logger.warning("kline refresh check failed: %s", exc)
|
||||
return True
|
||||
|
||||
def worker_loop(
|
||||
self,
|
||||
db_path: str,
|
||||
quote_fn: Callable[..., dict],
|
||||
get_mode_fn: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
subs = self._snapshot_subs()
|
||||
for sub in subs:
|
||||
if not self._should_refresh(sub, db_path):
|
||||
continue
|
||||
try:
|
||||
kline_data = fetch_market_klines(
|
||||
sub.symbol,
|
||||
sub.period,
|
||||
db_path,
|
||||
force_remote=True,
|
||||
trading_mode=get_mode_fn() if get_mode_fn else None,
|
||||
)
|
||||
if kline_data.get("bars"):
|
||||
self.publish(sub, "kline", kline_data)
|
||||
quote_data = quote_fn(
|
||||
sub.symbol, sub.market_code, sub.sina_code,
|
||||
)
|
||||
if quote_data:
|
||||
self.publish(sub, "quote", quote_data)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"kline stream refresh %s %s: %s",
|
||||
sub.symbol, sub.period, exc,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("kline stream worker: %s", exc)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
kline_hub = KlineStreamHub()
|
||||
|
||||
+96
-91
@@ -1,91 +1,96 @@
|
||||
"""Linux 上 vnpy_ctp 连接 CTP 前须设置有效 locale(否则 C++ 层 abort)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCALE_DONE = False
|
||||
_LOCALE_NAME = ""
|
||||
|
||||
# CTP C++ API 登录回调依赖中文 locale(见 vnpy/vnpy_ctp#24)
|
||||
_CTP_REQUIRED_LOCALES = ("zh_CN.GB18030", "zh_CN.gb18030")
|
||||
|
||||
|
||||
def _available_locales() -> set[str]:
|
||||
try:
|
||||
out = subprocess.check_output(["locale", "-a"], text=True, stderr=subprocess.DEVNULL)
|
||||
return {line.strip() for line in out.splitlines() if line.strip()}
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return set()
|
||||
|
||||
|
||||
def missing_ctp_locales() -> list[str]:
|
||||
"""CTP 所需的 zh_CN.GB18030 是否已安装。"""
|
||||
avail = {x.lower() for x in _available_locales()}
|
||||
if any(x.lower() in avail for x in _CTP_REQUIRED_LOCALES):
|
||||
return []
|
||||
return ["zh_CN.GB18030"]
|
||||
|
||||
|
||||
def _list_locale_candidates() -> list[str]:
|
||||
avail = _available_locales()
|
||||
names: list[str] = []
|
||||
# CTP 回调优先尝试中文 locale
|
||||
for item in (
|
||||
"zh_CN.GB18030",
|
||||
"zh_CN.gb18030",
|
||||
"zh_CN.UTF-8",
|
||||
"zh_CN.utf8",
|
||||
"en_US.UTF-8",
|
||||
"en_US.utf8",
|
||||
"C.UTF-8",
|
||||
"C.utf8",
|
||||
"POSIX",
|
||||
"C",
|
||||
):
|
||||
if item in avail and item not in names:
|
||||
names.append(item)
|
||||
for loc in sorted(avail):
|
||||
low = loc.lower()
|
||||
if "utf" in low and loc not in names:
|
||||
names.append(loc)
|
||||
return names
|
||||
|
||||
|
||||
def ensure_process_locale() -> str:
|
||||
"""强制设置进程 locale,覆盖系统里无效的旧值。"""
|
||||
global _LOCALE_DONE, _LOCALE_NAME
|
||||
if _LOCALE_DONE:
|
||||
return _LOCALE_NAME
|
||||
|
||||
missing = missing_ctp_locales()
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"CTP 需要中文 locale zh_CN.GB18030,当前系统未安装。"
|
||||
"请执行: sed -i '/^# zh_CN.GB18030/s/^# //' /etc/locale.gen && "
|
||||
"locale-gen zh_CN.GB18030"
|
||||
)
|
||||
|
||||
last_err: locale.Error | None = None
|
||||
for name in _list_locale_candidates():
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, name)
|
||||
os.environ["LANG"] = name
|
||||
os.environ["LC_ALL"] = name
|
||||
os.environ["LC_CTYPE"] = name
|
||||
_LOCALE_DONE = True
|
||||
_LOCALE_NAME = name
|
||||
logger.info("进程 locale 已设置: %s", name)
|
||||
return name
|
||||
except locale.Error as exc:
|
||||
last_err = exc
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"未找到可用 locale,vnpy_ctp 会在 CTP 登录后崩溃。"
|
||||
"请执行: apt install -y locales && locale-gen zh_CN.GB18030 en_US.UTF-8"
|
||||
) from last_err
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""Linux 上 vnpy_ctp 连接 CTP 前须设置有效 locale(否则 C++ 层 abort)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCALE_DONE = False
|
||||
_LOCALE_NAME = ""
|
||||
|
||||
# CTP C++ API 登录回调依赖中文 locale(见 vnpy/vnpy_ctp#24)
|
||||
_CTP_REQUIRED_LOCALES = ("zh_CN.GB18030", "zh_CN.gb18030")
|
||||
|
||||
|
||||
def _available_locales() -> set[str]:
|
||||
try:
|
||||
out = subprocess.check_output(["locale", "-a"], text=True, stderr=subprocess.DEVNULL)
|
||||
return {line.strip() for line in out.splitlines() if line.strip()}
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return set()
|
||||
|
||||
|
||||
def missing_ctp_locales() -> list[str]:
|
||||
"""CTP 所需的 zh_CN.GB18030 是否已安装。"""
|
||||
avail = {x.lower() for x in _available_locales()}
|
||||
if any(x.lower() in avail for x in _CTP_REQUIRED_LOCALES):
|
||||
return []
|
||||
return ["zh_CN.GB18030"]
|
||||
|
||||
|
||||
def _list_locale_candidates() -> list[str]:
|
||||
avail = _available_locales()
|
||||
names: list[str] = []
|
||||
# CTP 回调优先尝试中文 locale
|
||||
for item in (
|
||||
"zh_CN.GB18030",
|
||||
"zh_CN.gb18030",
|
||||
"zh_CN.UTF-8",
|
||||
"zh_CN.utf8",
|
||||
"en_US.UTF-8",
|
||||
"en_US.utf8",
|
||||
"C.UTF-8",
|
||||
"C.utf8",
|
||||
"POSIX",
|
||||
"C",
|
||||
):
|
||||
if item in avail and item not in names:
|
||||
names.append(item)
|
||||
for loc in sorted(avail):
|
||||
low = loc.lower()
|
||||
if "utf" in low and loc not in names:
|
||||
names.append(loc)
|
||||
return names
|
||||
|
||||
|
||||
def ensure_process_locale() -> str:
|
||||
"""强制设置进程 locale,覆盖系统里无效的旧值。"""
|
||||
global _LOCALE_DONE, _LOCALE_NAME
|
||||
if _LOCALE_DONE:
|
||||
return _LOCALE_NAME
|
||||
|
||||
missing = missing_ctp_locales()
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"CTP 需要中文 locale zh_CN.GB18030,当前系统未安装。"
|
||||
"请执行: sed -i '/^# zh_CN.GB18030/s/^# //' /etc/locale.gen && "
|
||||
"locale-gen zh_CN.GB18030"
|
||||
)
|
||||
|
||||
last_err: locale.Error | None = None
|
||||
for name in _list_locale_candidates():
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, name)
|
||||
os.environ["LANG"] = name
|
||||
os.environ["LC_ALL"] = name
|
||||
os.environ["LC_CTYPE"] = name
|
||||
_LOCALE_DONE = True
|
||||
_LOCALE_NAME = name
|
||||
logger.info("进程 locale 已设置: %s", name)
|
||||
return name
|
||||
except locale.Error as exc:
|
||||
last_err = exc
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"未找到可用 locale,vnpy_ctp 会在 CTP 登录后崩溃。"
|
||||
"请执行: apt install -y locales && locale-gen zh_CN.GB18030 en_US.UTF-8"
|
||||
) from last_err
|
||||
|
||||
@@ -1,243 +1,248 @@
|
||||
"""
|
||||
行情拉取:默认新浪(免费,普通用户可用)。
|
||||
同花顺 iFinD HTTP 仅面向机构用户,需单独申请 token,可选开启。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
THS_TOKEN_URL = "https://quantapi.51ifind.com/api/v1/get_access_token"
|
||||
THS_QUOTE_URL = "https://quantapi.51ifind.com/api/v1/real_time_quotation"
|
||||
|
||||
# iFinD HTTP 期货交易所后缀
|
||||
THS_EX_SUFFIX = {
|
||||
"SHFE": "SHFE",
|
||||
"DCE": "DCE",
|
||||
"CZCE": "CZCE",
|
||||
"CFFEX": "CFFEX",
|
||||
"INE": "INE",
|
||||
}
|
||||
|
||||
_token_cache: dict = {"token": "", "expires": 0.0, "refresh": ""}
|
||||
|
||||
|
||||
def _quote_source() -> str:
|
||||
return os.getenv("QUOTE_SOURCE", "sina").strip().lower()
|
||||
|
||||
|
||||
def _has_ths_token() -> bool:
|
||||
return bool(_get_refresh_token())
|
||||
|
||||
|
||||
def get_quote_source_label(*, ctp_connected: bool = False) -> str:
|
||||
"""界面展示用行情源说明。"""
|
||||
if ctp_connected:
|
||||
return "CTP 柜台(已连接)"
|
||||
source = _quote_source()
|
||||
if source == "sina":
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
if source == "ths":
|
||||
return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token)"
|
||||
if _has_ths_token():
|
||||
return "同花顺优先,失败回退新浪"
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
|
||||
|
||||
def _sina_headers() -> dict:
|
||||
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]:
|
||||
try:
|
||||
url = f"https://hq.sinajs.cn/list={sina_code}"
|
||||
resp = requests.get(url, headers=_sina_headers(), timeout=5)
|
||||
resp.encoding = "gbk"
|
||||
if '"' not in resp.text:
|
||||
return None
|
||||
body = resp.text.split('"')[1]
|
||||
if not body:
|
||||
return None
|
||||
parts = body.split(",")
|
||||
return _parse_sina_futures_quote(parts)
|
||||
except Exception as exc:
|
||||
logger.debug("sina fetch failed %s: %s", sina_code, exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_sina_price(sina_code: str) -> Optional[float]:
|
||||
raw = _fetch_sina_raw(sina_code)
|
||||
return raw["price"] if raw else None
|
||||
|
||||
|
||||
_runtime_refresh_token: str = ""
|
||||
|
||||
|
||||
def set_ths_refresh_token(token: str):
|
||||
global _runtime_refresh_token
|
||||
_runtime_refresh_token = (token or "").strip()
|
||||
|
||||
|
||||
def _get_refresh_token() -> str:
|
||||
if _runtime_refresh_token:
|
||||
return _runtime_refresh_token
|
||||
return os.getenv("THS_REFRESH_TOKEN", "").strip()
|
||||
|
||||
|
||||
def _get_ths_access_token(refresh_token: str) -> Optional[str]:
|
||||
if not refresh_token:
|
||||
return None
|
||||
now = time.time()
|
||||
if (
|
||||
_token_cache["token"]
|
||||
and _token_cache["refresh"] == refresh_token
|
||||
and now < _token_cache["expires"]
|
||||
):
|
||||
return _token_cache["token"]
|
||||
try:
|
||||
resp = requests.post(
|
||||
THS_TOKEN_URL,
|
||||
headers={"Content-Type": "application/json", "refresh_token": refresh_token},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errorcode") != 0:
|
||||
logger.warning("THS token error: %s", data.get("errmsg"))
|
||||
return None
|
||||
access = data["data"]["access_token"]
|
||||
_token_cache.update({
|
||||
"token": access,
|
||||
"refresh": refresh_token,
|
||||
"expires": now + 3600 * 6,
|
||||
})
|
||||
return access
|
||||
except Exception as exc:
|
||||
logger.warning("THS token request failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ths_quote(data: dict) -> Optional[float]:
|
||||
"""从同花顺实时行情响应解析最新价。"""
|
||||
try:
|
||||
tables = data.get("tables") or []
|
||||
for table in tables:
|
||||
t = table.get("table") or {}
|
||||
for key in ("latest", "new", "close", "trade", "last"):
|
||||
val = t.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, list) and val:
|
||||
return float(val[0])
|
||||
if isinstance(val, (int, float, str)) and str(val):
|
||||
return float(val)
|
||||
# 部分响应嵌套在 data 字段
|
||||
if "data" in data and isinstance(data["data"], dict):
|
||||
return _parse_ths_quote(data["data"])
|
||||
except Exception as exc:
|
||||
logger.debug("parse ths quote failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_ths_price(ths_full_code: str, refresh_token: str = "") -> Optional[float]:
|
||||
"""ths_full_code 如 ag2608.SHFE、IF2606.CFFEX"""
|
||||
token = refresh_token or _get_refresh_token()
|
||||
access = _get_ths_access_token(token)
|
||||
if not access:
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
THS_QUOTE_URL,
|
||||
headers={"Content-Type": "application/json", "access_token": access},
|
||||
json={"codes": ths_full_code, "indicators": "latest"},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errorcode") != 0:
|
||||
logger.warning("THS quote error %s: %s", ths_full_code, data.get("errmsg"))
|
||||
return None
|
||||
return _parse_ths_quote(data)
|
||||
except Exception as exc:
|
||||
logger.warning("THS quote failed %s: %s", ths_full_code, exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_price(market_code: str, sina_fallback: str = "") -> Optional[float]:
|
||||
"""
|
||||
统一取价入口。
|
||||
sina_fallback: 新浪代码 nf_AG2608(普通用户默认使用)
|
||||
market_code: 同花顺完整代码 ag2608.SHFE(仅机构 token 可用时)
|
||||
"""
|
||||
source = _quote_source()
|
||||
|
||||
# 仅在有 token 且配置为 ths/auto 时才尝试同花顺
|
||||
use_ths = source == "ths" or (source == "auto" and _has_ths_token())
|
||||
if use_ths and market_code and "." in market_code:
|
||||
price = get_ths_price(market_code)
|
||||
if price is not None:
|
||||
return price
|
||||
if source == "ths":
|
||||
return None
|
||||
|
||||
if sina_fallback:
|
||||
return get_sina_price(sina_fallback)
|
||||
|
||||
if market_code.startswith("nf_") or market_code.startswith("CFF_RE_"):
|
||||
return get_sina_price(market_code)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def fetch_raw_for_volume(sina_code: str) -> Optional[dict]:
|
||||
"""主力合约扫描用(成交量),走新浪。"""
|
||||
return _fetch_sina_raw(sina_code)
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""
|
||||
行情拉取:默认新浪(免费,普通用户可用)。
|
||||
同花顺 iFinD HTTP 仅面向机构用户,需单独申请 token,可选开启。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
THS_TOKEN_URL = "https://quantapi.51ifind.com/api/v1/get_access_token"
|
||||
THS_QUOTE_URL = "https://quantapi.51ifind.com/api/v1/real_time_quotation"
|
||||
|
||||
# iFinD HTTP 期货交易所后缀
|
||||
THS_EX_SUFFIX = {
|
||||
"SHFE": "SHFE",
|
||||
"DCE": "DCE",
|
||||
"CZCE": "CZCE",
|
||||
"CFFEX": "CFFEX",
|
||||
"INE": "INE",
|
||||
}
|
||||
|
||||
_token_cache: dict = {"token": "", "expires": 0.0, "refresh": ""}
|
||||
|
||||
|
||||
def _quote_source() -> str:
|
||||
return os.getenv("QUOTE_SOURCE", "sina").strip().lower()
|
||||
|
||||
|
||||
def _has_ths_token() -> bool:
|
||||
return bool(_get_refresh_token())
|
||||
|
||||
|
||||
def get_quote_source_label(*, ctp_connected: bool = False) -> str:
|
||||
"""界面展示用行情源说明。"""
|
||||
if ctp_connected:
|
||||
return "CTP 柜台(已连接)"
|
||||
source = _quote_source()
|
||||
if source == "sina":
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
if source == "ths":
|
||||
return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token)"
|
||||
if _has_ths_token():
|
||||
return "同花顺优先,失败回退新浪"
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
|
||||
|
||||
def _sina_headers() -> dict:
|
||||
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]:
|
||||
try:
|
||||
url = f"https://hq.sinajs.cn/list={sina_code}"
|
||||
resp = requests.get(url, headers=_sina_headers(), timeout=5)
|
||||
resp.encoding = "gbk"
|
||||
if '"' not in resp.text:
|
||||
return None
|
||||
body = resp.text.split('"')[1]
|
||||
if not body:
|
||||
return None
|
||||
parts = body.split(",")
|
||||
return _parse_sina_futures_quote(parts)
|
||||
except Exception as exc:
|
||||
logger.debug("sina fetch failed %s: %s", sina_code, exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_sina_price(sina_code: str) -> Optional[float]:
|
||||
raw = _fetch_sina_raw(sina_code)
|
||||
return raw["price"] if raw else None
|
||||
|
||||
|
||||
_runtime_refresh_token: str = ""
|
||||
|
||||
|
||||
def set_ths_refresh_token(token: str):
|
||||
global _runtime_refresh_token
|
||||
_runtime_refresh_token = (token or "").strip()
|
||||
|
||||
|
||||
def _get_refresh_token() -> str:
|
||||
if _runtime_refresh_token:
|
||||
return _runtime_refresh_token
|
||||
return os.getenv("THS_REFRESH_TOKEN", "").strip()
|
||||
|
||||
|
||||
def _get_ths_access_token(refresh_token: str) -> Optional[str]:
|
||||
if not refresh_token:
|
||||
return None
|
||||
now = time.time()
|
||||
if (
|
||||
_token_cache["token"]
|
||||
and _token_cache["refresh"] == refresh_token
|
||||
and now < _token_cache["expires"]
|
||||
):
|
||||
return _token_cache["token"]
|
||||
try:
|
||||
resp = requests.post(
|
||||
THS_TOKEN_URL,
|
||||
headers={"Content-Type": "application/json", "refresh_token": refresh_token},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errorcode") != 0:
|
||||
logger.warning("THS token error: %s", data.get("errmsg"))
|
||||
return None
|
||||
access = data["data"]["access_token"]
|
||||
_token_cache.update({
|
||||
"token": access,
|
||||
"refresh": refresh_token,
|
||||
"expires": now + 3600 * 6,
|
||||
})
|
||||
return access
|
||||
except Exception as exc:
|
||||
logger.warning("THS token request failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ths_quote(data: dict) -> Optional[float]:
|
||||
"""从同花顺实时行情响应解析最新价。"""
|
||||
try:
|
||||
tables = data.get("tables") or []
|
||||
for table in tables:
|
||||
t = table.get("table") or {}
|
||||
for key in ("latest", "new", "close", "trade", "last"):
|
||||
val = t.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, list) and val:
|
||||
return float(val[0])
|
||||
if isinstance(val, (int, float, str)) and str(val):
|
||||
return float(val)
|
||||
# 部分响应嵌套在 data 字段
|
||||
if "data" in data and isinstance(data["data"], dict):
|
||||
return _parse_ths_quote(data["data"])
|
||||
except Exception as exc:
|
||||
logger.debug("parse ths quote failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_ths_price(ths_full_code: str, refresh_token: str = "") -> Optional[float]:
|
||||
"""ths_full_code 如 ag2608.SHFE、IF2606.CFFEX"""
|
||||
token = refresh_token or _get_refresh_token()
|
||||
access = _get_ths_access_token(token)
|
||||
if not access:
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
THS_QUOTE_URL,
|
||||
headers={"Content-Type": "application/json", "access_token": access},
|
||||
json={"codes": ths_full_code, "indicators": "latest"},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errorcode") != 0:
|
||||
logger.warning("THS quote error %s: %s", ths_full_code, data.get("errmsg"))
|
||||
return None
|
||||
return _parse_ths_quote(data)
|
||||
except Exception as exc:
|
||||
logger.warning("THS quote failed %s: %s", ths_full_code, exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_price(market_code: str, sina_fallback: str = "") -> Optional[float]:
|
||||
"""
|
||||
统一取价入口。
|
||||
sina_fallback: 新浪代码 nf_AG2608(普通用户默认使用)
|
||||
market_code: 同花顺完整代码 ag2608.SHFE(仅机构 token 可用时)
|
||||
"""
|
||||
source = _quote_source()
|
||||
|
||||
# 仅在有 token 且配置为 ths/auto 时才尝试同花顺
|
||||
use_ths = source == "ths" or (source == "auto" and _has_ths_token())
|
||||
if use_ths and market_code and "." in market_code:
|
||||
price = get_ths_price(market_code)
|
||||
if price is not None:
|
||||
return price
|
||||
if source == "ths":
|
||||
return None
|
||||
|
||||
if sina_fallback:
|
||||
return get_sina_price(sina_fallback)
|
||||
|
||||
if market_code.startswith("nf_") or market_code.startswith("CFF_RE_"):
|
||||
return get_sina_price(market_code)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def fetch_raw_for_volume(sina_code: str) -> Optional[dict]:
|
||||
"""主力合约扫描用(成交量),走新浪。"""
|
||||
return _fetch_sina_raw(sina_code)
|
||||
|
||||
+107
-102
@@ -1,102 +1,107 @@
|
||||
"""国内期货交易时段与盘前连接窗口。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# 各交易段开盘时刻 (时, 分)
|
||||
SESSION_OPENS = (
|
||||
(9, 0),
|
||||
(13, 30),
|
||||
(21, 0),
|
||||
)
|
||||
|
||||
|
||||
def is_trading_session(now: Optional[datetime] = None) -> bool:
|
||||
d = now or datetime.now(TZ)
|
||||
if d.tzinfo is None:
|
||||
d = d.replace(tzinfo=TZ)
|
||||
else:
|
||||
d = d.astimezone(TZ)
|
||||
wd = d.weekday()
|
||||
if wd == 6:
|
||||
return False
|
||||
if wd == 5 and d.hour < 21:
|
||||
return False
|
||||
t = d.hour * 60 + d.minute
|
||||
def in_range(sh: int, sm: int, eh: int, em: int) -> bool:
|
||||
return t >= sh * 60 + sm and t < eh * 60 + em
|
||||
if in_range(9, 0, 11, 30):
|
||||
return True
|
||||
if in_range(13, 30, 15, 0):
|
||||
return True
|
||||
if in_range(21, 0, 24, 0):
|
||||
return True
|
||||
if in_range(0, 0, 2, 30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _session_open_allowed(day: datetime, hour: int, minute: int) -> bool:
|
||||
wd = day.weekday()
|
||||
if (hour, minute) == (9, 0) or (hour, minute) == (13, 30):
|
||||
return wd < 5
|
||||
if (hour, minute) == (21, 0):
|
||||
if wd < 5:
|
||||
return True
|
||||
return wd == 5
|
||||
return False
|
||||
|
||||
|
||||
def iter_session_starts(
|
||||
start: datetime,
|
||||
*,
|
||||
hours_ahead: int = 36,
|
||||
) -> list[datetime]:
|
||||
"""列出 start 之后若干小时内的各段开盘时刻。"""
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=TZ)
|
||||
else:
|
||||
start = start.astimezone(TZ)
|
||||
end = start + timedelta(hours=hours_ahead)
|
||||
out: list[datetime] = []
|
||||
day = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
while day <= end:
|
||||
for h, m in SESSION_OPENS:
|
||||
if not _session_open_allowed(day, h, m):
|
||||
continue
|
||||
dt = day.replace(hour=h, minute=m)
|
||||
if dt > start and dt <= end:
|
||||
out.append(dt)
|
||||
day += timedelta(days=1)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def minutes_until_next_session(now: Optional[datetime] = None) -> Optional[float]:
|
||||
d = now or datetime.now(TZ)
|
||||
if d.tzinfo is None:
|
||||
d = d.replace(tzinfo=TZ)
|
||||
else:
|
||||
d = d.astimezone(TZ)
|
||||
starts = iter_session_starts(d, hours_ahead=48)
|
||||
if not starts:
|
||||
return None
|
||||
return (starts[0] - d).total_seconds() / 60.0
|
||||
|
||||
|
||||
def in_premarket_connect_window(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
minutes_before: int = 30,
|
||||
) -> bool:
|
||||
"""距下一段开盘 <= minutes_before 分钟,且当前尚未进入交易时段。"""
|
||||
if is_trading_session(now):
|
||||
return False
|
||||
mins = minutes_until_next_session(now)
|
||||
if mins is None:
|
||||
return False
|
||||
return 0 < mins <= float(minutes_before)
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""国内期货交易时段与盘前连接窗口。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# 各交易段开盘时刻 (时, 分)
|
||||
SESSION_OPENS = (
|
||||
(9, 0),
|
||||
(13, 30),
|
||||
(21, 0),
|
||||
)
|
||||
|
||||
|
||||
def is_trading_session(now: Optional[datetime] = None) -> bool:
|
||||
d = now or datetime.now(TZ)
|
||||
if d.tzinfo is None:
|
||||
d = d.replace(tzinfo=TZ)
|
||||
else:
|
||||
d = d.astimezone(TZ)
|
||||
wd = d.weekday()
|
||||
if wd == 6:
|
||||
return False
|
||||
if wd == 5 and d.hour < 21:
|
||||
return False
|
||||
t = d.hour * 60 + d.minute
|
||||
def in_range(sh: int, sm: int, eh: int, em: int) -> bool:
|
||||
return t >= sh * 60 + sm and t < eh * 60 + em
|
||||
if in_range(9, 0, 11, 30):
|
||||
return True
|
||||
if in_range(13, 30, 15, 0):
|
||||
return True
|
||||
if in_range(21, 0, 24, 0):
|
||||
return True
|
||||
if in_range(0, 0, 2, 30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _session_open_allowed(day: datetime, hour: int, minute: int) -> bool:
|
||||
wd = day.weekday()
|
||||
if (hour, minute) == (9, 0) or (hour, minute) == (13, 30):
|
||||
return wd < 5
|
||||
if (hour, minute) == (21, 0):
|
||||
if wd < 5:
|
||||
return True
|
||||
return wd == 5
|
||||
return False
|
||||
|
||||
|
||||
def iter_session_starts(
|
||||
start: datetime,
|
||||
*,
|
||||
hours_ahead: int = 36,
|
||||
) -> list[datetime]:
|
||||
"""列出 start 之后若干小时内的各段开盘时刻。"""
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=TZ)
|
||||
else:
|
||||
start = start.astimezone(TZ)
|
||||
end = start + timedelta(hours=hours_ahead)
|
||||
out: list[datetime] = []
|
||||
day = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
while day <= end:
|
||||
for h, m in SESSION_OPENS:
|
||||
if not _session_open_allowed(day, h, m):
|
||||
continue
|
||||
dt = day.replace(hour=h, minute=m)
|
||||
if dt > start and dt <= end:
|
||||
out.append(dt)
|
||||
day += timedelta(days=1)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def minutes_until_next_session(now: Optional[datetime] = None) -> Optional[float]:
|
||||
d = now or datetime.now(TZ)
|
||||
if d.tzinfo is None:
|
||||
d = d.replace(tzinfo=TZ)
|
||||
else:
|
||||
d = d.astimezone(TZ)
|
||||
starts = iter_session_starts(d, hours_ahead=48)
|
||||
if not starts:
|
||||
return None
|
||||
return (starts[0] - d).total_seconds() / 60.0
|
||||
|
||||
|
||||
def in_premarket_connect_window(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
minutes_before: int = 30,
|
||||
) -> bool:
|
||||
"""距下一段开盘 <= minutes_before 分钟,且当前尚未进入交易时段。"""
|
||||
if is_trading_session(now):
|
||||
return False
|
||||
mins = minutes_until_next_session(now)
|
||||
if mins is None:
|
||||
return False
|
||||
return 0 < mins <= float(minutes_before)
|
||||
|
||||
+51
-46
@@ -1,46 +1,51 @@
|
||||
"""顶栏导航项显示开关(系统设置)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
# 可在系统设置中开关的导航项
|
||||
NAV_TOGGLES: dict[str, str] = {
|
||||
"fees": "手续费配置",
|
||||
"contract": "品种简介",
|
||||
"plans": "开单计划",
|
||||
"market": "行情K线",
|
||||
"strategy": "策略交易",
|
||||
}
|
||||
|
||||
DEFAULT_NAV: dict[str, bool] = {k: True for k in NAV_TOGGLES}
|
||||
|
||||
|
||||
def get_nav_items(get_setting: Callable[[str, str], str]) -> dict[str, bool]:
|
||||
raw = (get_setting("nav_items", "") or "").strip()
|
||||
out = dict(DEFAULT_NAV)
|
||||
if not raw:
|
||||
return out
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
for k in NAV_TOGGLES:
|
||||
if k in data:
|
||||
out[k] = bool(data[k])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def save_nav_items(set_setting: Callable[[str, str], None], items: dict[str, bool]) -> None:
|
||||
merged = dict(DEFAULT_NAV)
|
||||
for k in NAV_TOGGLES:
|
||||
if k in items:
|
||||
merged[k] = bool(items[k])
|
||||
set_setting("nav_items", json.dumps(merged, ensure_ascii=False))
|
||||
|
||||
|
||||
def nav_enabled(get_setting: Callable[[str, str], str], key: str) -> bool:
|
||||
if key not in NAV_TOGGLES:
|
||||
return True
|
||||
return get_nav_items(get_setting).get(key, True)
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""顶栏导航项显示开关(系统设置)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
# 可在系统设置中开关的导航项
|
||||
NAV_TOGGLES: dict[str, str] = {
|
||||
"fees": "手续费配置",
|
||||
"contract": "品种简介",
|
||||
"plans": "开单计划",
|
||||
"market": "行情K线",
|
||||
"strategy": "策略交易",
|
||||
}
|
||||
|
||||
DEFAULT_NAV: dict[str, bool] = {k: True for k in NAV_TOGGLES}
|
||||
|
||||
|
||||
def get_nav_items(get_setting: Callable[[str, str], str]) -> dict[str, bool]:
|
||||
raw = (get_setting("nav_items", "") or "").strip()
|
||||
out = dict(DEFAULT_NAV)
|
||||
if not raw:
|
||||
return out
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
for k in NAV_TOGGLES:
|
||||
if k in data:
|
||||
out[k] = bool(data[k])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def save_nav_items(set_setting: Callable[[str, str], None], items: dict[str, bool]) -> None:
|
||||
merged = dict(DEFAULT_NAV)
|
||||
for k in NAV_TOGGLES:
|
||||
if k in items:
|
||||
merged[k] = bool(items[k])
|
||||
set_setting("nav_items", json.dumps(merged, ensure_ascii=False))
|
||||
|
||||
|
||||
def nav_enabled(get_setting: Callable[[str, str], str], key: str) -> bool:
|
||||
if key not in NAV_TOGGLES:
|
||||
return True
|
||||
return get_nav_items(get_setting).get(key, True)
|
||||
|
||||
+180
-175
@@ -1,175 +1,180 @@
|
||||
"""开仓委托:pending 状态跟踪、成交转正、超时撤单。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from market_sessions import is_trading_session
|
||||
from vnpy_bridge import ctp_cancel_order, ctp_list_active_orders, ctp_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
DEFAULT_PENDING_ORDER_TIMEOUT_SEC = 300
|
||||
|
||||
|
||||
def parse_monitor_ts(raw: str) -> Optional[float]:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
|
||||
try:
|
||||
return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def pending_age_sec(mon: dict) -> float:
|
||||
ts = parse_monitor_ts(mon.get("open_time") or "") or parse_monitor_ts(
|
||||
str(mon.get("created_at") or "")
|
||||
)
|
||||
if ts is None:
|
||||
return 0.0
|
||||
return max(0.0, time.time() - ts)
|
||||
|
||||
|
||||
def pending_auto_cancel_remaining(
|
||||
mon: dict,
|
||||
*,
|
||||
timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC,
|
||||
) -> int:
|
||||
limit = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC))
|
||||
return max(0, int(limit - pending_age_sec(mon)))
|
||||
|
||||
|
||||
def _match_symbol(ctp_sym: str, ths: str) -> bool:
|
||||
a = (ctp_sym or "").lower()
|
||||
b = (ths or "").lower()
|
||||
if a == b:
|
||||
return True
|
||||
if a and b and a.split(".")[0] == b.split(".")[0]:
|
||||
return True
|
||||
try:
|
||||
from ctp_symbol import ths_to_vnpy_symbol
|
||||
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
|
||||
if a == vnpy_sym.lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _find_ctp_position(positions: list[dict], sym: str, direction: str) -> Optional[dict]:
|
||||
direction = (direction or "long").strip().lower()
|
||||
for p in positions or []:
|
||||
if int(p.get("lots") or 0) <= 0:
|
||||
continue
|
||||
if (p.get("direction") or "long") != direction:
|
||||
continue
|
||||
if _match_symbol(p.get("symbol") or "", sym):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def reconcile_pending_orders(
|
||||
conn,
|
||||
mode: str,
|
||||
*,
|
||||
match_symbol_fn: Callable[[str, str], bool] | None = None,
|
||||
sync_monitor_fn: Callable[..., None] | None = None,
|
||||
capital: float = 0.0,
|
||||
list_positions_fn: Callable[..., list] | None = None,
|
||||
timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC,
|
||||
) -> dict[str, int]:
|
||||
"""同步 pending 委托:成交→active;超时/已撤→closed。"""
|
||||
limit_sec = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC))
|
||||
stats = {"promoted": 0, "cancelled": 0, "closed": 0}
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return stats
|
||||
|
||||
match = match_symbol_fn or _match_symbol
|
||||
positions = (
|
||||
list_positions_fn(mode, refresh_if_empty=False, refresh_margin=False)
|
||||
if list_positions_fn
|
||||
else []
|
||||
)
|
||||
try:
|
||||
active_orders = {
|
||||
str(o.get("order_id") or ""): o
|
||||
for o in ctp_list_active_orders(mode)
|
||||
if o.get("order_id")
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("list active orders: %s", exc)
|
||||
active_orders = {}
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id ASC"
|
||||
).fetchall()
|
||||
|
||||
for r in rows:
|
||||
mon = dict(r)
|
||||
mid = int(mon["id"])
|
||||
sym = mon.get("symbol") or ""
|
||||
direction = mon.get("direction") or "long"
|
||||
vt_oid = (mon.get("vt_order_id") or "").strip()
|
||||
age = pending_age_sec(mon)
|
||||
|
||||
pos = _find_ctp_position(positions, sym, direction)
|
||||
if pos:
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET status='active' WHERE id=?",
|
||||
(mid,),
|
||||
)
|
||||
if sync_monitor_fn:
|
||||
sync_monitor_fn(
|
||||
conn, mid, sym, direction, mode, ctp=pos, capital=capital,
|
||||
)
|
||||
stats["promoted"] += 1
|
||||
continue
|
||||
|
||||
if vt_oid and vt_oid in active_orders:
|
||||
if age >= limit_sec and is_trading_session():
|
||||
if ctp_cancel_order(mode, vt_oid):
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
|
||||
(mid,),
|
||||
)
|
||||
stats["cancelled"] += 1
|
||||
else:
|
||||
logger.warning("pending auto-cancel failed monitor=%s order=%s", mid, vt_oid)
|
||||
continue
|
||||
|
||||
# 委托已不在活跃列表且无持仓:拒单/撤单/过期
|
||||
if age >= 8:
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
|
||||
(mid,),
|
||||
)
|
||||
stats["closed"] += 1
|
||||
|
||||
if any(stats.values()):
|
||||
conn.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def cancel_pending_monitor(
|
||||
conn,
|
||||
mon: dict,
|
||||
mode: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""手动撤销 pending 开仓委托。"""
|
||||
mid = int(mon.get("id") or 0)
|
||||
vt_oid = (mon.get("vt_order_id") or "").strip()
|
||||
if vt_oid and ctp_status(mode).get("connected"):
|
||||
try:
|
||||
ctp_cancel_order(mode, vt_oid)
|
||||
except Exception as exc:
|
||||
logger.warning("cancel pending order monitor=%s: %s", mid, exc)
|
||||
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mid,))
|
||||
conn.commit()
|
||||
return True, "开仓委托已撤销"
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""开仓委托:pending 状态跟踪、成交转正、超时撤单。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from market_sessions import is_trading_session
|
||||
from vnpy_bridge import ctp_cancel_order, ctp_list_active_orders, ctp_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
DEFAULT_PENDING_ORDER_TIMEOUT_SEC = 300
|
||||
|
||||
|
||||
def parse_monitor_ts(raw: str) -> Optional[float]:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
|
||||
try:
|
||||
return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def pending_age_sec(mon: dict) -> float:
|
||||
ts = parse_monitor_ts(mon.get("open_time") or "") or parse_monitor_ts(
|
||||
str(mon.get("created_at") or "")
|
||||
)
|
||||
if ts is None:
|
||||
return 0.0
|
||||
return max(0.0, time.time() - ts)
|
||||
|
||||
|
||||
def pending_auto_cancel_remaining(
|
||||
mon: dict,
|
||||
*,
|
||||
timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC,
|
||||
) -> int:
|
||||
limit = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC))
|
||||
return max(0, int(limit - pending_age_sec(mon)))
|
||||
|
||||
|
||||
def _match_symbol(ctp_sym: str, ths: str) -> bool:
|
||||
a = (ctp_sym or "").lower()
|
||||
b = (ths or "").lower()
|
||||
if a == b:
|
||||
return True
|
||||
if a and b and a.split(".")[0] == b.split(".")[0]:
|
||||
return True
|
||||
try:
|
||||
from ctp_symbol import ths_to_vnpy_symbol
|
||||
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
|
||||
if a == vnpy_sym.lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _find_ctp_position(positions: list[dict], sym: str, direction: str) -> Optional[dict]:
|
||||
direction = (direction or "long").strip().lower()
|
||||
for p in positions or []:
|
||||
if int(p.get("lots") or 0) <= 0:
|
||||
continue
|
||||
if (p.get("direction") or "long") != direction:
|
||||
continue
|
||||
if _match_symbol(p.get("symbol") or "", sym):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def reconcile_pending_orders(
|
||||
conn,
|
||||
mode: str,
|
||||
*,
|
||||
match_symbol_fn: Callable[[str, str], bool] | None = None,
|
||||
sync_monitor_fn: Callable[..., None] | None = None,
|
||||
capital: float = 0.0,
|
||||
list_positions_fn: Callable[..., list] | None = None,
|
||||
timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC,
|
||||
) -> dict[str, int]:
|
||||
"""同步 pending 委托:成交→active;超时/已撤→closed。"""
|
||||
limit_sec = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC))
|
||||
stats = {"promoted": 0, "cancelled": 0, "closed": 0}
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return stats
|
||||
|
||||
match = match_symbol_fn or _match_symbol
|
||||
positions = (
|
||||
list_positions_fn(mode, refresh_if_empty=False, refresh_margin=False)
|
||||
if list_positions_fn
|
||||
else []
|
||||
)
|
||||
try:
|
||||
active_orders = {
|
||||
str(o.get("order_id") or ""): o
|
||||
for o in ctp_list_active_orders(mode)
|
||||
if o.get("order_id")
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("list active orders: %s", exc)
|
||||
active_orders = {}
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id ASC"
|
||||
).fetchall()
|
||||
|
||||
for r in rows:
|
||||
mon = dict(r)
|
||||
mid = int(mon["id"])
|
||||
sym = mon.get("symbol") or ""
|
||||
direction = mon.get("direction") or "long"
|
||||
vt_oid = (mon.get("vt_order_id") or "").strip()
|
||||
age = pending_age_sec(mon)
|
||||
|
||||
pos = _find_ctp_position(positions, sym, direction)
|
||||
if pos:
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET status='active' WHERE id=?",
|
||||
(mid,),
|
||||
)
|
||||
if sync_monitor_fn:
|
||||
sync_monitor_fn(
|
||||
conn, mid, sym, direction, mode, ctp=pos, capital=capital,
|
||||
)
|
||||
stats["promoted"] += 1
|
||||
continue
|
||||
|
||||
if vt_oid and vt_oid in active_orders:
|
||||
if age >= limit_sec and is_trading_session():
|
||||
if ctp_cancel_order(mode, vt_oid):
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
|
||||
(mid,),
|
||||
)
|
||||
stats["cancelled"] += 1
|
||||
else:
|
||||
logger.warning("pending auto-cancel failed monitor=%s order=%s", mid, vt_oid)
|
||||
continue
|
||||
|
||||
# 委托已不在活跃列表且无持仓:拒单/撤单/过期
|
||||
if age >= 8:
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
|
||||
(mid,),
|
||||
)
|
||||
stats["closed"] += 1
|
||||
|
||||
if any(stats.values()):
|
||||
conn.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def cancel_pending_monitor(
|
||||
conn,
|
||||
mon: dict,
|
||||
mode: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""手动撤销 pending 开仓委托。"""
|
||||
mid = int(mon.get("id") or 0)
|
||||
vt_oid = (mon.get("vt_order_id") or "").strip()
|
||||
if vt_oid and ctp_status(mode).get("connected"):
|
||||
try:
|
||||
ctp_cancel_order(mode, vt_oid)
|
||||
except Exception as exc:
|
||||
logger.warning("cancel pending order monitor=%s: %s", mid, exc)
|
||||
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mid,))
|
||||
conn.commit()
|
||||
return True, "开仓委托已撤销"
|
||||
|
||||
+171
-166
@@ -1,166 +1,171 @@
|
||||
"""期货计仓:固定手数 / 固定金额。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
|
||||
MODE_FIXED = "fixed"
|
||||
MODE_AMOUNT = "amount"
|
||||
MODE_RISK = "amount" # 兼容旧配置「以损定仓」
|
||||
|
||||
DEFAULT_MAX_ORDER_LOTS = 50
|
||||
|
||||
|
||||
def normalize_sizing_mode(raw: str) -> str:
|
||||
m = (raw or MODE_FIXED).strip().lower()
|
||||
if m == "risk":
|
||||
m = MODE_AMOUNT
|
||||
return m if m in (MODE_FIXED, MODE_AMOUNT) else MODE_FIXED
|
||||
|
||||
|
||||
def price_precision_from_tick(tick_size: float) -> int:
|
||||
if tick_size <= 0:
|
||||
return 0
|
||||
s = f"{tick_size:.10f}".rstrip("0").rstrip(".")
|
||||
if "." not in s:
|
||||
return 0
|
||||
return len(s.split(".")[1])
|
||||
|
||||
|
||||
def _per_lot_risk(entry: float, stop_loss: float, direction: str, ths_code: str) -> tuple[float, Optional[str]]:
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec["mult"]
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
per_lot = (stop_loss - entry) * mult
|
||||
else:
|
||||
per_lot = (entry - stop_loss) * mult
|
||||
if per_lot <= 0:
|
||||
return 0.0, "止损方向与入场价不匹配"
|
||||
return per_lot, None
|
||||
|
||||
|
||||
def calc_lots_by_amount(
|
||||
entry: float,
|
||||
stop_loss: float,
|
||||
direction: str,
|
||||
amount: float,
|
||||
ths_code: str,
|
||||
*,
|
||||
capital: float = 0.0,
|
||||
max_lots: Optional[int] = None,
|
||||
max_margin_pct: float = 30.0,
|
||||
) -> tuple[Optional[int], Optional[str]]:
|
||||
"""固定金额:按止损距离将金额换算为手数。"""
|
||||
try:
|
||||
entry_f = float(entry)
|
||||
sl_f = float(stop_loss)
|
||||
budget = float(amount)
|
||||
cap = float(capital or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if entry_f <= 0 or budget <= 0:
|
||||
return None, "入场价或固定金额无效"
|
||||
per_lot_risk, err = _per_lot_risk(entry_f, sl_f, direction, ths_code)
|
||||
if err:
|
||||
return None, err
|
||||
lots = int(math.floor(budget / per_lot_risk))
|
||||
if lots < 1:
|
||||
return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手"
|
||||
if cap > 0:
|
||||
spec = get_contract_spec(ths_code)
|
||||
margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"]
|
||||
margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||
max_by_margin = (
|
||||
int(math.floor(cap * margin_cap / 100.0 / margin_per_lot))
|
||||
if margin_per_lot > 0 else lots
|
||||
)
|
||||
if max_by_margin < 1:
|
||||
return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手"
|
||||
lots = min(lots, max_by_margin)
|
||||
cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS
|
||||
lots = min(lots, cap_lots)
|
||||
return lots, None
|
||||
|
||||
|
||||
def calc_lots_by_risk(
|
||||
entry: float,
|
||||
stop_loss: float,
|
||||
direction: str,
|
||||
capital: float,
|
||||
risk_percent: float,
|
||||
ths_code: str,
|
||||
*,
|
||||
max_lots: Optional[int] = None,
|
||||
max_margin_pct: float = 30.0,
|
||||
) -> tuple[Optional[int], Optional[str]]:
|
||||
"""策略等场景:按权益百分比风险预算换算手数。"""
|
||||
try:
|
||||
cap = float(capital)
|
||||
rp = float(risk_percent)
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if cap <= 0 or rp <= 0:
|
||||
return None, "资金或风险比例无效"
|
||||
budget = cap * rp / 100.0
|
||||
return calc_lots_by_amount(
|
||||
entry, stop_loss, direction, budget, ths_code,
|
||||
capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct,
|
||||
)
|
||||
|
||||
|
||||
def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = None) -> dict:
|
||||
"""下单区展示:最小变动价位、每跳盈亏、保证金等。"""
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = int(spec["mult"])
|
||||
tick = float(spec.get("tick_size") or 1.0)
|
||||
margin_rate = float(spec["margin_rate"])
|
||||
lots_i = max(1, int(lots or 1))
|
||||
tick_value_per_lot = round(tick * mult, 4)
|
||||
tick_value_total = round(tick_value_per_lot * lots_i, 2)
|
||||
prec = price_precision_from_tick(tick)
|
||||
mark = float(price) if price else 0.0
|
||||
margin_per_lot = round(mark * mult * margin_rate, 2) if mark > 0 else None
|
||||
margin_total = round(margin_per_lot * lots_i, 2) if margin_per_lot else None
|
||||
return {
|
||||
"mult": mult,
|
||||
"tick_size": tick,
|
||||
"price_precision": prec,
|
||||
"tick_value_per_lot": tick_value_per_lot,
|
||||
"tick_value_total": tick_value_total,
|
||||
"lots": lots_i,
|
||||
"margin_per_lot": margin_per_lot,
|
||||
"margin_total": margin_total,
|
||||
"margin_rate": margin_rate,
|
||||
}
|
||||
|
||||
|
||||
def calc_margin_usage_pct(
|
||||
positions: list[dict],
|
||||
capital: float,
|
||||
*,
|
||||
extra_symbol: str = "",
|
||||
extra_lots: int = 0,
|
||||
extra_price: float = 0,
|
||||
) -> float:
|
||||
"""当前持仓 + 拟开仓占权益的保证金比例(%)。"""
|
||||
cap = float(capital or 0)
|
||||
if cap <= 0:
|
||||
return 999.0
|
||||
total = 0.0
|
||||
for p in positions:
|
||||
lots = int(p.get("lots") or 0)
|
||||
if lots <= 0:
|
||||
continue
|
||||
sym = (p.get("symbol") or "").strip()
|
||||
entry = float(p.get("avg_price") or p.get("entry_price") or 0)
|
||||
if entry <= 0:
|
||||
continue
|
||||
spec = get_contract_spec(sym)
|
||||
total += entry * spec["mult"] * lots * spec["margin_rate"]
|
||||
if extra_symbol and extra_lots > 0 and extra_price > 0:
|
||||
spec = get_contract_spec(extra_symbol)
|
||||
total += extra_price * spec["mult"] * extra_lots * spec["margin_rate"]
|
||||
return round(total / cap * 100.0, 2)
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""期货计仓:固定手数 / 固定金额。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
|
||||
MODE_FIXED = "fixed"
|
||||
MODE_AMOUNT = "amount"
|
||||
MODE_RISK = "amount" # 兼容旧配置「以损定仓」
|
||||
|
||||
DEFAULT_MAX_ORDER_LOTS = 50
|
||||
|
||||
|
||||
def normalize_sizing_mode(raw: str) -> str:
|
||||
m = (raw or MODE_FIXED).strip().lower()
|
||||
if m == "risk":
|
||||
m = MODE_AMOUNT
|
||||
return m if m in (MODE_FIXED, MODE_AMOUNT) else MODE_FIXED
|
||||
|
||||
|
||||
def price_precision_from_tick(tick_size: float) -> int:
|
||||
if tick_size <= 0:
|
||||
return 0
|
||||
s = f"{tick_size:.10f}".rstrip("0").rstrip(".")
|
||||
if "." not in s:
|
||||
return 0
|
||||
return len(s.split(".")[1])
|
||||
|
||||
|
||||
def _per_lot_risk(entry: float, stop_loss: float, direction: str, ths_code: str) -> tuple[float, Optional[str]]:
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec["mult"]
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
per_lot = (stop_loss - entry) * mult
|
||||
else:
|
||||
per_lot = (entry - stop_loss) * mult
|
||||
if per_lot <= 0:
|
||||
return 0.0, "止损方向与入场价不匹配"
|
||||
return per_lot, None
|
||||
|
||||
|
||||
def calc_lots_by_amount(
|
||||
entry: float,
|
||||
stop_loss: float,
|
||||
direction: str,
|
||||
amount: float,
|
||||
ths_code: str,
|
||||
*,
|
||||
capital: float = 0.0,
|
||||
max_lots: Optional[int] = None,
|
||||
max_margin_pct: float = 30.0,
|
||||
) -> tuple[Optional[int], Optional[str]]:
|
||||
"""固定金额:按止损距离将金额换算为手数。"""
|
||||
try:
|
||||
entry_f = float(entry)
|
||||
sl_f = float(stop_loss)
|
||||
budget = float(amount)
|
||||
cap = float(capital or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if entry_f <= 0 or budget <= 0:
|
||||
return None, "入场价或固定金额无效"
|
||||
per_lot_risk, err = _per_lot_risk(entry_f, sl_f, direction, ths_code)
|
||||
if err:
|
||||
return None, err
|
||||
lots = int(math.floor(budget / per_lot_risk))
|
||||
if lots < 1:
|
||||
return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手"
|
||||
if cap > 0:
|
||||
spec = get_contract_spec(ths_code)
|
||||
margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"]
|
||||
margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||
max_by_margin = (
|
||||
int(math.floor(cap * margin_cap / 100.0 / margin_per_lot))
|
||||
if margin_per_lot > 0 else lots
|
||||
)
|
||||
if max_by_margin < 1:
|
||||
return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手"
|
||||
lots = min(lots, max_by_margin)
|
||||
cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS
|
||||
lots = min(lots, cap_lots)
|
||||
return lots, None
|
||||
|
||||
|
||||
def calc_lots_by_risk(
|
||||
entry: float,
|
||||
stop_loss: float,
|
||||
direction: str,
|
||||
capital: float,
|
||||
risk_percent: float,
|
||||
ths_code: str,
|
||||
*,
|
||||
max_lots: Optional[int] = None,
|
||||
max_margin_pct: float = 30.0,
|
||||
) -> tuple[Optional[int], Optional[str]]:
|
||||
"""策略等场景:按权益百分比风险预算换算手数。"""
|
||||
try:
|
||||
cap = float(capital)
|
||||
rp = float(risk_percent)
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if cap <= 0 or rp <= 0:
|
||||
return None, "资金或风险比例无效"
|
||||
budget = cap * rp / 100.0
|
||||
return calc_lots_by_amount(
|
||||
entry, stop_loss, direction, budget, ths_code,
|
||||
capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct,
|
||||
)
|
||||
|
||||
|
||||
def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = None) -> dict:
|
||||
"""下单区展示:最小变动价位、每跳盈亏、保证金等。"""
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = int(spec["mult"])
|
||||
tick = float(spec.get("tick_size") or 1.0)
|
||||
margin_rate = float(spec["margin_rate"])
|
||||
lots_i = max(1, int(lots or 1))
|
||||
tick_value_per_lot = round(tick * mult, 4)
|
||||
tick_value_total = round(tick_value_per_lot * lots_i, 2)
|
||||
prec = price_precision_from_tick(tick)
|
||||
mark = float(price) if price else 0.0
|
||||
margin_per_lot = round(mark * mult * margin_rate, 2) if mark > 0 else None
|
||||
margin_total = round(margin_per_lot * lots_i, 2) if margin_per_lot else None
|
||||
return {
|
||||
"mult": mult,
|
||||
"tick_size": tick,
|
||||
"price_precision": prec,
|
||||
"tick_value_per_lot": tick_value_per_lot,
|
||||
"tick_value_total": tick_value_total,
|
||||
"lots": lots_i,
|
||||
"margin_per_lot": margin_per_lot,
|
||||
"margin_total": margin_total,
|
||||
"margin_rate": margin_rate,
|
||||
}
|
||||
|
||||
|
||||
def calc_margin_usage_pct(
|
||||
positions: list[dict],
|
||||
capital: float,
|
||||
*,
|
||||
extra_symbol: str = "",
|
||||
extra_lots: int = 0,
|
||||
extra_price: float = 0,
|
||||
) -> float:
|
||||
"""当前持仓 + 拟开仓占权益的保证金比例(%)。"""
|
||||
cap = float(capital or 0)
|
||||
if cap <= 0:
|
||||
return 999.0
|
||||
total = 0.0
|
||||
for p in positions:
|
||||
lots = int(p.get("lots") or 0)
|
||||
if lots <= 0:
|
||||
continue
|
||||
sym = (p.get("symbol") or "").strip()
|
||||
entry = float(p.get("avg_price") or p.get("entry_price") or 0)
|
||||
if entry <= 0:
|
||||
continue
|
||||
spec = get_contract_spec(sym)
|
||||
total += entry * spec["mult"] * lots * spec["margin_rate"]
|
||||
if extra_symbol and extra_lots > 0 and extra_price > 0:
|
||||
spec = get_contract_spec(extra_symbol)
|
||||
total += extra_price * spec["mult"] * extra_lots * spec["margin_rate"]
|
||||
return round(total / cap * 100.0, 2)
|
||||
|
||||
+106
-101
@@ -1,101 +1,106 @@
|
||||
"""持仓监控:后台拉取 CTP 并 SSE 推送给前端(避免每次刷新阻塞读柜台)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from kline_stream import sse_format
|
||||
from market_sessions import is_trading_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PUSH_INTERVAL_SEC = 1
|
||||
IDLE_INTERVAL_SEC = 5
|
||||
|
||||
|
||||
class PositionStreamHub:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list[queue.Queue] = []
|
||||
self._snapshot: Optional[dict] = None
|
||||
self._snapshot_ts: float = 0.0
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue(maxsize=16)
|
||||
with self._lock:
|
||||
self._subs.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subs.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def get_snapshot(self) -> Optional[dict]:
|
||||
with self._lock:
|
||||
return dict(self._snapshot) if self._snapshot else None
|
||||
|
||||
def set_snapshot(self, data: dict) -> None:
|
||||
with self._lock:
|
||||
self._snapshot = dict(data)
|
||||
self._snapshot_ts = time.time()
|
||||
|
||||
def broadcast(self, event: str, data: dict) -> None:
|
||||
self.set_snapshot(data)
|
||||
msg = {"event": event, "data": data}
|
||||
with self._lock:
|
||||
subs = list(self._subs)
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
position_hub = PositionStreamHub()
|
||||
|
||||
|
||||
def start_position_worker(
|
||||
*,
|
||||
refresh_fn: Callable[[], dict],
|
||||
interval: int = PUSH_INTERVAL_SEC,
|
||||
idle_interval: int = IDLE_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""后台定时刷新持仓快照并 SSE 广播。"""
|
||||
|
||||
def _loop() -> None:
|
||||
while True:
|
||||
sleep_sec = idle_interval
|
||||
try:
|
||||
payload = refresh_fn()
|
||||
if payload:
|
||||
position_hub.broadcast("positions", payload)
|
||||
ctp_st = (payload or {}).get("ctp_status") or {}
|
||||
connected = bool(ctp_st.get("connected"))
|
||||
in_session = bool((payload or {}).get("trading_session"))
|
||||
rows = (payload or {}).get("rows") or []
|
||||
has_sl_tp = any(
|
||||
r.get("stop_loss") is not None or r.get("take_profit") is not None
|
||||
for r in rows
|
||||
)
|
||||
if connected and in_session:
|
||||
sleep_sec = max(1, interval)
|
||||
elif connected:
|
||||
sleep_sec = max(2, min(idle_interval, 3))
|
||||
except Exception as exc:
|
||||
logger.warning("position worker failed: %s", exc)
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="position-stream").start()
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""持仓监控:后台拉取 CTP 并 SSE 推送给前端(避免每次刷新阻塞读柜台)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from kline_stream import sse_format
|
||||
from market_sessions import is_trading_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PUSH_INTERVAL_SEC = 1
|
||||
IDLE_INTERVAL_SEC = 5
|
||||
|
||||
|
||||
class PositionStreamHub:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list[queue.Queue] = []
|
||||
self._snapshot: Optional[dict] = None
|
||||
self._snapshot_ts: float = 0.0
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue(maxsize=16)
|
||||
with self._lock:
|
||||
self._subs.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subs.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def get_snapshot(self) -> Optional[dict]:
|
||||
with self._lock:
|
||||
return dict(self._snapshot) if self._snapshot else None
|
||||
|
||||
def set_snapshot(self, data: dict) -> None:
|
||||
with self._lock:
|
||||
self._snapshot = dict(data)
|
||||
self._snapshot_ts = time.time()
|
||||
|
||||
def broadcast(self, event: str, data: dict) -> None:
|
||||
self.set_snapshot(data)
|
||||
msg = {"event": event, "data": data}
|
||||
with self._lock:
|
||||
subs = list(self._subs)
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
position_hub = PositionStreamHub()
|
||||
|
||||
|
||||
def start_position_worker(
|
||||
*,
|
||||
refresh_fn: Callable[[], dict],
|
||||
interval: int = PUSH_INTERVAL_SEC,
|
||||
idle_interval: int = IDLE_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""后台定时刷新持仓快照并 SSE 广播。"""
|
||||
|
||||
def _loop() -> None:
|
||||
while True:
|
||||
sleep_sec = idle_interval
|
||||
try:
|
||||
payload = refresh_fn()
|
||||
if payload:
|
||||
position_hub.broadcast("positions", payload)
|
||||
ctp_st = (payload or {}).get("ctp_status") or {}
|
||||
connected = bool(ctp_st.get("connected"))
|
||||
in_session = bool((payload or {}).get("trading_session"))
|
||||
rows = (payload or {}).get("rows") or []
|
||||
has_sl_tp = any(
|
||||
r.get("stop_loss") is not None or r.get("take_profit") is not None
|
||||
for r in rows
|
||||
)
|
||||
if connected and in_session:
|
||||
sleep_sec = max(1, interval)
|
||||
elif connected:
|
||||
sleep_sec = max(2, min(idle_interval, 3))
|
||||
except Exception as exc:
|
||||
logger.warning("position worker failed: %s", exc)
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="position-stream").start()
|
||||
|
||||
+169
-164
@@ -1,164 +1,169 @@
|
||||
"""按账户资金推荐可交易品种(期货核心筛选)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _attach_turnover(row: dict) -> None:
|
||||
"""成交额 = 昨日成交量(手) × 昨收 × 合约乘数。"""
|
||||
try:
|
||||
vol = float(row.get("volume") or 0)
|
||||
price = float(row.get("prev_close") or row.get("price") or 0)
|
||||
mult = float(row.get("mult") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if vol > 0 and price > 0 and mult > 0:
|
||||
row["turnover"] = round(vol * price * mult, 2)
|
||||
|
||||
|
||||
def _letters_from_ths(ths_code: str) -> str:
|
||||
import re
|
||||
m = re.match(r"^([A-Za-z]+)", (ths_code or "").strip())
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def assess_product_for_capital(
|
||||
product: dict,
|
||||
capital: float,
|
||||
price: Optional[float],
|
||||
*,
|
||||
max_margin_pct: float = 30.0,
|
||||
default_stop_ticks: int = 20,
|
||||
reward_risk_ratio: float = 2.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> dict:
|
||||
"""评估单品种在当前资金下是否可交易。"""
|
||||
ths = product.get("ths") or ""
|
||||
name = product.get("name") or ths
|
||||
exchange = product.get("exchange") or ""
|
||||
category = product.get("category") or product_category(ths)
|
||||
spec = get_contract_spec(ths + "8888")
|
||||
mult = spec["mult"]
|
||||
margin_rate = spec["margin_rate"]
|
||||
tick = float(spec.get("tick_size") or 1.0)
|
||||
p = float(price) if price and price > 0 else 0.0
|
||||
cap = float(capital or 0)
|
||||
margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||
|
||||
if p <= 0:
|
||||
return {
|
||||
"ths": ths,
|
||||
"name": name,
|
||||
"exchange": exchange,
|
||||
"category": category,
|
||||
"status": "no_price",
|
||||
"status_label": "暂无行情",
|
||||
"min_capital_one_lot": None,
|
||||
"margin_one_lot": None,
|
||||
"max_lots": 0,
|
||||
"risk_one_lot_1pct": None,
|
||||
}
|
||||
|
||||
margin_one = p * mult * margin_rate
|
||||
min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one
|
||||
margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0
|
||||
max_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0
|
||||
stop_dist = tick * default_stop_ticks
|
||||
risk_one_lot = stop_dist * mult
|
||||
risk_pct_1lot = (risk_one_lot / cap * 100) if cap > 0 else 999.0
|
||||
ref_sl = round(p - stop_dist, 4)
|
||||
ref_tp = round(p + stop_dist * reward_risk_ratio, 4)
|
||||
fee_ths = ths + "8888"
|
||||
try:
|
||||
fee_info = calc_fee_breakdown(
|
||||
fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("recommend fee calc failed %s: %s", ths, exc)
|
||||
fee_info = {"open_fee": 0.0, "total_fee": 0.0}
|
||||
|
||||
can_margin = max_lots >= 1
|
||||
can_risk = cap > 0 and risk_one_lot <= cap * 0.01
|
||||
|
||||
if can_margin and can_risk:
|
||||
status, label = "ok", f"最大 {max_lots} 手"
|
||||
elif can_margin:
|
||||
status, label = "margin_ok", f"最大 {max_lots} 手·止损偏宽"
|
||||
else:
|
||||
status, label = "blocked", "资金不足"
|
||||
|
||||
return {
|
||||
"ths": ths,
|
||||
"name": name,
|
||||
"exchange": exchange,
|
||||
"category": category,
|
||||
"price": round(p, 4),
|
||||
"mult": mult,
|
||||
"tick_size": tick,
|
||||
"margin_one_lot": round(margin_one, 2),
|
||||
"min_capital_one_lot": round(min_capital, 2),
|
||||
"max_lots": max_lots,
|
||||
"margin_budget": round(margin_budget, 2),
|
||||
"max_margin_pct": margin_pct,
|
||||
"risk_one_lot_1pct": round(risk_one_lot, 2),
|
||||
"risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2),
|
||||
"ref_stop_loss": ref_sl,
|
||||
"ref_take_profit": ref_tp,
|
||||
"open_fee_one_lot": fee_info["open_fee"],
|
||||
"roundtrip_fee_one_lot": fee_info["total_fee"],
|
||||
"status": status,
|
||||
"status_label": label,
|
||||
}
|
||||
|
||||
|
||||
def list_product_recommendations(
|
||||
capital: float,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
*,
|
||||
max_margin_pct: float = 30.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> list[dict]:
|
||||
"""扫描全部品种并排序:推荐 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}"""
|
||||
|
||||
def _one(product: dict) -> dict:
|
||||
ths = product["ths"]
|
||||
try:
|
||||
quote = quote_fn(ths) or {}
|
||||
price = quote.get("price")
|
||||
row = assess_product_for_capital(
|
||||
product, capital, price,
|
||||
max_margin_pct=max_margin_pct,
|
||||
trading_mode=trading_mode,
|
||||
)
|
||||
main_code = (quote.get("ths_code") or "").strip()
|
||||
row["main_code"] = main_code
|
||||
if main_code:
|
||||
row.update(analyze_product_daily(main_code))
|
||||
_attach_turnover(row)
|
||||
return row
|
||||
except Exception as exc:
|
||||
logger.warning("recommend product failed %s: %s", ths, exc)
|
||||
return {
|
||||
"ths": ths,
|
||||
"name": product.get("name") or ths,
|
||||
"exchange": product.get("exchange") or "",
|
||||
"category": product.get("category") or product_category(ths),
|
||||
"status": "no_price",
|
||||
"status_label": "计算失败",
|
||||
"main_code": "",
|
||||
"max_lots": 0,
|
||||
}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as pool:
|
||||
rows = list(pool.map(_one, PRODUCTS))
|
||||
return sort_recommend_by_trend(rows)
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""按账户资金筛选可开仓品种(保证金与仓位纪律)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _attach_turnover(row: dict) -> None:
|
||||
"""成交额 = 昨日成交量(手) × 昨收 × 合约乘数。"""
|
||||
try:
|
||||
vol = float(row.get("volume") or 0)
|
||||
price = float(row.get("prev_close") or row.get("price") or 0)
|
||||
mult = float(row.get("mult") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if vol > 0 and price > 0 and mult > 0:
|
||||
row["turnover"] = round(vol * price * mult, 2)
|
||||
|
||||
|
||||
def _letters_from_ths(ths_code: str) -> str:
|
||||
import re
|
||||
m = re.match(r"^([A-Za-z]+)", (ths_code or "").strip())
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def assess_product_for_capital(
|
||||
product: dict,
|
||||
capital: float,
|
||||
price: Optional[float],
|
||||
*,
|
||||
max_margin_pct: float = 30.0,
|
||||
default_stop_ticks: int = 20,
|
||||
reward_risk_ratio: float = 2.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> dict:
|
||||
"""评估单品种在当前资金下是否可交易。"""
|
||||
ths = product.get("ths") or ""
|
||||
name = product.get("name") or ths
|
||||
exchange = product.get("exchange") or ""
|
||||
category = product.get("category") or product_category(ths)
|
||||
spec = get_contract_spec(ths + "8888")
|
||||
mult = spec["mult"]
|
||||
margin_rate = spec["margin_rate"]
|
||||
tick = float(spec.get("tick_size") or 1.0)
|
||||
p = float(price) if price and price > 0 else 0.0
|
||||
cap = float(capital or 0)
|
||||
margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||
|
||||
if p <= 0:
|
||||
return {
|
||||
"ths": ths,
|
||||
"name": name,
|
||||
"exchange": exchange,
|
||||
"category": category,
|
||||
"status": "no_price",
|
||||
"status_label": "暂无行情",
|
||||
"min_capital_one_lot": None,
|
||||
"margin_one_lot": None,
|
||||
"max_lots": 0,
|
||||
"risk_one_lot_1pct": None,
|
||||
}
|
||||
|
||||
margin_one = p * mult * margin_rate
|
||||
min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one
|
||||
margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0
|
||||
max_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0
|
||||
stop_dist = tick * default_stop_ticks
|
||||
risk_one_lot = stop_dist * mult
|
||||
risk_pct_1lot = (risk_one_lot / cap * 100) if cap > 0 else 999.0
|
||||
ref_sl = round(p - stop_dist, 4)
|
||||
ref_tp = round(p + stop_dist * reward_risk_ratio, 4)
|
||||
fee_ths = ths + "8888"
|
||||
try:
|
||||
fee_info = calc_fee_breakdown(
|
||||
fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("recommend fee calc failed %s: %s", ths, exc)
|
||||
fee_info = {"open_fee": 0.0, "total_fee": 0.0}
|
||||
|
||||
can_margin = max_lots >= 1
|
||||
can_risk = cap > 0 and risk_one_lot <= cap * 0.01
|
||||
|
||||
if can_margin and can_risk:
|
||||
status, label = "ok", f"最大 {max_lots} 手"
|
||||
elif can_margin:
|
||||
status, label = "margin_ok", f"最大 {max_lots} 手·止损偏宽"
|
||||
else:
|
||||
status, label = "blocked", "资金不足"
|
||||
|
||||
return {
|
||||
"ths": ths,
|
||||
"name": name,
|
||||
"exchange": exchange,
|
||||
"category": category,
|
||||
"price": round(p, 4),
|
||||
"mult": mult,
|
||||
"tick_size": tick,
|
||||
"margin_one_lot": round(margin_one, 2),
|
||||
"min_capital_one_lot": round(min_capital, 2),
|
||||
"max_lots": max_lots,
|
||||
"margin_budget": round(margin_budget, 2),
|
||||
"max_margin_pct": margin_pct,
|
||||
"risk_one_lot_1pct": round(risk_one_lot, 2),
|
||||
"risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2),
|
||||
"ref_stop_loss": ref_sl,
|
||||
"ref_take_profit": ref_tp,
|
||||
"open_fee_one_lot": fee_info["open_fee"],
|
||||
"roundtrip_fee_one_lot": fee_info["total_fee"],
|
||||
"status": status,
|
||||
"status_label": label,
|
||||
}
|
||||
|
||||
|
||||
def list_product_recommendations(
|
||||
capital: float,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
*,
|
||||
max_margin_pct: float = 30.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> list[dict]:
|
||||
"""扫描全部品种并排序:可开且纪律友好 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}"""
|
||||
|
||||
def _one(product: dict) -> dict:
|
||||
ths = product["ths"]
|
||||
try:
|
||||
quote = quote_fn(ths) or {}
|
||||
price = quote.get("price")
|
||||
row = assess_product_for_capital(
|
||||
product, capital, price,
|
||||
max_margin_pct=max_margin_pct,
|
||||
trading_mode=trading_mode,
|
||||
)
|
||||
main_code = (quote.get("ths_code") or "").strip()
|
||||
row["main_code"] = main_code
|
||||
if main_code:
|
||||
row.update(analyze_product_daily(main_code))
|
||||
_attach_turnover(row)
|
||||
return row
|
||||
except Exception as exc:
|
||||
logger.warning("recommend product failed %s: %s", ths, exc)
|
||||
return {
|
||||
"ths": ths,
|
||||
"name": product.get("name") or ths,
|
||||
"exchange": product.get("exchange") or "",
|
||||
"category": product.get("category") or product_category(ths),
|
||||
"status": "no_price",
|
||||
"status_label": "计算失败",
|
||||
"main_code": "",
|
||||
"max_lots": 0,
|
||||
}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as pool:
|
||||
rows = list(pool.map(_one, PRODUCTS))
|
||||
return sort_recommend_by_trend(rows)
|
||||
|
||||
+264
-259
@@ -1,259 +1,264 @@
|
||||
"""品种推荐:计算、按资金过滤、SQLite 缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from fee_specs import ensure_fee_rates_schema
|
||||
from product_recommend import _attach_turnover, list_product_recommendations
|
||||
from recommend_trend import sort_recommend_by_trend
|
||||
from symbols import product_category
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECOMMEND_CACHE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS product_recommend_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
capital REAL NOT NULL DEFAULT 0,
|
||||
rows_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def ensure_recommend_tables(conn) -> None:
|
||||
conn.execute(RECOMMEND_CACHE_SQL)
|
||||
|
||||
|
||||
def filter_affordable_recommendations(rows: list[dict]) -> list[dict]:
|
||||
"""仅保留当前资金可开 1 手的品种(不含资金不足、无行情)。"""
|
||||
return [r for r in rows if r.get("status") in ("ok", "margin_ok")]
|
||||
|
||||
|
||||
def rows_missing_max_lots(rows: list[dict]) -> bool:
|
||||
"""缓存是否为旧版(缺少最大手数字段)。"""
|
||||
if not rows:
|
||||
return False
|
||||
return any("max_lots" not in r for r in rows)
|
||||
|
||||
|
||||
def rows_missing_trend(rows: list[dict]) -> bool:
|
||||
"""缓存是否为旧版(缺少走势字段)。"""
|
||||
if not rows:
|
||||
return False
|
||||
return any("trend" not in r for r in rows)
|
||||
|
||||
|
||||
def rows_missing_daily_stats(rows: list[dict]) -> bool:
|
||||
"""缓存是否为旧版(缺少跳空/量价字段)。"""
|
||||
if not rows:
|
||||
return False
|
||||
return any("gap" not in r for r in rows)
|
||||
|
||||
|
||||
def rows_missing_category(rows: list[dict]) -> bool:
|
||||
if not rows:
|
||||
return False
|
||||
return any("category" not in r for r in rows)
|
||||
|
||||
|
||||
def rows_missing_turnover(rows: list[dict]) -> bool:
|
||||
if not rows:
|
||||
return False
|
||||
return any("turnover" not in r for r in rows)
|
||||
|
||||
|
||||
def recommend_cache_needs_refresh(
|
||||
cached: dict,
|
||||
*,
|
||||
capital: float = 0.0,
|
||||
) -> bool:
|
||||
"""是否需要重新拉行情计算推荐列表。"""
|
||||
if recommend_cache_stale(cached.get("updated_at")):
|
||||
return True
|
||||
rows = cached.get("rows") or []
|
||||
if rows_missing_max_lots(rows):
|
||||
return True
|
||||
if rows_missing_trend(rows):
|
||||
return True
|
||||
if rows_missing_daily_stats(rows):
|
||||
return True
|
||||
if rows_missing_category(rows):
|
||||
return True
|
||||
if rows_missing_turnover(rows):
|
||||
return True
|
||||
if float(capital or 0) > 0 and not rows:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def enrich_recommend_rows(
|
||||
rows: list[dict],
|
||||
capital: float,
|
||||
*,
|
||||
max_margin_pct: float = 30.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> list[dict]:
|
||||
"""用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。"""
|
||||
cap = float(capital or 0)
|
||||
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||
budget = cap * pct / 100.0 if cap > 0 else 0.0
|
||||
ctp_connected = False
|
||||
try:
|
||||
from vnpy_bridge import ctp_estimate_margin_one_lot, ctp_status
|
||||
ctp_connected = bool(ctp_status(trading_mode).get("connected"))
|
||||
except Exception:
|
||||
pass
|
||||
enriched: list[dict] = []
|
||||
for raw in rows:
|
||||
row = dict(raw)
|
||||
margin_one = 0.0
|
||||
try:
|
||||
margin_one = float(row.get("margin_one_lot") or 0)
|
||||
except (TypeError, ValueError):
|
||||
margin_one = 0.0
|
||||
price = float(row.get("price") or 0)
|
||||
main_code = (row.get("main_code") or "").strip()
|
||||
if ctp_connected and main_code and price > 0:
|
||||
ctp_margin = ctp_estimate_margin_one_lot(trading_mode, main_code, price)
|
||||
if ctp_margin and ctp_margin > 0:
|
||||
margin_one = ctp_margin
|
||||
row["margin_one_lot"] = ctp_margin
|
||||
row["margin_source"] = "ctp"
|
||||
if margin_one > 0 and budget > 0:
|
||||
lots = int(math.floor(budget / margin_one))
|
||||
else:
|
||||
try:
|
||||
lots = int(row.get("max_lots") or row.get("recommended_lots") or 0)
|
||||
except (TypeError, ValueError):
|
||||
lots = 0
|
||||
row["max_lots"] = lots
|
||||
row.pop("recommended_lots", None)
|
||||
row["margin_budget"] = round(budget, 2)
|
||||
row["max_margin_pct"] = pct
|
||||
status = row.get("status") or ""
|
||||
if lots >= 1 and status in ("ok", "margin_ok"):
|
||||
src = "柜台" if row.get("margin_source") == "ctp" else "估算"
|
||||
row["status_label"] = (
|
||||
f"最大 {lots} 手" if status == "ok" else f"最大 {lots} 手·止损偏宽"
|
||||
)
|
||||
if row.get("margin_source") == "ctp":
|
||||
row["status_label"] += f"({src}保证金)"
|
||||
elif lots < 1 and status in ("ok", "margin_ok"):
|
||||
row["status"] = "blocked"
|
||||
row["status_label"] = "资金不足"
|
||||
if not row.get("category"):
|
||||
row["category"] = product_category(row.get("ths") or "")
|
||||
_attach_turnover(row)
|
||||
enriched.append(row)
|
||||
return enriched
|
||||
|
||||
|
||||
def filter_recommend_by_sizing(
|
||||
rows: list[dict],
|
||||
*,
|
||||
sizing_mode: str,
|
||||
fixed_lots: int = 1,
|
||||
) -> list[dict]:
|
||||
"""固定手数模式下:最大手数低于设定值的品种不展示。"""
|
||||
if (sizing_mode or "").strip().lower() != "fixed":
|
||||
return rows
|
||||
fl = max(1, int(fixed_lots or 1))
|
||||
return [r for r in rows if int(r.get("max_lots") or 0) >= fl]
|
||||
|
||||
|
||||
def refresh_recommend_cache(
|
||||
conn,
|
||||
capital: float,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
*,
|
||||
trading_mode: str = "simulation",
|
||||
max_margin_pct: float = 30.0,
|
||||
) -> list[dict]:
|
||||
"""后台拉行情、筛选并写入数据库。"""
|
||||
ensure_recommend_tables(conn)
|
||||
ensure_fee_rates_schema(conn)
|
||||
all_rows = list_product_recommendations(
|
||||
capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode,
|
||||
)
|
||||
rows = filter_affordable_recommendations(all_rows)
|
||||
if not rows and float(capital or 0) > 0:
|
||||
logger.warning(
|
||||
"recommend refresh: 0 affordable rows capital=%.2f total=%d no_price=%d blocked=%d",
|
||||
float(capital or 0),
|
||||
len(all_rows),
|
||||
sum(1 for r in all_rows if r.get("status") == "no_price"),
|
||||
sum(1 for r in all_rows if r.get("status") == "blocked"),
|
||||
)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at)
|
||||
VALUES (1, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
capital=excluded.capital,
|
||||
rows_json=excluded.rows_json,
|
||||
updated_at=excluded.updated_at""",
|
||||
(float(capital or 0), json.dumps(rows, ensure_ascii=False), now),
|
||||
)
|
||||
conn.commit()
|
||||
return rows
|
||||
|
||||
|
||||
def recommend_cache_stale(updated_at: Optional[str], *, now: Optional[datetime] = None) -> bool:
|
||||
"""缓存是否不是今日更新(需重新拉行情计算)。"""
|
||||
if not updated_at:
|
||||
return True
|
||||
try:
|
||||
cached_day = datetime.strptime(str(updated_at)[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return True
|
||||
today = (now or datetime.now()).date()
|
||||
return cached_day != today
|
||||
|
||||
|
||||
def load_recommend_cache(conn) -> dict:
|
||||
"""优先从数据库读取推荐列表。"""
|
||||
ensure_recommend_tables(conn)
|
||||
row = conn.execute("SELECT capital, rows_json, updated_at FROM product_recommend_cache WHERE id=1").fetchone()
|
||||
if not row:
|
||||
return {"capital": 0.0, "rows": [], "updated_at": None, "stale": True}
|
||||
try:
|
||||
rows = json.loads(row["rows_json"] or "[]")
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
rows = []
|
||||
updated_at = row["updated_at"]
|
||||
return {
|
||||
"capital": float(row["capital"] or 0),
|
||||
"rows": rows if isinstance(rows, list) else [],
|
||||
"updated_at": updated_at,
|
||||
"stale": recommend_cache_stale(updated_at),
|
||||
}
|
||||
|
||||
|
||||
def recommend_payload(
|
||||
conn,
|
||||
*,
|
||||
live_capital: float,
|
||||
max_margin_pct: float = 30.0,
|
||||
trading_mode: str = "simulation",
|
||||
sizing_mode: str = "fixed",
|
||||
fixed_lots: int = 1,
|
||||
) -> dict:
|
||||
"""读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。"""
|
||||
payload = load_recommend_cache(conn)
|
||||
cap = float(live_capital or 0)
|
||||
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||
payload["capital"] = cap
|
||||
payload["max_margin_pct"] = pct
|
||||
rows = payload.get("rows") or []
|
||||
rows = enrich_recommend_rows(
|
||||
rows, cap, max_margin_pct=pct, trading_mode=trading_mode,
|
||||
)
|
||||
rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots)
|
||||
rows = sort_recommend_by_trend(rows)
|
||||
payload["rows"] = rows
|
||||
payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
|
||||
return payload
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""可开仓品种:计算、按资金过滤、SQLite 缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from fee_specs import ensure_fee_rates_schema
|
||||
from product_recommend import _attach_turnover, list_product_recommendations
|
||||
from recommend_trend import sort_recommend_by_trend
|
||||
from symbols import product_category
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECOMMEND_CACHE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS product_recommend_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
capital REAL NOT NULL DEFAULT 0,
|
||||
rows_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def ensure_recommend_tables(conn) -> None:
|
||||
conn.execute(RECOMMEND_CACHE_SQL)
|
||||
|
||||
|
||||
def filter_affordable_recommendations(rows: list[dict]) -> list[dict]:
|
||||
"""仅保留当前资金可开 1 手的品种(不含资金不足、无行情)。"""
|
||||
return [r for r in rows if r.get("status") in ("ok", "margin_ok")]
|
||||
|
||||
|
||||
def rows_missing_max_lots(rows: list[dict]) -> bool:
|
||||
"""缓存是否为旧版(缺少最大手数字段)。"""
|
||||
if not rows:
|
||||
return False
|
||||
return any("max_lots" not in r for r in rows)
|
||||
|
||||
|
||||
def rows_missing_trend(rows: list[dict]) -> bool:
|
||||
"""缓存是否为旧版(缺少走势字段)。"""
|
||||
if not rows:
|
||||
return False
|
||||
return any("trend" not in r for r in rows)
|
||||
|
||||
|
||||
def rows_missing_daily_stats(rows: list[dict]) -> bool:
|
||||
"""缓存是否为旧版(缺少跳空/量价字段)。"""
|
||||
if not rows:
|
||||
return False
|
||||
return any("gap" not in r for r in rows)
|
||||
|
||||
|
||||
def rows_missing_category(rows: list[dict]) -> bool:
|
||||
if not rows:
|
||||
return False
|
||||
return any("category" not in r for r in rows)
|
||||
|
||||
|
||||
def rows_missing_turnover(rows: list[dict]) -> bool:
|
||||
if not rows:
|
||||
return False
|
||||
return any("turnover" not in r for r in rows)
|
||||
|
||||
|
||||
def recommend_cache_needs_refresh(
|
||||
cached: dict,
|
||||
*,
|
||||
capital: float = 0.0,
|
||||
) -> bool:
|
||||
"""是否需要重新拉行情计算可开仓列表。"""
|
||||
if recommend_cache_stale(cached.get("updated_at")):
|
||||
return True
|
||||
rows = cached.get("rows") or []
|
||||
if rows_missing_max_lots(rows):
|
||||
return True
|
||||
if rows_missing_trend(rows):
|
||||
return True
|
||||
if rows_missing_daily_stats(rows):
|
||||
return True
|
||||
if rows_missing_category(rows):
|
||||
return True
|
||||
if rows_missing_turnover(rows):
|
||||
return True
|
||||
if float(capital or 0) > 0 and not rows:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def enrich_recommend_rows(
|
||||
rows: list[dict],
|
||||
capital: float,
|
||||
*,
|
||||
max_margin_pct: float = 30.0,
|
||||
trading_mode: str = "simulation",
|
||||
) -> list[dict]:
|
||||
"""用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。"""
|
||||
cap = float(capital or 0)
|
||||
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||
budget = cap * pct / 100.0 if cap > 0 else 0.0
|
||||
ctp_connected = False
|
||||
try:
|
||||
from vnpy_bridge import ctp_estimate_margin_one_lot, ctp_status
|
||||
ctp_connected = bool(ctp_status(trading_mode).get("connected"))
|
||||
except Exception:
|
||||
pass
|
||||
enriched: list[dict] = []
|
||||
for raw in rows:
|
||||
row = dict(raw)
|
||||
margin_one = 0.0
|
||||
try:
|
||||
margin_one = float(row.get("margin_one_lot") or 0)
|
||||
except (TypeError, ValueError):
|
||||
margin_one = 0.0
|
||||
price = float(row.get("price") or 0)
|
||||
main_code = (row.get("main_code") or "").strip()
|
||||
if ctp_connected and main_code and price > 0:
|
||||
ctp_margin = ctp_estimate_margin_one_lot(trading_mode, main_code, price)
|
||||
if ctp_margin and ctp_margin > 0:
|
||||
margin_one = ctp_margin
|
||||
row["margin_one_lot"] = ctp_margin
|
||||
row["margin_source"] = "ctp"
|
||||
if margin_one > 0 and budget > 0:
|
||||
lots = int(math.floor(budget / margin_one))
|
||||
else:
|
||||
try:
|
||||
lots = int(row.get("max_lots") or row.get("recommended_lots") or 0)
|
||||
except (TypeError, ValueError):
|
||||
lots = 0
|
||||
row["max_lots"] = lots
|
||||
row.pop("recommended_lots", None)
|
||||
row["margin_budget"] = round(budget, 2)
|
||||
row["max_margin_pct"] = pct
|
||||
status = row.get("status") or ""
|
||||
if lots >= 1 and status in ("ok", "margin_ok"):
|
||||
src = "柜台" if row.get("margin_source") == "ctp" else "估算"
|
||||
row["status_label"] = (
|
||||
f"最大 {lots} 手" if status == "ok" else f"最大 {lots} 手·止损偏宽"
|
||||
)
|
||||
if row.get("margin_source") == "ctp":
|
||||
row["status_label"] += f"({src}保证金)"
|
||||
elif lots < 1 and status in ("ok", "margin_ok"):
|
||||
row["status"] = "blocked"
|
||||
row["status_label"] = "资金不足"
|
||||
if not row.get("category"):
|
||||
row["category"] = product_category(row.get("ths") or "")
|
||||
_attach_turnover(row)
|
||||
enriched.append(row)
|
||||
return enriched
|
||||
|
||||
|
||||
def filter_recommend_by_sizing(
|
||||
rows: list[dict],
|
||||
*,
|
||||
sizing_mode: str,
|
||||
fixed_lots: int = 1,
|
||||
) -> list[dict]:
|
||||
"""固定手数模式下:最大手数低于设定值的品种不展示。"""
|
||||
if (sizing_mode or "").strip().lower() != "fixed":
|
||||
return rows
|
||||
fl = max(1, int(fixed_lots or 1))
|
||||
return [r for r in rows if int(r.get("max_lots") or 0) >= fl]
|
||||
|
||||
|
||||
def refresh_recommend_cache(
|
||||
conn,
|
||||
capital: float,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
*,
|
||||
trading_mode: str = "simulation",
|
||||
max_margin_pct: float = 30.0,
|
||||
) -> list[dict]:
|
||||
"""后台拉行情、筛选并写入数据库。"""
|
||||
ensure_recommend_tables(conn)
|
||||
ensure_fee_rates_schema(conn)
|
||||
all_rows = list_product_recommendations(
|
||||
capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode,
|
||||
)
|
||||
rows = filter_affordable_recommendations(all_rows)
|
||||
if not rows and float(capital or 0) > 0:
|
||||
logger.warning(
|
||||
"recommend refresh: 0 affordable rows capital=%.2f total=%d no_price=%d blocked=%d",
|
||||
float(capital or 0),
|
||||
len(all_rows),
|
||||
sum(1 for r in all_rows if r.get("status") == "no_price"),
|
||||
sum(1 for r in all_rows if r.get("status") == "blocked"),
|
||||
)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at)
|
||||
VALUES (1, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
capital=excluded.capital,
|
||||
rows_json=excluded.rows_json,
|
||||
updated_at=excluded.updated_at""",
|
||||
(float(capital or 0), json.dumps(rows, ensure_ascii=False), now),
|
||||
)
|
||||
conn.commit()
|
||||
return rows
|
||||
|
||||
|
||||
def recommend_cache_stale(updated_at: Optional[str], *, now: Optional[datetime] = None) -> bool:
|
||||
"""缓存是否不是今日更新(需重新拉行情计算)。"""
|
||||
if not updated_at:
|
||||
return True
|
||||
try:
|
||||
cached_day = datetime.strptime(str(updated_at)[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return True
|
||||
today = (now or datetime.now()).date()
|
||||
return cached_day != today
|
||||
|
||||
|
||||
def load_recommend_cache(conn) -> dict:
|
||||
"""优先从数据库读取可开仓品种列表。"""
|
||||
ensure_recommend_tables(conn)
|
||||
row = conn.execute("SELECT capital, rows_json, updated_at FROM product_recommend_cache WHERE id=1").fetchone()
|
||||
if not row:
|
||||
return {"capital": 0.0, "rows": [], "updated_at": None, "stale": True}
|
||||
try:
|
||||
rows = json.loads(row["rows_json"] or "[]")
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
rows = []
|
||||
updated_at = row["updated_at"]
|
||||
return {
|
||||
"capital": float(row["capital"] or 0),
|
||||
"rows": rows if isinstance(rows, list) else [],
|
||||
"updated_at": updated_at,
|
||||
"stale": recommend_cache_stale(updated_at),
|
||||
}
|
||||
|
||||
|
||||
def recommend_payload(
|
||||
conn,
|
||||
*,
|
||||
live_capital: float,
|
||||
max_margin_pct: float = 30.0,
|
||||
trading_mode: str = "simulation",
|
||||
sizing_mode: str = "fixed",
|
||||
fixed_lots: int = 1,
|
||||
) -> dict:
|
||||
"""读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。"""
|
||||
payload = load_recommend_cache(conn)
|
||||
cap = float(live_capital or 0)
|
||||
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||
payload["capital"] = cap
|
||||
payload["max_margin_pct"] = pct
|
||||
rows = payload.get("rows") or []
|
||||
rows = enrich_recommend_rows(
|
||||
rows, cap, max_margin_pct=pct, trading_mode=trading_mode,
|
||||
)
|
||||
rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots)
|
||||
rows = sort_recommend_by_trend(rows)
|
||||
payload["rows"] = rows
|
||||
payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
|
||||
return payload
|
||||
|
||||
+163
-158
@@ -1,158 +1,163 @@
|
||||
"""品种推荐 SSE 推送与后台刷新。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from db_conn import connect_db
|
||||
from kline_stream import sse_format
|
||||
from recommend_store import (
|
||||
load_recommend_cache,
|
||||
recommend_cache_needs_refresh,
|
||||
recommend_payload,
|
||||
refresh_recommend_cache,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL_SEC = 3600
|
||||
_refresh_lock = threading.Lock()
|
||||
_refresh_running = False
|
||||
|
||||
|
||||
def schedule_recommend_refresh(
|
||||
*,
|
||||
db_path: str,
|
||||
get_capital_fn: Callable,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
init_tables_fn: Callable | None = None,
|
||||
get_mode_fn: Callable[[], str] | None = None,
|
||||
get_max_margin_pct_fn: Callable[[], float] | None = None,
|
||||
get_sizing_mode_fn: Callable[[], str] | None = None,
|
||||
get_fixed_lots_fn: Callable[[], int] | None = None,
|
||||
) -> None:
|
||||
"""后台刷新推荐缓存(不阻塞页面请求)。"""
|
||||
global _refresh_running
|
||||
with _refresh_lock:
|
||||
if _refresh_running:
|
||||
return
|
||||
_refresh_running = True
|
||||
|
||||
def _run() -> None:
|
||||
global _refresh_running
|
||||
try:
|
||||
conn = connect_db(db_path)
|
||||
try:
|
||||
if init_tables_fn:
|
||||
init_tables_fn(conn)
|
||||
capital = float(get_capital_fn(conn) or 0)
|
||||
mode = get_mode_fn() if get_mode_fn else "simulation"
|
||||
max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0
|
||||
cached = load_recommend_cache(conn)
|
||||
if not recommend_cache_needs_refresh(cached, capital=capital):
|
||||
payload = recommend_payload(
|
||||
conn,
|
||||
live_capital=capital,
|
||||
max_margin_pct=max_pct,
|
||||
trading_mode=mode,
|
||||
sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed",
|
||||
fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1,
|
||||
)
|
||||
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
||||
return
|
||||
refresh_recommend_cache(
|
||||
conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct,
|
||||
)
|
||||
cached = load_recommend_cache(conn)
|
||||
logger.info(
|
||||
"品种推荐后台刷新完成,capital=%.2f rows=%d",
|
||||
capital, len(cached.get("rows") or []),
|
||||
)
|
||||
payload = recommend_payload(
|
||||
conn,
|
||||
live_capital=capital,
|
||||
max_margin_pct=max_pct,
|
||||
trading_mode=mode,
|
||||
sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed",
|
||||
fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
||||
except Exception as exc:
|
||||
logger.warning("recommend background refresh failed: %s", exc)
|
||||
finally:
|
||||
with _refresh_lock:
|
||||
_refresh_running = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="recommend-refresh").start()
|
||||
|
||||
|
||||
class RecommendStreamHub:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list[queue.Queue] = []
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue(maxsize=8)
|
||||
with self._lock:
|
||||
self._subs.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subs.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def broadcast(self, event: str, data: dict) -> None:
|
||||
msg = {"event": event, "data": data}
|
||||
with self._lock:
|
||||
subs = list(self._subs)
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
recommend_hub = RecommendStreamHub()
|
||||
|
||||
|
||||
def start_recommend_worker(
|
||||
*,
|
||||
db_path: str,
|
||||
get_capital_fn: Callable,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
init_tables_fn: Callable | None = None,
|
||||
get_mode_fn: Callable[[], str] | None = None,
|
||||
get_max_margin_pct_fn: Callable[[], float] | None = None,
|
||||
get_sizing_mode_fn: Callable[[], str] | None = None,
|
||||
get_fixed_lots_fn: Callable[[], int] | None = None,
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""后台每日刷新推荐(每小时检查一次是否需更新),并推送给 SSE 订阅者。"""
|
||||
|
||||
def _loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
schedule_recommend_refresh(
|
||||
db_path=db_path,
|
||||
get_capital_fn=get_capital_fn,
|
||||
quote_fn=quote_fn,
|
||||
init_tables_fn=init_tables_fn,
|
||||
get_mode_fn=get_mode_fn,
|
||||
get_max_margin_pct_fn=get_max_margin_pct_fn,
|
||||
get_sizing_mode_fn=get_sizing_mode_fn,
|
||||
get_fixed_lots_fn=get_fixed_lots_fn,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("recommend worker failed: %s", exc)
|
||||
time.sleep(max(300, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="recommend-worker").start()
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""可开仓品种 SSE 推送与后台刷新。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from db_conn import connect_db
|
||||
from kline_stream import sse_format
|
||||
from recommend_store import (
|
||||
load_recommend_cache,
|
||||
recommend_cache_needs_refresh,
|
||||
recommend_payload,
|
||||
refresh_recommend_cache,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL_SEC = 3600
|
||||
_refresh_lock = threading.Lock()
|
||||
_refresh_running = False
|
||||
|
||||
|
||||
def schedule_recommend_refresh(
|
||||
*,
|
||||
db_path: str,
|
||||
get_capital_fn: Callable,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
init_tables_fn: Callable | None = None,
|
||||
get_mode_fn: Callable[[], str] | None = None,
|
||||
get_max_margin_pct_fn: Callable[[], float] | None = None,
|
||||
get_sizing_mode_fn: Callable[[], str] | None = None,
|
||||
get_fixed_lots_fn: Callable[[], int] | None = None,
|
||||
) -> None:
|
||||
"""后台刷新可开仓品种缓存(不阻塞页面请求)。"""
|
||||
global _refresh_running
|
||||
with _refresh_lock:
|
||||
if _refresh_running:
|
||||
return
|
||||
_refresh_running = True
|
||||
|
||||
def _run() -> None:
|
||||
global _refresh_running
|
||||
try:
|
||||
conn = connect_db(db_path)
|
||||
try:
|
||||
if init_tables_fn:
|
||||
init_tables_fn(conn)
|
||||
capital = float(get_capital_fn(conn) or 0)
|
||||
mode = get_mode_fn() if get_mode_fn else "simulation"
|
||||
max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0
|
||||
cached = load_recommend_cache(conn)
|
||||
if not recommend_cache_needs_refresh(cached, capital=capital):
|
||||
payload = recommend_payload(
|
||||
conn,
|
||||
live_capital=capital,
|
||||
max_margin_pct=max_pct,
|
||||
trading_mode=mode,
|
||||
sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed",
|
||||
fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1,
|
||||
)
|
||||
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
||||
return
|
||||
refresh_recommend_cache(
|
||||
conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct,
|
||||
)
|
||||
cached = load_recommend_cache(conn)
|
||||
logger.info(
|
||||
"可开仓品种后台刷新完成,capital=%.2f rows=%d",
|
||||
capital, len(cached.get("rows") or []),
|
||||
)
|
||||
payload = recommend_payload(
|
||||
conn,
|
||||
live_capital=capital,
|
||||
max_margin_pct=max_pct,
|
||||
trading_mode=mode,
|
||||
sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed",
|
||||
fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
||||
except Exception as exc:
|
||||
logger.warning("recommend background refresh failed: %s", exc)
|
||||
finally:
|
||||
with _refresh_lock:
|
||||
_refresh_running = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="recommend-refresh").start()
|
||||
|
||||
|
||||
class RecommendStreamHub:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list[queue.Queue] = []
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue(maxsize=8)
|
||||
with self._lock:
|
||||
self._subs.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subs.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def broadcast(self, event: str, data: dict) -> None:
|
||||
msg = {"event": event, "data": data}
|
||||
with self._lock:
|
||||
subs = list(self._subs)
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
recommend_hub = RecommendStreamHub()
|
||||
|
||||
|
||||
def start_recommend_worker(
|
||||
*,
|
||||
db_path: str,
|
||||
get_capital_fn: Callable,
|
||||
quote_fn: Callable[[str], Optional[dict]],
|
||||
init_tables_fn: Callable | None = None,
|
||||
get_mode_fn: Callable[[], str] | None = None,
|
||||
get_max_margin_pct_fn: Callable[[], float] | None = None,
|
||||
get_sizing_mode_fn: Callable[[], str] | None = None,
|
||||
get_fixed_lots_fn: Callable[[], int] | None = None,
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
"""后台每日刷新可开仓列表(每小时检查一次是否需更新),并推送给 SSE 订阅者。"""
|
||||
|
||||
def _loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
schedule_recommend_refresh(
|
||||
db_path=db_path,
|
||||
get_capital_fn=get_capital_fn,
|
||||
quote_fn=quote_fn,
|
||||
init_tables_fn=init_tables_fn,
|
||||
get_mode_fn=get_mode_fn,
|
||||
get_max_margin_pct_fn=get_max_margin_pct_fn,
|
||||
get_sizing_mode_fn=get_sizing_mode_fn,
|
||||
get_fixed_lots_fn=get_fixed_lots_fn,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("recommend worker failed: %s", exc)
|
||||
time.sleep(max(300, interval))
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="recommend-worker").start()
|
||||
|
||||
+339
-334
@@ -1,334 +1,339 @@
|
||||
"""品种推荐:近一周日线走势(多头 / 空头 / 震荡 / 转多 / 转空)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from kline_chart import fetch_sina_klines, ths_to_sina_chart_symbol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAILY_LOOKBACK = 7
|
||||
OVERLAP_WINDOW = 3
|
||||
OVERLAP_RANGE_THRESHOLD = 0.70
|
||||
KLINE_FETCH_TIMEOUT = 5
|
||||
|
||||
TREND_LONG = "long"
|
||||
TREND_SHORT = "short"
|
||||
TREND_RANGE = "range"
|
||||
TREND_BREAK_LONG = "break_long"
|
||||
TREND_BREAK_SHORT = "break_short"
|
||||
|
||||
|
||||
def _bar_ohlc(bar: dict) -> tuple[float, float, float, float]:
|
||||
o = float(bar.get("o") or bar.get("open") or 0)
|
||||
h = float(bar.get("h") or bar.get("high") or o)
|
||||
l = float(bar.get("l") or bar.get("low") or o)
|
||||
c = float(bar.get("c") or bar.get("close") or o)
|
||||
return o, h, l, c
|
||||
|
||||
|
||||
def kline_overlap_ratio(bars: list) -> float:
|
||||
"""三根 K 线高低价区间的重叠度 = 交集 / 并集(0~1)。"""
|
||||
if len(bars) < OVERLAP_WINDOW:
|
||||
return 0.0
|
||||
chunk = bars[-OVERLAP_WINDOW:]
|
||||
lows, highs = [], []
|
||||
for bar in chunk:
|
||||
_, h, l, _ = _bar_ohlc(bar)
|
||||
if h <= 0 and l <= 0:
|
||||
continue
|
||||
lows.append(l)
|
||||
highs.append(h)
|
||||
if len(lows) < OVERLAP_WINDOW:
|
||||
return 0.0
|
||||
overlap = max(0.0, min(highs) - max(lows))
|
||||
union = max(highs) - min(lows)
|
||||
if union <= 0:
|
||||
return 1.0 if overlap > 0 else 0.0
|
||||
return overlap / union
|
||||
|
||||
|
||||
def _direction_from_closes(bars: list) -> str:
|
||||
if len(bars) < 2:
|
||||
return TREND_RANGE
|
||||
closes = [_bar_ohlc(b)[3] for b in bars if _bar_ohlc(b)[3] > 0]
|
||||
if len(closes) < 2:
|
||||
return TREND_RANGE
|
||||
if closes[-1] > closes[0]:
|
||||
return TREND_LONG
|
||||
if closes[-1] < closes[0]:
|
||||
return TREND_SHORT
|
||||
return TREND_RANGE
|
||||
|
||||
|
||||
def _bar_ohlcv(bar: dict) -> tuple[float, float, float, float, float]:
|
||||
o, h, l, c = _bar_ohlc(bar)
|
||||
v = float(bar.get("v") or bar.get("volume") or 0)
|
||||
return o, h, l, c, v
|
||||
|
||||
|
||||
def compute_daily_quote_stats(bars: list) -> dict:
|
||||
"""从日线提取:跳空、昨收、今开、昨涨跌、昨振幅、成交量。"""
|
||||
empty = {
|
||||
"gap": "",
|
||||
"gap_label": "—",
|
||||
"gap_pct": None,
|
||||
"prev_close": None,
|
||||
"today_open": None,
|
||||
"yesterday_change": None,
|
||||
"yesterday_change_pct": None,
|
||||
"yesterday_amplitude_pct": None,
|
||||
"volume": None,
|
||||
}
|
||||
if len(bars) < 2:
|
||||
return empty
|
||||
|
||||
t_o, _, _, _, t_v = _bar_ohlcv(bars[-1])
|
||||
y_o, y_h, y_l, y_c, y_v = _bar_ohlcv(bars[-2])
|
||||
if y_c <= 0:
|
||||
return empty
|
||||
|
||||
prev_close = round(y_c, 4)
|
||||
today_open = round(t_o, 4) if t_o > 0 else None
|
||||
|
||||
gap, gap_label, gap_pct = "none", "否", 0.0
|
||||
if today_open is not None and today_open > y_c:
|
||||
gap, gap_label = "up", "跳空高开"
|
||||
gap_pct = (today_open - y_c) / y_c * 100
|
||||
elif today_open is not None and today_open < y_c:
|
||||
gap, gap_label = "down", "跳空低开"
|
||||
gap_pct = (today_open - y_c) / y_c * 100
|
||||
|
||||
if len(bars) >= 3:
|
||||
_, _, _, p_c, _ = _bar_ohlcv(bars[-3])
|
||||
base = p_c if p_c > 0 else y_o
|
||||
else:
|
||||
base = y_o if y_o > 0 else y_c
|
||||
|
||||
y_change = y_c - base if base > 0 else None
|
||||
y_change_pct = (y_change / base * 100) if y_change is not None and base > 0 else None
|
||||
y_amp = ((y_h - y_l) / base * 100) if base > 0 and y_h >= y_l else None
|
||||
vol = y_v if y_v > 0 else (t_v if t_v > 0 else None)
|
||||
|
||||
return {
|
||||
"gap": gap,
|
||||
"gap_label": gap_label,
|
||||
"gap_pct": round(gap_pct, 2) if gap != "none" else 0.0,
|
||||
"prev_close": prev_close,
|
||||
"today_open": today_open,
|
||||
"yesterday_change": round(y_change, 4) if y_change is not None else None,
|
||||
"yesterday_change_pct": round(y_change_pct, 2) if y_change_pct is not None else None,
|
||||
"yesterday_amplitude_pct": round(y_amp, 2) if y_amp is not None else None,
|
||||
"volume": int(vol) if vol is not None else None,
|
||||
"volume_unit": "lot",
|
||||
}
|
||||
|
||||
|
||||
def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict:
|
||||
"""根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。"""
|
||||
empty = {
|
||||
"trend": "",
|
||||
"trend_label": "—",
|
||||
"trend_transition": False,
|
||||
"trend_overlap_pct": None,
|
||||
"trend_prev_overlap_pct": None,
|
||||
}
|
||||
if len(bars) < OVERLAP_WINDOW:
|
||||
return empty
|
||||
|
||||
recent = bars[-DAILY_LOOKBACK:] if len(bars) > DAILY_LOOKBACK else bars
|
||||
curr_overlap = kline_overlap_ratio(recent)
|
||||
prev_overlap = kline_overlap_ratio(recent[:-OVERLAP_WINDOW]) if len(recent) >= OVERLAP_WINDOW * 2 else 0.0
|
||||
|
||||
curr_range = curr_overlap >= overlap_threshold
|
||||
prev_range = prev_overlap >= overlap_threshold
|
||||
|
||||
if curr_range:
|
||||
trend, label = TREND_RANGE, "震荡"
|
||||
transition = False
|
||||
else:
|
||||
direction = _direction_from_closes(recent[-OVERLAP_WINDOW:])
|
||||
if direction == TREND_LONG:
|
||||
trend, label = TREND_LONG, "多头"
|
||||
elif direction == TREND_SHORT:
|
||||
trend, label = TREND_SHORT, "空头"
|
||||
else:
|
||||
trend, label = TREND_RANGE, "震荡"
|
||||
transition = prev_range and trend in (TREND_LONG, TREND_SHORT)
|
||||
if transition:
|
||||
if trend == TREND_LONG:
|
||||
trend, label = TREND_BREAK_LONG, "转多"
|
||||
else:
|
||||
trend, label = TREND_BREAK_SHORT, "转空"
|
||||
|
||||
return {
|
||||
"trend": trend,
|
||||
"trend_label": label,
|
||||
"trend_transition": transition,
|
||||
"trend_overlap_pct": round(curr_overlap * 100, 1),
|
||||
"trend_prev_overlap_pct": round(prev_overlap * 100, 1) if prev_overlap else None,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_daily_bars(raw: list) -> list:
|
||||
out = []
|
||||
for row in raw:
|
||||
if isinstance(row, list) and len(row) >= 5:
|
||||
out.append({
|
||||
"d": str(row[0]),
|
||||
"o": float(row[1]),
|
||||
"h": float(row[2]),
|
||||
"l": float(row[3]),
|
||||
"c": float(row[4]),
|
||||
"v": float(row[5]) if len(row) > 5 and row[5] else 0.0,
|
||||
})
|
||||
elif isinstance(row, dict) and row.get("d"):
|
||||
out.append({
|
||||
"d": str(row["d"]),
|
||||
"o": float(row.get("o", 0) or 0),
|
||||
"h": float(row.get("h", 0) or 0),
|
||||
"l": float(row.get("l", 0) or 0),
|
||||
"c": float(row.get("c", 0) or 0),
|
||||
"v": float(row.get("v", 0) or 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_sina_daily_quick(chart_sym: str) -> list:
|
||||
url = (
|
||||
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
|
||||
f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}"
|
||||
)
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, timeout=KLINE_FETCH_TIMEOUT,
|
||||
headers={"Referer": "https://finance.sina.com.cn"},
|
||||
)
|
||||
raw = resp.json()
|
||||
if raw and isinstance(raw, list):
|
||||
bars = _normalize_daily_bars(raw)
|
||||
if bars:
|
||||
return bars
|
||||
except Exception as exc:
|
||||
logger.debug("quick daily kline failed %s: %s", chart_sym, exc)
|
||||
return []
|
||||
|
||||
|
||||
def fetch_week_daily_bars(
|
||||
symbol: str,
|
||||
*,
|
||||
fetch_fn: Callable[[str, str], list] | None = None,
|
||||
) -> list:
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
return []
|
||||
if fetch_fn:
|
||||
try:
|
||||
bars = fetch_fn(sym, "d") or []
|
||||
except Exception as exc:
|
||||
logger.debug("fetch week daily failed %s: %s", sym, exc)
|
||||
return []
|
||||
return bars[-DAILY_LOOKBACK:] if bars else []
|
||||
|
||||
chart_sym = ths_to_sina_chart_symbol(sym)
|
||||
if not chart_sym:
|
||||
return []
|
||||
bars = _fetch_sina_daily_quick(chart_sym)
|
||||
if not bars:
|
||||
try:
|
||||
bars = fetch_sina_klines(sym, "d") or []
|
||||
except Exception as exc:
|
||||
logger.debug("fetch week daily fallback failed %s: %s", sym, exc)
|
||||
return []
|
||||
return bars[-DAILY_LOOKBACK:] if bars else []
|
||||
|
||||
|
||||
def analyze_product_daily(
|
||||
symbol: str,
|
||||
*,
|
||||
fetch_fn: Callable[[str, str], list] | None = None,
|
||||
) -> dict:
|
||||
"""拉取主力合约一周日线:走势 + 跳空/量价统计。"""
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
out = analyze_daily_trend([])
|
||||
out.update(compute_daily_quote_stats([]))
|
||||
return out
|
||||
bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn)
|
||||
out = analyze_daily_trend(bars)
|
||||
out.update(compute_daily_quote_stats(bars))
|
||||
return out
|
||||
|
||||
|
||||
def analyze_product_trend(
|
||||
symbol: str,
|
||||
*,
|
||||
fetch_fn: Callable[[str, str], list] | None = None,
|
||||
) -> dict:
|
||||
return analyze_product_daily(symbol, fetch_fn=fetch_fn)
|
||||
|
||||
|
||||
GAP_SORT_RANK = {"up": 2, "down": 1, "none": 0, "": -1}
|
||||
TREND_SORT_RANK = {
|
||||
TREND_BREAK_LONG: 0,
|
||||
TREND_BREAK_SHORT: 0,
|
||||
TREND_LONG: 1,
|
||||
TREND_SHORT: 2,
|
||||
TREND_RANGE: 3,
|
||||
"": 9,
|
||||
}
|
||||
|
||||
|
||||
def recommend_sort_key(row: dict, sort_by: str = "trend", *, desc: bool = True) -> tuple:
|
||||
"""可排序字段:trend / gap / volume / amplitude。"""
|
||||
key = (sort_by or "trend").strip().lower()
|
||||
if key == "gap":
|
||||
primary = GAP_SORT_RANK.get(row.get("gap") or "", -1)
|
||||
secondary = abs(float(row.get("gap_pct") or 0))
|
||||
elif key == "volume":
|
||||
primary = float(row.get("volume") or 0)
|
||||
secondary = 0.0
|
||||
elif key == "amplitude":
|
||||
primary = float(row.get("yesterday_amplitude_pct") or 0)
|
||||
secondary = 0.0
|
||||
else:
|
||||
primary = TREND_SORT_RANK.get(row.get("trend") or "", 9)
|
||||
secondary = -(int(row.get("max_lots") or 0))
|
||||
|
||||
if desc:
|
||||
return (-primary, -secondary, row.get("name") or "")
|
||||
return (primary, secondary, row.get("name") or "")
|
||||
|
||||
|
||||
def sort_recommend_rows(
|
||||
rows: list[dict],
|
||||
*,
|
||||
sort_by: str = "trend",
|
||||
desc: bool = True,
|
||||
) -> list[dict]:
|
||||
return sorted(rows, key=lambda r: recommend_sort_key(r, sort_by, desc=desc))
|
||||
|
||||
|
||||
def trend_sort_key(row: dict) -> tuple:
|
||||
"""转多/转空优先,其次多头/空头,震荡靠后。"""
|
||||
trend = (row.get("trend") or "").strip()
|
||||
priority = {
|
||||
TREND_BREAK_LONG: 0,
|
||||
TREND_BREAK_SHORT: 0,
|
||||
TREND_LONG: 1,
|
||||
TREND_SHORT: 1,
|
||||
TREND_RANGE: 2,
|
||||
}
|
||||
status_order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3}
|
||||
return (
|
||||
priority.get(trend, 3),
|
||||
status_order.get(row.get("status") or "", 9),
|
||||
-(int(row.get("max_lots") or 0)),
|
||||
)
|
||||
|
||||
|
||||
def sort_recommend_by_trend(rows: list[dict]) -> list[dict]:
|
||||
return sort_recommend_rows(rows, sort_by="trend", desc=True)
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""可开仓品种:近一周日线走势(多头 / 空头 / 震荡 / 转多 / 转空)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from kline_chart import fetch_sina_klines, ths_to_sina_chart_symbol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAILY_LOOKBACK = 7
|
||||
OVERLAP_WINDOW = 3
|
||||
OVERLAP_RANGE_THRESHOLD = 0.70
|
||||
KLINE_FETCH_TIMEOUT = 5
|
||||
|
||||
TREND_LONG = "long"
|
||||
TREND_SHORT = "short"
|
||||
TREND_RANGE = "range"
|
||||
TREND_BREAK_LONG = "break_long"
|
||||
TREND_BREAK_SHORT = "break_short"
|
||||
|
||||
|
||||
def _bar_ohlc(bar: dict) -> tuple[float, float, float, float]:
|
||||
o = float(bar.get("o") or bar.get("open") or 0)
|
||||
h = float(bar.get("h") or bar.get("high") or o)
|
||||
l = float(bar.get("l") or bar.get("low") or o)
|
||||
c = float(bar.get("c") or bar.get("close") or o)
|
||||
return o, h, l, c
|
||||
|
||||
|
||||
def kline_overlap_ratio(bars: list) -> float:
|
||||
"""三根 K 线高低价区间的重叠度 = 交集 / 并集(0~1)。"""
|
||||
if len(bars) < OVERLAP_WINDOW:
|
||||
return 0.0
|
||||
chunk = bars[-OVERLAP_WINDOW:]
|
||||
lows, highs = [], []
|
||||
for bar in chunk:
|
||||
_, h, l, _ = _bar_ohlc(bar)
|
||||
if h <= 0 and l <= 0:
|
||||
continue
|
||||
lows.append(l)
|
||||
highs.append(h)
|
||||
if len(lows) < OVERLAP_WINDOW:
|
||||
return 0.0
|
||||
overlap = max(0.0, min(highs) - max(lows))
|
||||
union = max(highs) - min(lows)
|
||||
if union <= 0:
|
||||
return 1.0 if overlap > 0 else 0.0
|
||||
return overlap / union
|
||||
|
||||
|
||||
def _direction_from_closes(bars: list) -> str:
|
||||
if len(bars) < 2:
|
||||
return TREND_RANGE
|
||||
closes = [_bar_ohlc(b)[3] for b in bars if _bar_ohlc(b)[3] > 0]
|
||||
if len(closes) < 2:
|
||||
return TREND_RANGE
|
||||
if closes[-1] > closes[0]:
|
||||
return TREND_LONG
|
||||
if closes[-1] < closes[0]:
|
||||
return TREND_SHORT
|
||||
return TREND_RANGE
|
||||
|
||||
|
||||
def _bar_ohlcv(bar: dict) -> tuple[float, float, float, float, float]:
|
||||
o, h, l, c = _bar_ohlc(bar)
|
||||
v = float(bar.get("v") or bar.get("volume") or 0)
|
||||
return o, h, l, c, v
|
||||
|
||||
|
||||
def compute_daily_quote_stats(bars: list) -> dict:
|
||||
"""从日线提取:跳空、昨收、今开、昨涨跌、昨振幅、成交量。"""
|
||||
empty = {
|
||||
"gap": "",
|
||||
"gap_label": "—",
|
||||
"gap_pct": None,
|
||||
"prev_close": None,
|
||||
"today_open": None,
|
||||
"yesterday_change": None,
|
||||
"yesterday_change_pct": None,
|
||||
"yesterday_amplitude_pct": None,
|
||||
"volume": None,
|
||||
}
|
||||
if len(bars) < 2:
|
||||
return empty
|
||||
|
||||
t_o, _, _, _, t_v = _bar_ohlcv(bars[-1])
|
||||
y_o, y_h, y_l, y_c, y_v = _bar_ohlcv(bars[-2])
|
||||
if y_c <= 0:
|
||||
return empty
|
||||
|
||||
prev_close = round(y_c, 4)
|
||||
today_open = round(t_o, 4) if t_o > 0 else None
|
||||
|
||||
gap, gap_label, gap_pct = "none", "否", 0.0
|
||||
if today_open is not None and today_open > y_c:
|
||||
gap, gap_label = "up", "跳空高开"
|
||||
gap_pct = (today_open - y_c) / y_c * 100
|
||||
elif today_open is not None and today_open < y_c:
|
||||
gap, gap_label = "down", "跳空低开"
|
||||
gap_pct = (today_open - y_c) / y_c * 100
|
||||
|
||||
if len(bars) >= 3:
|
||||
_, _, _, p_c, _ = _bar_ohlcv(bars[-3])
|
||||
base = p_c if p_c > 0 else y_o
|
||||
else:
|
||||
base = y_o if y_o > 0 else y_c
|
||||
|
||||
y_change = y_c - base if base > 0 else None
|
||||
y_change_pct = (y_change / base * 100) if y_change is not None and base > 0 else None
|
||||
y_amp = ((y_h - y_l) / base * 100) if base > 0 and y_h >= y_l else None
|
||||
vol = y_v if y_v > 0 else (t_v if t_v > 0 else None)
|
||||
|
||||
return {
|
||||
"gap": gap,
|
||||
"gap_label": gap_label,
|
||||
"gap_pct": round(gap_pct, 2) if gap != "none" else 0.0,
|
||||
"prev_close": prev_close,
|
||||
"today_open": today_open,
|
||||
"yesterday_change": round(y_change, 4) if y_change is not None else None,
|
||||
"yesterday_change_pct": round(y_change_pct, 2) if y_change_pct is not None else None,
|
||||
"yesterday_amplitude_pct": round(y_amp, 2) if y_amp is not None else None,
|
||||
"volume": int(vol) if vol is not None else None,
|
||||
"volume_unit": "lot",
|
||||
}
|
||||
|
||||
|
||||
def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict:
|
||||
"""根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。"""
|
||||
empty = {
|
||||
"trend": "",
|
||||
"trend_label": "—",
|
||||
"trend_transition": False,
|
||||
"trend_overlap_pct": None,
|
||||
"trend_prev_overlap_pct": None,
|
||||
}
|
||||
if len(bars) < OVERLAP_WINDOW:
|
||||
return empty
|
||||
|
||||
recent = bars[-DAILY_LOOKBACK:] if len(bars) > DAILY_LOOKBACK else bars
|
||||
curr_overlap = kline_overlap_ratio(recent)
|
||||
prev_overlap = kline_overlap_ratio(recent[:-OVERLAP_WINDOW]) if len(recent) >= OVERLAP_WINDOW * 2 else 0.0
|
||||
|
||||
curr_range = curr_overlap >= overlap_threshold
|
||||
prev_range = prev_overlap >= overlap_threshold
|
||||
|
||||
if curr_range:
|
||||
trend, label = TREND_RANGE, "震荡"
|
||||
transition = False
|
||||
else:
|
||||
direction = _direction_from_closes(recent[-OVERLAP_WINDOW:])
|
||||
if direction == TREND_LONG:
|
||||
trend, label = TREND_LONG, "多头"
|
||||
elif direction == TREND_SHORT:
|
||||
trend, label = TREND_SHORT, "空头"
|
||||
else:
|
||||
trend, label = TREND_RANGE, "震荡"
|
||||
transition = prev_range and trend in (TREND_LONG, TREND_SHORT)
|
||||
if transition:
|
||||
if trend == TREND_LONG:
|
||||
trend, label = TREND_BREAK_LONG, "转多"
|
||||
else:
|
||||
trend, label = TREND_BREAK_SHORT, "转空"
|
||||
|
||||
return {
|
||||
"trend": trend,
|
||||
"trend_label": label,
|
||||
"trend_transition": transition,
|
||||
"trend_overlap_pct": round(curr_overlap * 100, 1),
|
||||
"trend_prev_overlap_pct": round(prev_overlap * 100, 1) if prev_overlap else None,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_daily_bars(raw: list) -> list:
|
||||
out = []
|
||||
for row in raw:
|
||||
if isinstance(row, list) and len(row) >= 5:
|
||||
out.append({
|
||||
"d": str(row[0]),
|
||||
"o": float(row[1]),
|
||||
"h": float(row[2]),
|
||||
"l": float(row[3]),
|
||||
"c": float(row[4]),
|
||||
"v": float(row[5]) if len(row) > 5 and row[5] else 0.0,
|
||||
})
|
||||
elif isinstance(row, dict) and row.get("d"):
|
||||
out.append({
|
||||
"d": str(row["d"]),
|
||||
"o": float(row.get("o", 0) or 0),
|
||||
"h": float(row.get("h", 0) or 0),
|
||||
"l": float(row.get("l", 0) or 0),
|
||||
"c": float(row.get("c", 0) or 0),
|
||||
"v": float(row.get("v", 0) or 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_sina_daily_quick(chart_sym: str) -> list:
|
||||
url = (
|
||||
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
|
||||
f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}"
|
||||
)
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, timeout=KLINE_FETCH_TIMEOUT,
|
||||
headers={"Referer": "https://finance.sina.com.cn"},
|
||||
)
|
||||
raw = resp.json()
|
||||
if raw and isinstance(raw, list):
|
||||
bars = _normalize_daily_bars(raw)
|
||||
if bars:
|
||||
return bars
|
||||
except Exception as exc:
|
||||
logger.debug("quick daily kline failed %s: %s", chart_sym, exc)
|
||||
return []
|
||||
|
||||
|
||||
def fetch_week_daily_bars(
|
||||
symbol: str,
|
||||
*,
|
||||
fetch_fn: Callable[[str, str], list] | None = None,
|
||||
) -> list:
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
return []
|
||||
if fetch_fn:
|
||||
try:
|
||||
bars = fetch_fn(sym, "d") or []
|
||||
except Exception as exc:
|
||||
logger.debug("fetch week daily failed %s: %s", sym, exc)
|
||||
return []
|
||||
return bars[-DAILY_LOOKBACK:] if bars else []
|
||||
|
||||
chart_sym = ths_to_sina_chart_symbol(sym)
|
||||
if not chart_sym:
|
||||
return []
|
||||
bars = _fetch_sina_daily_quick(chart_sym)
|
||||
if not bars:
|
||||
try:
|
||||
bars = fetch_sina_klines(sym, "d") or []
|
||||
except Exception as exc:
|
||||
logger.debug("fetch week daily fallback failed %s: %s", sym, exc)
|
||||
return []
|
||||
return bars[-DAILY_LOOKBACK:] if bars else []
|
||||
|
||||
|
||||
def analyze_product_daily(
|
||||
symbol: str,
|
||||
*,
|
||||
fetch_fn: Callable[[str, str], list] | None = None,
|
||||
) -> dict:
|
||||
"""拉取主力合约一周日线:走势 + 跳空/量价统计。"""
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
out = analyze_daily_trend([])
|
||||
out.update(compute_daily_quote_stats([]))
|
||||
return out
|
||||
bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn)
|
||||
out = analyze_daily_trend(bars)
|
||||
out.update(compute_daily_quote_stats(bars))
|
||||
return out
|
||||
|
||||
|
||||
def analyze_product_trend(
|
||||
symbol: str,
|
||||
*,
|
||||
fetch_fn: Callable[[str, str], list] | None = None,
|
||||
) -> dict:
|
||||
return analyze_product_daily(symbol, fetch_fn=fetch_fn)
|
||||
|
||||
|
||||
GAP_SORT_RANK = {"up": 2, "down": 1, "none": 0, "": -1}
|
||||
TREND_SORT_RANK = {
|
||||
TREND_BREAK_LONG: 0,
|
||||
TREND_BREAK_SHORT: 0,
|
||||
TREND_LONG: 1,
|
||||
TREND_SHORT: 2,
|
||||
TREND_RANGE: 3,
|
||||
"": 9,
|
||||
}
|
||||
|
||||
|
||||
def recommend_sort_key(row: dict, sort_by: str = "trend", *, desc: bool = True) -> tuple:
|
||||
"""可排序字段:trend / gap / volume / amplitude。"""
|
||||
key = (sort_by or "trend").strip().lower()
|
||||
if key == "gap":
|
||||
primary = GAP_SORT_RANK.get(row.get("gap") or "", -1)
|
||||
secondary = abs(float(row.get("gap_pct") or 0))
|
||||
elif key == "volume":
|
||||
primary = float(row.get("volume") or 0)
|
||||
secondary = 0.0
|
||||
elif key == "amplitude":
|
||||
primary = float(row.get("yesterday_amplitude_pct") or 0)
|
||||
secondary = 0.0
|
||||
else:
|
||||
primary = TREND_SORT_RANK.get(row.get("trend") or "", 9)
|
||||
secondary = -(int(row.get("max_lots") or 0))
|
||||
|
||||
if desc:
|
||||
return (-primary, -secondary, row.get("name") or "")
|
||||
return (primary, secondary, row.get("name") or "")
|
||||
|
||||
|
||||
def sort_recommend_rows(
|
||||
rows: list[dict],
|
||||
*,
|
||||
sort_by: str = "trend",
|
||||
desc: bool = True,
|
||||
) -> list[dict]:
|
||||
return sorted(rows, key=lambda r: recommend_sort_key(r, sort_by, desc=desc))
|
||||
|
||||
|
||||
def trend_sort_key(row: dict) -> tuple:
|
||||
"""转多/转空优先,其次多头/空头,震荡靠后。"""
|
||||
trend = (row.get("trend") or "").strip()
|
||||
priority = {
|
||||
TREND_BREAK_LONG: 0,
|
||||
TREND_BREAK_SHORT: 0,
|
||||
TREND_LONG: 1,
|
||||
TREND_SHORT: 1,
|
||||
TREND_RANGE: 2,
|
||||
}
|
||||
status_order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3}
|
||||
return (
|
||||
priority.get(trend, 3),
|
||||
status_order.get(row.get("status") or "", 9),
|
||||
-(int(row.get("max_lots") or 0)),
|
||||
)
|
||||
|
||||
|
||||
def sort_recommend_by_trend(rows: list[dict]) -> list[dict]:
|
||||
return sort_recommend_rows(rows, sort_by="trend", desc=True)
|
||||
|
||||
+29
-24
@@ -1,24 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从 .env 重置管理员账号(服务器上忘记密码时使用)"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
load_dotenv(os.path.join(BASE, ".env"))
|
||||
|
||||
sys.path.insert(0, BASE)
|
||||
from app import set_setting, get_setting # noqa: E402
|
||||
|
||||
username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin"
|
||||
password = os.getenv("ADMIN_PASSWORD", "").strip()
|
||||
if not password or password == "change-me-on-first-login":
|
||||
print("请在 .env 中设置 ADMIN_PASSWORD 后再运行此脚本")
|
||||
sys.exit(1)
|
||||
|
||||
old_username = get_setting("admin_username")
|
||||
set_setting("admin_username", username)
|
||||
set_setting("admin_password_hash", generate_password_hash(password))
|
||||
print(f"已重置管理员: {username}(原账号: {old_username or '无'})")
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""从 .env 重置管理员账号(服务器上忘记密码时使用)"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
load_dotenv(os.path.join(BASE, ".env"))
|
||||
|
||||
sys.path.insert(0, BASE)
|
||||
from app import set_setting, get_setting # noqa: E402
|
||||
|
||||
username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin"
|
||||
password = os.getenv("ADMIN_PASSWORD", "").strip()
|
||||
if not password or password == "change-me-on-first-login":
|
||||
print("请在 .env 中设置 ADMIN_PASSWORD 后再运行此脚本")
|
||||
sys.exit(1)
|
||||
|
||||
old_username = get_setting("admin_username")
|
||||
set_setting("admin_username", username)
|
||||
set_setting("admin_password_hash", generate_password_hash(password))
|
||||
print(f"已重置管理员: {username}(原账号: {old_username or '无'})")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
|
||||
+307
-302
@@ -1,302 +1,307 @@
|
||||
"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional, TypeVar
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
STATUS_NORMAL = "normal"
|
||||
STATUS_FREEZE_1H = "freeze_1h"
|
||||
STATUS_FREEZE_4H = "freeze_4h"
|
||||
STATUS_DAILY = "freeze_daily"
|
||||
STATUS_FREEZE_POSITION = "freeze_position"
|
||||
|
||||
STATUS_LABELS = {
|
||||
STATUS_NORMAL: "正常",
|
||||
STATUS_FREEZE_1H: "1h冻结",
|
||||
STATUS_FREEZE_4H: "4h冻结",
|
||||
STATUS_DAILY: "日冻结",
|
||||
STATUS_FREEZE_POSITION: "仓位上限冻结",
|
||||
}
|
||||
|
||||
MOOD_ISSUE_OPTIONS = (
|
||||
"怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规",
|
||||
)
|
||||
|
||||
CLOSE_SOURCE_USER = "user_instance"
|
||||
CLOSE_SOURCE_TREND_STOP = "user_trend_stop"
|
||||
|
||||
|
||||
def _app_tz():
|
||||
name = (os.getenv("APP_TIMEZONE") or "Asia/Shanghai").strip()
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def risk_control_enabled() -> bool:
|
||||
raw = (os.getenv("RISK_CONTROL_ENABLED") or "true").strip().lower()
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def cooling_hours_manual() -> float:
|
||||
try:
|
||||
return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL", "4")))
|
||||
except (TypeError, ValueError):
|
||||
return 4.0
|
||||
|
||||
|
||||
def cooling_hours_manual_journal() -> float:
|
||||
try:
|
||||
return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL_JOURNAL", "1")))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def manual_close_daily_limit() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2")))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
|
||||
def max_active_positions() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def trading_day_reset_hour() -> int:
|
||||
try:
|
||||
return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))))
|
||||
except (TypeError, ValueError):
|
||||
return 8
|
||||
|
||||
|
||||
_SCHEMA_READY = False
|
||||
|
||||
|
||||
def _db_retry(action: Callable[[], T], *, retries: int = 8, base_delay: float = 0.03) -> T:
|
||||
last: sqlite3.OperationalError | None = None
|
||||
for i in range(retries):
|
||||
try:
|
||||
return action()
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
last = exc
|
||||
time.sleep(base_delay * (2 ** i))
|
||||
if last is not None:
|
||||
raise last
|
||||
raise RuntimeError("db retry failed")
|
||||
|
||||
|
||||
def ensure_account_risk_schema(conn) -> None:
|
||||
global _SCHEMA_READY
|
||||
if _SCHEMA_READY:
|
||||
return
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS account_risk_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
trading_day TEXT,
|
||||
manual_close_count INTEGER DEFAULT 0,
|
||||
cooloff_until_ms INTEGER,
|
||||
cooloff_hours INTEGER,
|
||||
daily_frozen INTEGER DEFAULT 0,
|
||||
last_close_at_ms INTEGER,
|
||||
updated_at TEXT
|
||||
)"""
|
||||
)
|
||||
if not conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone():
|
||||
conn.execute(
|
||||
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)"
|
||||
)
|
||||
conn.commit()
|
||||
_SCHEMA_READY = True
|
||||
|
||||
|
||||
def _row_get(row, key, default=None):
|
||||
if row is None:
|
||||
return default
|
||||
try:
|
||||
return row[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _now_ms(now: Optional[datetime] = None) -> int:
|
||||
dt = now or datetime.now(_app_tz())
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=_app_tz())
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
def trading_day_label(now: Optional[datetime] = None) -> str:
|
||||
dt = now or datetime.now(_app_tz())
|
||||
if dt.hour < trading_day_reset_hour():
|
||||
from datetime import timedelta
|
||||
dt = dt - timedelta(days=1)
|
||||
return dt.date().isoformat()
|
||||
|
||||
|
||||
def count_active_trade_monitors(conn) -> int:
|
||||
try:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM trade_order_monitors WHERE status='active'"
|
||||
).fetchone()[0]
|
||||
return int(n or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def parse_mood_issues(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
parts = [str(x).strip() for x in raw if str(x).strip()]
|
||||
else:
|
||||
parts = [x.strip() for x in str(raw).split(",") if x.strip()]
|
||||
return [p for p in parts if p in MOOD_ISSUE_OPTIONS]
|
||||
|
||||
|
||||
def on_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
ensure_account_risk_schema(conn)
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
td = (trading_day or trading_day_label(now)).strip()
|
||||
stored = str(_row_get(row, "trading_day") or "")
|
||||
count = int(_row_get(row, "manual_close_count") or 0)
|
||||
if stored != td:
|
||||
count = 0
|
||||
count += 1
|
||||
close_ms = _now_ms(now)
|
||||
if count >= manual_close_daily_limit():
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
|
||||
daily_frozen=1, cooloff_until_ms=NULL, last_close_at_ms=?, updated_at=? WHERE id=1""",
|
||||
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
return
|
||||
until = close_ms + int(cooling_hours_manual() * 3600 * 1000)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
|
||||
daily_frozen=0, cooloff_until_ms=?, cooloff_hours=?, last_close_at_ms=?, updated_at=? WHERE id=1""",
|
||||
(td, count, until, int(cooling_hours_manual()), close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
|
||||
|
||||
def on_mood_journal_freeze(conn, *, trading_day: str) -> None:
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
ensure_account_risk_schema(conn)
|
||||
td = (trading_day or trading_day_label()).strip()
|
||||
conn.execute(
|
||||
"UPDATE account_risk_state SET trading_day=?, daily_frozen=1, updated_at=? WHERE id=1",
|
||||
(td, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
|
||||
|
||||
def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
||||
"""复盘手动平仓说明后,4h 冷静期降为 1h。"""
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
ensure_account_risk_schema(conn)
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
if int(_row_get(row, "daily_frozen") or 0):
|
||||
return
|
||||
until = _row_get(row, "cooloff_until_ms")
|
||||
if not until:
|
||||
return
|
||||
now_ms = _now_ms(now)
|
||||
if int(until) <= now_ms:
|
||||
return
|
||||
last = int(_row_get(row, "last_close_at_ms") or now_ms)
|
||||
journal_ms = int(cooling_hours_manual_journal() * 3600 * 1000)
|
||||
new_until = max(now_ms, last + journal_ms)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET cooloff_until_ms=?, cooloff_hours=?, updated_at=? WHERE id=1""",
|
||||
(new_until, int(cooling_hours_manual_journal()), datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
|
||||
|
||||
def get_risk_status(conn, *, now: Optional[datetime] = None, active_count: Optional[int] = None) -> dict:
|
||||
def _load() -> dict:
|
||||
ensure_account_risk_schema(conn)
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
td = trading_day_label(now)
|
||||
stored = str(_row_get(row, "trading_day") or "")
|
||||
if stored != td:
|
||||
conn.execute(
|
||||
"UPDATE account_risk_state SET trading_day=?, manual_close_count=0, daily_frozen=0 WHERE id=1 AND trading_day<>?",
|
||||
(td, td),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
|
||||
now_ms = _now_ms(now)
|
||||
daily = int(_row_get(row, "daily_frozen") or 0) == 1
|
||||
until = _row_get(row, "cooloff_until_ms")
|
||||
active = count_active_trade_monitors(conn) if active_count is None else int(active_count)
|
||||
mx = max_active_positions()
|
||||
pos_limit = active >= mx
|
||||
|
||||
if daily:
|
||||
return {
|
||||
"status": STATUS_DAILY,
|
||||
"status_label": STATUS_LABELS[STATUS_DAILY],
|
||||
"can_trade": False,
|
||||
"can_roll": False,
|
||||
"reason": "当日日冻结,禁止新开仓",
|
||||
"active_count": active,
|
||||
"max_active_positions": mx,
|
||||
}
|
||||
if until and int(until) > now_ms:
|
||||
rem = int((int(until) - now_ms) / 1000)
|
||||
hours = float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
|
||||
st = STATUS_FREEZE_1H if hours <= cooling_hours_manual_journal() + 0.01 else STATUS_FREEZE_4H
|
||||
return {
|
||||
"status": st,
|
||||
"status_label": STATUS_LABELS[st],
|
||||
"can_trade": False,
|
||||
"can_roll": pos_limit,
|
||||
"reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m",
|
||||
"freeze_remaining_sec": rem,
|
||||
"active_count": active,
|
||||
"max_active_positions": mx,
|
||||
}
|
||||
if pos_limit:
|
||||
return {
|
||||
"status": STATUS_FREEZE_POSITION,
|
||||
"status_label": STATUS_LABELS[STATUS_FREEZE_POSITION],
|
||||
"can_trade": False,
|
||||
"can_roll": True,
|
||||
"reason": f"已达仓位上限 {active}/{mx}",
|
||||
"active_count": active,
|
||||
"max_active_positions": mx,
|
||||
}
|
||||
return {
|
||||
"status": STATUS_NORMAL,
|
||||
"status_label": STATUS_LABELS[STATUS_NORMAL],
|
||||
"can_trade": True,
|
||||
"can_roll": True,
|
||||
"reason": "可新开仓",
|
||||
"active_count": active,
|
||||
"max_active_positions": mx,
|
||||
}
|
||||
|
||||
return _db_retry(_load)
|
||||
|
||||
|
||||
def assert_can_open(conn, *, active_count: Optional[int] = None) -> Optional[str]:
|
||||
rs = get_risk_status(conn, active_count=active_count)
|
||||
if not rs.get("can_trade"):
|
||||
return rs.get("reason") or "当前不可开仓"
|
||||
return None
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional, TypeVar
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
STATUS_NORMAL = "normal"
|
||||
STATUS_FREEZE_1H = "freeze_1h"
|
||||
STATUS_FREEZE_4H = "freeze_4h"
|
||||
STATUS_DAILY = "freeze_daily"
|
||||
STATUS_FREEZE_POSITION = "freeze_position"
|
||||
|
||||
STATUS_LABELS = {
|
||||
STATUS_NORMAL: "正常",
|
||||
STATUS_FREEZE_1H: "1h冻结",
|
||||
STATUS_FREEZE_4H: "4h冻结",
|
||||
STATUS_DAILY: "日冻结",
|
||||
STATUS_FREEZE_POSITION: "仓位上限冻结",
|
||||
}
|
||||
|
||||
MOOD_ISSUE_OPTIONS = (
|
||||
"怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规",
|
||||
)
|
||||
|
||||
CLOSE_SOURCE_USER = "user_instance"
|
||||
CLOSE_SOURCE_TREND_STOP = "user_trend_stop"
|
||||
|
||||
|
||||
def _app_tz():
|
||||
name = (os.getenv("APP_TIMEZONE") or "Asia/Shanghai").strip()
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def risk_control_enabled() -> bool:
|
||||
raw = (os.getenv("RISK_CONTROL_ENABLED") or "true").strip().lower()
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def cooling_hours_manual() -> float:
|
||||
try:
|
||||
return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL", "4")))
|
||||
except (TypeError, ValueError):
|
||||
return 4.0
|
||||
|
||||
|
||||
def cooling_hours_manual_journal() -> float:
|
||||
try:
|
||||
return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL_JOURNAL", "1")))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def manual_close_daily_limit() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2")))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
|
||||
def max_active_positions() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def trading_day_reset_hour() -> int:
|
||||
try:
|
||||
return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))))
|
||||
except (TypeError, ValueError):
|
||||
return 8
|
||||
|
||||
|
||||
_SCHEMA_READY = False
|
||||
|
||||
|
||||
def _db_retry(action: Callable[[], T], *, retries: int = 8, base_delay: float = 0.03) -> T:
|
||||
last: sqlite3.OperationalError | None = None
|
||||
for i in range(retries):
|
||||
try:
|
||||
return action()
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
last = exc
|
||||
time.sleep(base_delay * (2 ** i))
|
||||
if last is not None:
|
||||
raise last
|
||||
raise RuntimeError("db retry failed")
|
||||
|
||||
|
||||
def ensure_account_risk_schema(conn) -> None:
|
||||
global _SCHEMA_READY
|
||||
if _SCHEMA_READY:
|
||||
return
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS account_risk_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
trading_day TEXT,
|
||||
manual_close_count INTEGER DEFAULT 0,
|
||||
cooloff_until_ms INTEGER,
|
||||
cooloff_hours INTEGER,
|
||||
daily_frozen INTEGER DEFAULT 0,
|
||||
last_close_at_ms INTEGER,
|
||||
updated_at TEXT
|
||||
)"""
|
||||
)
|
||||
if not conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone():
|
||||
conn.execute(
|
||||
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)"
|
||||
)
|
||||
conn.commit()
|
||||
_SCHEMA_READY = True
|
||||
|
||||
|
||||
def _row_get(row, key, default=None):
|
||||
if row is None:
|
||||
return default
|
||||
try:
|
||||
return row[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _now_ms(now: Optional[datetime] = None) -> int:
|
||||
dt = now or datetime.now(_app_tz())
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=_app_tz())
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
def trading_day_label(now: Optional[datetime] = None) -> str:
|
||||
dt = now or datetime.now(_app_tz())
|
||||
if dt.hour < trading_day_reset_hour():
|
||||
from datetime import timedelta
|
||||
dt = dt - timedelta(days=1)
|
||||
return dt.date().isoformat()
|
||||
|
||||
|
||||
def count_active_trade_monitors(conn) -> int:
|
||||
try:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM trade_order_monitors WHERE status='active'"
|
||||
).fetchone()[0]
|
||||
return int(n or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def parse_mood_issues(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
parts = [str(x).strip() for x in raw if str(x).strip()]
|
||||
else:
|
||||
parts = [x.strip() for x in str(raw).split(",") if x.strip()]
|
||||
return [p for p in parts if p in MOOD_ISSUE_OPTIONS]
|
||||
|
||||
|
||||
def on_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
ensure_account_risk_schema(conn)
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
td = (trading_day or trading_day_label(now)).strip()
|
||||
stored = str(_row_get(row, "trading_day") or "")
|
||||
count = int(_row_get(row, "manual_close_count") or 0)
|
||||
if stored != td:
|
||||
count = 0
|
||||
count += 1
|
||||
close_ms = _now_ms(now)
|
||||
if count >= manual_close_daily_limit():
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
|
||||
daily_frozen=1, cooloff_until_ms=NULL, last_close_at_ms=?, updated_at=? WHERE id=1""",
|
||||
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
return
|
||||
until = close_ms + int(cooling_hours_manual() * 3600 * 1000)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
|
||||
daily_frozen=0, cooloff_until_ms=?, cooloff_hours=?, last_close_at_ms=?, updated_at=? WHERE id=1""",
|
||||
(td, count, until, int(cooling_hours_manual()), close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
|
||||
|
||||
def on_mood_journal_freeze(conn, *, trading_day: str) -> None:
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
ensure_account_risk_schema(conn)
|
||||
td = (trading_day or trading_day_label()).strip()
|
||||
conn.execute(
|
||||
"UPDATE account_risk_state SET trading_day=?, daily_frozen=1, updated_at=? WHERE id=1",
|
||||
(td, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
|
||||
|
||||
def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
||||
"""复盘手动平仓说明后,4h 冷静期降为 1h。"""
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
ensure_account_risk_schema(conn)
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
if int(_row_get(row, "daily_frozen") or 0):
|
||||
return
|
||||
until = _row_get(row, "cooloff_until_ms")
|
||||
if not until:
|
||||
return
|
||||
now_ms = _now_ms(now)
|
||||
if int(until) <= now_ms:
|
||||
return
|
||||
last = int(_row_get(row, "last_close_at_ms") or now_ms)
|
||||
journal_ms = int(cooling_hours_manual_journal() * 3600 * 1000)
|
||||
new_until = max(now_ms, last + journal_ms)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET cooloff_until_ms=?, cooloff_hours=?, updated_at=? WHERE id=1""",
|
||||
(new_until, int(cooling_hours_manual_journal()), datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
|
||||
|
||||
def get_risk_status(conn, *, now: Optional[datetime] = None, active_count: Optional[int] = None) -> dict:
|
||||
def _load() -> dict:
|
||||
ensure_account_risk_schema(conn)
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
td = trading_day_label(now)
|
||||
stored = str(_row_get(row, "trading_day") or "")
|
||||
if stored != td:
|
||||
conn.execute(
|
||||
"UPDATE account_risk_state SET trading_day=?, manual_close_count=0, daily_frozen=0 WHERE id=1 AND trading_day<>?",
|
||||
(td, td),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
|
||||
now_ms = _now_ms(now)
|
||||
daily = int(_row_get(row, "daily_frozen") or 0) == 1
|
||||
until = _row_get(row, "cooloff_until_ms")
|
||||
active = count_active_trade_monitors(conn) if active_count is None else int(active_count)
|
||||
mx = max_active_positions()
|
||||
pos_limit = active >= mx
|
||||
|
||||
if daily:
|
||||
return {
|
||||
"status": STATUS_DAILY,
|
||||
"status_label": STATUS_LABELS[STATUS_DAILY],
|
||||
"can_trade": False,
|
||||
"can_roll": False,
|
||||
"reason": "当日日冻结,禁止新开仓",
|
||||
"active_count": active,
|
||||
"max_active_positions": mx,
|
||||
}
|
||||
if until and int(until) > now_ms:
|
||||
rem = int((int(until) - now_ms) / 1000)
|
||||
hours = float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
|
||||
st = STATUS_FREEZE_1H if hours <= cooling_hours_manual_journal() + 0.01 else STATUS_FREEZE_4H
|
||||
return {
|
||||
"status": st,
|
||||
"status_label": STATUS_LABELS[st],
|
||||
"can_trade": False,
|
||||
"can_roll": pos_limit,
|
||||
"reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m",
|
||||
"freeze_remaining_sec": rem,
|
||||
"active_count": active,
|
||||
"max_active_positions": mx,
|
||||
}
|
||||
if pos_limit:
|
||||
return {
|
||||
"status": STATUS_FREEZE_POSITION,
|
||||
"status_label": STATUS_LABELS[STATUS_FREEZE_POSITION],
|
||||
"can_trade": False,
|
||||
"can_roll": True,
|
||||
"reason": f"已达仓位上限 {active}/{mx}",
|
||||
"active_count": active,
|
||||
"max_active_positions": mx,
|
||||
}
|
||||
return {
|
||||
"status": STATUS_NORMAL,
|
||||
"status_label": STATUS_LABELS[STATUS_NORMAL],
|
||||
"can_trade": True,
|
||||
"can_roll": True,
|
||||
"reason": "可新开仓",
|
||||
"active_count": active,
|
||||
"max_active_positions": mx,
|
||||
}
|
||||
|
||||
return _db_retry(_load)
|
||||
|
||||
|
||||
def assert_can_open(conn, *, active_count: Optional[int] = None) -> Optional[str]:
|
||||
rs = get_risk_status(conn, active_count=active_count)
|
||||
if not rs.get("can_trade"):
|
||||
return rs.get("reason") or "当前不可开仓"
|
||||
return None
|
||||
|
||||
+812
-807
File diff suppressed because it is too large
Load Diff
+549
-548
File diff suppressed because it is too large
Load Diff
+205
-204
@@ -1,204 +1,205 @@
|
||||
/* 科技感增强层 — 与 base.html 变量配合 */
|
||||
|
||||
.tech-bg{
|
||||
position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden;
|
||||
}
|
||||
.tech-grid{
|
||||
position:absolute;inset:0;
|
||||
background-image:
|
||||
linear-gradient(var(--bg-grid) 1px,transparent 1px),
|
||||
linear-gradient(90deg,var(--bg-grid) 1px,transparent 1px);
|
||||
background-size:32px 32px;
|
||||
mask-image:radial-gradient(ellipse 85% 75% at 50% 35%,#000 20%,transparent 75%);
|
||||
}
|
||||
.tech-glow{
|
||||
position:absolute;width:70vmax;height:70vmax;
|
||||
top:-25%;left:50%;transform:translateX(-50%);
|
||||
background:radial-gradient(circle,var(--ambient-glow) 0%,transparent 65%);
|
||||
animation:tech-pulse 8s ease-in-out infinite;
|
||||
}
|
||||
.tech-glow-2{
|
||||
position:absolute;width:50vmax;height:50vmax;
|
||||
bottom:-20%;right:-10%;
|
||||
background:radial-gradient(circle,var(--ambient-glow-2) 0%,transparent 70%);
|
||||
animation:tech-pulse 10s ease-in-out infinite reverse;
|
||||
}
|
||||
.tech-scanline{
|
||||
position:absolute;inset:0;
|
||||
background:repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
var(--scanline) 2px,
|
||||
var(--scanline) 3px
|
||||
);
|
||||
opacity:.35;
|
||||
animation:tech-scan 12s linear infinite;
|
||||
}
|
||||
@keyframes tech-pulse{
|
||||
0%,100%{opacity:.55;transform:translateX(-50%) scale(1)}
|
||||
50%{opacity:.85;transform:translateX(-50%) scale(1.05)}
|
||||
}
|
||||
@keyframes tech-scan{
|
||||
0%{transform:translateY(0)}
|
||||
100%{transform:translateY(32px)}
|
||||
}
|
||||
@keyframes tech-shine{
|
||||
0%,100%{opacity:.45}
|
||||
50%{opacity:.9}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.tech-glow,.tech-glow-2,.tech-scanline,.card::after,.site-header::after{animation:none}
|
||||
}
|
||||
|
||||
.page-wrap{position:relative;z-index:1}
|
||||
|
||||
.site-header{
|
||||
border-bottom:1px solid var(--border-header);
|
||||
background:transparent;
|
||||
backdrop-filter:none;
|
||||
}
|
||||
.site-header::after{
|
||||
content:"";display:block;height:1px;margin-top:-1px;
|
||||
background:linear-gradient(90deg,transparent,var(--accent),var(--accent-2),transparent);
|
||||
opacity:.7;animation:tech-shine 4s ease-in-out infinite;
|
||||
}
|
||||
.site-title{
|
||||
letter-spacing:.04em;
|
||||
background:linear-gradient(135deg,var(--text-title) 0%,var(--accent) 45%,var(--accent-2) 100%);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
|
||||
background-clip:text;
|
||||
filter:drop-shadow(0 0 24px var(--title-glow));
|
||||
}
|
||||
.site-title-sub{
|
||||
display:block;font-size:.72rem;font-weight:500;
|
||||
letter-spacing:.22em;text-transform:uppercase;
|
||||
color:var(--text-muted);margin-top:.35rem;
|
||||
-webkit-text-fill-color:var(--text-muted);
|
||||
}
|
||||
|
||||
.site-nav a{
|
||||
border-radius:999px;
|
||||
letter-spacing:.02em;
|
||||
position:relative;overflow:hidden;
|
||||
transition:transform .2s,box-shadow .2s,border-color .2s,background .2s;
|
||||
}
|
||||
.site-nav a::before{
|
||||
content:"";position:absolute;inset:0;
|
||||
background:linear-gradient(120deg,transparent,rgba(255,255,255,.06),transparent);
|
||||
opacity:0;transition:opacity .25s;
|
||||
}
|
||||
.site-nav a:hover::before{opacity:1}
|
||||
.site-nav a:hover{
|
||||
transform:translateY(-1px);
|
||||
box-shadow:0 4px 20px var(--nav-hover-glow);
|
||||
}
|
||||
.site-nav a.active{
|
||||
background:linear-gradient(135deg,var(--nav-active),var(--accent-2));
|
||||
border-color:transparent;
|
||||
box-shadow:0 0 20px var(--nav-active-glow),inset 0 1px 0 rgba(255,255,255,.15);
|
||||
}
|
||||
|
||||
.theme-switch-btn:hover{
|
||||
color:var(--text-primary);
|
||||
}
|
||||
.theme-switch-btn.active{
|
||||
box-shadow:0 0 12px var(--btn-glow);
|
||||
}
|
||||
|
||||
.card{
|
||||
border-radius:14px;
|
||||
transition:transform .25s,box-shadow .25s,border-color .25s;
|
||||
}
|
||||
.card:hover{
|
||||
transform:translateY(-2px);
|
||||
border-color:var(--card-border-hover);
|
||||
box-shadow:var(--shadow-card-hover);
|
||||
}
|
||||
.card::after{
|
||||
animation:tech-shine 5s ease-in-out infinite;
|
||||
}
|
||||
.card h2{letter-spacing:.03em}
|
||||
.card h2:before{
|
||||
box-shadow:0 0 12px var(--accent),0 0 4px var(--accent-2);
|
||||
}
|
||||
|
||||
input:focus,select:focus,textarea:focus{
|
||||
box-shadow:0 0 0 3px var(--focus-ring),0 0 16px var(--focus-glow);
|
||||
}
|
||||
button.btn-primary{
|
||||
font-weight:600;letter-spacing:.04em;
|
||||
box-shadow:0 4px 20px var(--btn-glow);
|
||||
transition:transform .15s,box-shadow .2s,opacity .2s;
|
||||
}
|
||||
button.btn-primary:hover{
|
||||
transform:translateY(-1px);
|
||||
box-shadow:0 6px 28px var(--btn-glow-strong);
|
||||
opacity:1;
|
||||
}
|
||||
|
||||
.list-item{
|
||||
transition:border-color .2s,box-shadow .2s,transform .2s;
|
||||
}
|
||||
.list-item:hover{
|
||||
border-color:var(--card-border-hover);
|
||||
box-shadow:0 4px 16px var(--card-glow);
|
||||
}
|
||||
table tbody tr{transition:background .15s}
|
||||
table tbody tr:hover{background:var(--row-hover)}
|
||||
|
||||
.stat-item{
|
||||
backdrop-filter:blur(8px);
|
||||
transition:transform .2s,box-shadow .2s;
|
||||
}
|
||||
.stat-item:hover{
|
||||
transform:translateY(-2px);
|
||||
box-shadow:0 8px 24px var(--card-glow);
|
||||
}
|
||||
.stat-item .value{
|
||||
font-variant-numeric:tabular-nums;
|
||||
letter-spacing:.02em;
|
||||
}
|
||||
|
||||
.pos-card{
|
||||
position:relative;overflow:hidden;
|
||||
transition:border-color .2s,box-shadow .2s;
|
||||
}
|
||||
.pos-card::before{
|
||||
content:"";position:absolute;top:0;left:0;right:0;height:2px;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));
|
||||
opacity:.5;
|
||||
}
|
||||
.pos-card:hover{
|
||||
border-color:var(--card-border-hover);
|
||||
box-shadow:0 6px 24px var(--card-glow);
|
||||
}
|
||||
|
||||
.badge{letter-spacing:.02em;border:1px solid transparent}
|
||||
.badge.dir{border-color:rgba(76,194,255,.25)}
|
||||
.badge.profit{border-color:rgba(76,217,127,.3)}
|
||||
.badge.loss{border-color:rgba(255,102,102,.3)}
|
||||
|
||||
.modal-box{
|
||||
border:1px solid var(--card-border-hover);
|
||||
box-shadow:var(--shadow-card-hover),0 0 60px var(--card-glow);
|
||||
}
|
||||
|
||||
.flash{
|
||||
box-shadow:0 0 24px var(--focus-glow);
|
||||
letter-spacing:.02em;
|
||||
}
|
||||
|
||||
.profile-spec{
|
||||
border:1px solid var(--card-border-hover);
|
||||
box-shadow:inset 0 0 40px var(--card-glow);
|
||||
}
|
||||
|
||||
.key-live .live-price-line,.live-price{
|
||||
text-shadow:0 0 12px var(--focus-glow);
|
||||
}
|
||||
|
||||
.preset-tabs a.active{
|
||||
box-shadow:0 0 12px var(--focus-glow);
|
||||
}
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt */
|
||||
/* 科技感增强层 — 与 base.html 变量配合 */
|
||||
|
||||
.tech-bg{
|
||||
position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden;
|
||||
}
|
||||
.tech-grid{
|
||||
position:absolute;inset:0;
|
||||
background-image:
|
||||
linear-gradient(var(--bg-grid) 1px,transparent 1px),
|
||||
linear-gradient(90deg,var(--bg-grid) 1px,transparent 1px);
|
||||
background-size:32px 32px;
|
||||
mask-image:radial-gradient(ellipse 85% 75% at 50% 35%,#000 20%,transparent 75%);
|
||||
}
|
||||
.tech-glow{
|
||||
position:absolute;width:70vmax;height:70vmax;
|
||||
top:-25%;left:50%;transform:translateX(-50%);
|
||||
background:radial-gradient(circle,var(--ambient-glow) 0%,transparent 65%);
|
||||
animation:tech-pulse 8s ease-in-out infinite;
|
||||
}
|
||||
.tech-glow-2{
|
||||
position:absolute;width:50vmax;height:50vmax;
|
||||
bottom:-20%;right:-10%;
|
||||
background:radial-gradient(circle,var(--ambient-glow-2) 0%,transparent 70%);
|
||||
animation:tech-pulse 10s ease-in-out infinite reverse;
|
||||
}
|
||||
.tech-scanline{
|
||||
position:absolute;inset:0;
|
||||
background:repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
var(--scanline) 2px,
|
||||
var(--scanline) 3px
|
||||
);
|
||||
opacity:.35;
|
||||
animation:tech-scan 12s linear infinite;
|
||||
}
|
||||
@keyframes tech-pulse{
|
||||
0%,100%{opacity:.55;transform:translateX(-50%) scale(1)}
|
||||
50%{opacity:.85;transform:translateX(-50%) scale(1.05)}
|
||||
}
|
||||
@keyframes tech-scan{
|
||||
0%{transform:translateY(0)}
|
||||
100%{transform:translateY(32px)}
|
||||
}
|
||||
@keyframes tech-shine{
|
||||
0%,100%{opacity:.45}
|
||||
50%{opacity:.9}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.tech-glow,.tech-glow-2,.tech-scanline,.card::after,.site-header::after{animation:none}
|
||||
}
|
||||
|
||||
.page-wrap{position:relative;z-index:1}
|
||||
|
||||
.site-header{
|
||||
border-bottom:1px solid var(--border-header);
|
||||
background:transparent;
|
||||
backdrop-filter:none;
|
||||
}
|
||||
.site-header::after{
|
||||
content:"";display:block;height:1px;margin-top:-1px;
|
||||
background:linear-gradient(90deg,transparent,var(--accent),var(--accent-2),transparent);
|
||||
opacity:.7;animation:tech-shine 4s ease-in-out infinite;
|
||||
}
|
||||
.site-title{
|
||||
letter-spacing:.04em;
|
||||
background:linear-gradient(135deg,var(--text-title) 0%,var(--accent) 45%,var(--accent-2) 100%);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
|
||||
background-clip:text;
|
||||
filter:drop-shadow(0 0 24px var(--title-glow));
|
||||
}
|
||||
.site-title-sub{
|
||||
display:block;font-size:.72rem;font-weight:500;
|
||||
letter-spacing:.22em;text-transform:uppercase;
|
||||
color:var(--text-muted);margin-top:.35rem;
|
||||
-webkit-text-fill-color:var(--text-muted);
|
||||
}
|
||||
|
||||
.site-nav a{
|
||||
border-radius:999px;
|
||||
letter-spacing:.02em;
|
||||
position:relative;overflow:hidden;
|
||||
transition:transform .2s,box-shadow .2s,border-color .2s,background .2s;
|
||||
}
|
||||
.site-nav a::before{
|
||||
content:"";position:absolute;inset:0;
|
||||
background:linear-gradient(120deg,transparent,rgba(255,255,255,.06),transparent);
|
||||
opacity:0;transition:opacity .25s;
|
||||
}
|
||||
.site-nav a:hover::before{opacity:1}
|
||||
.site-nav a:hover{
|
||||
transform:translateY(-1px);
|
||||
box-shadow:0 4px 20px var(--nav-hover-glow);
|
||||
}
|
||||
.site-nav a.active{
|
||||
background:linear-gradient(135deg,var(--nav-active),var(--accent-2));
|
||||
border-color:transparent;
|
||||
box-shadow:0 0 20px var(--nav-active-glow),inset 0 1px 0 rgba(255,255,255,.15);
|
||||
}
|
||||
|
||||
.theme-switch-btn:hover{
|
||||
color:var(--text-primary);
|
||||
}
|
||||
.theme-switch-btn.active{
|
||||
box-shadow:0 0 12px var(--btn-glow);
|
||||
}
|
||||
|
||||
.card{
|
||||
border-radius:14px;
|
||||
transition:transform .25s,box-shadow .25s,border-color .25s;
|
||||
}
|
||||
.card:hover{
|
||||
transform:translateY(-2px);
|
||||
border-color:var(--card-border-hover);
|
||||
box-shadow:var(--shadow-card-hover);
|
||||
}
|
||||
.card::after{
|
||||
animation:tech-shine 5s ease-in-out infinite;
|
||||
}
|
||||
.card h2{letter-spacing:.03em}
|
||||
.card h2:before{
|
||||
box-shadow:0 0 12px var(--accent),0 0 4px var(--accent-2);
|
||||
}
|
||||
|
||||
input:focus,select:focus,textarea:focus{
|
||||
box-shadow:0 0 0 3px var(--focus-ring),0 0 16px var(--focus-glow);
|
||||
}
|
||||
button.btn-primary{
|
||||
font-weight:600;letter-spacing:.04em;
|
||||
box-shadow:0 4px 20px var(--btn-glow);
|
||||
transition:transform .15s,box-shadow .2s,opacity .2s;
|
||||
}
|
||||
button.btn-primary:hover{
|
||||
transform:translateY(-1px);
|
||||
box-shadow:0 6px 28px var(--btn-glow-strong);
|
||||
opacity:1;
|
||||
}
|
||||
|
||||
.list-item{
|
||||
transition:border-color .2s,box-shadow .2s,transform .2s;
|
||||
}
|
||||
.list-item:hover{
|
||||
border-color:var(--card-border-hover);
|
||||
box-shadow:0 4px 16px var(--card-glow);
|
||||
}
|
||||
table tbody tr{transition:background .15s}
|
||||
table tbody tr:hover{background:var(--row-hover)}
|
||||
|
||||
.stat-item{
|
||||
backdrop-filter:blur(8px);
|
||||
transition:transform .2s,box-shadow .2s;
|
||||
}
|
||||
.stat-item:hover{
|
||||
transform:translateY(-2px);
|
||||
box-shadow:0 8px 24px var(--card-glow);
|
||||
}
|
||||
.stat-item .value{
|
||||
font-variant-numeric:tabular-nums;
|
||||
letter-spacing:.02em;
|
||||
}
|
||||
|
||||
.pos-card{
|
||||
position:relative;overflow:hidden;
|
||||
transition:border-color .2s,box-shadow .2s;
|
||||
}
|
||||
.pos-card::before{
|
||||
content:"";position:absolute;top:0;left:0;right:0;height:2px;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));
|
||||
opacity:.5;
|
||||
}
|
||||
.pos-card:hover{
|
||||
border-color:var(--card-border-hover);
|
||||
box-shadow:0 6px 24px var(--card-glow);
|
||||
}
|
||||
|
||||
.badge{letter-spacing:.02em;border:1px solid transparent}
|
||||
.badge.dir{border-color:rgba(76,194,255,.25)}
|
||||
.badge.profit{border-color:rgba(76,217,127,.3)}
|
||||
.badge.loss{border-color:rgba(255,102,102,.3)}
|
||||
|
||||
.modal-box{
|
||||
border:1px solid var(--card-border-hover);
|
||||
box-shadow:var(--shadow-card-hover),0 0 60px var(--card-glow);
|
||||
}
|
||||
|
||||
.flash{
|
||||
box-shadow:0 0 24px var(--focus-glow);
|
||||
letter-spacing:.02em;
|
||||
}
|
||||
|
||||
.profile-spec{
|
||||
border:1px solid var(--card-border-hover);
|
||||
box-shadow:inset 0 0 40px var(--card-glow);
|
||||
}
|
||||
|
||||
.key-live .live-price-line,.live-price{
|
||||
text-shadow:0 0 12px var(--focus-glow);
|
||||
}
|
||||
|
||||
.preset-tabs a.active{
|
||||
box-shadow:0 0 12px var(--focus-glow);
|
||||
}
|
||||
|
||||
+107
-106
@@ -1,106 +1,107 @@
|
||||
/* 持仓监控页 — 与 split-grid(关键位监控)同宽,全端自适应 */
|
||||
.trade-page{width:100%}
|
||||
.trade-split{margin-bottom:1.25rem}
|
||||
.trade-split .card{min-height:480px}
|
||||
.trade-top-bar{
|
||||
display:flex;flex-wrap:wrap;gap:.65rem 1rem;
|
||||
align-items:center;justify-content:space-between;
|
||||
margin-bottom:1.25rem;
|
||||
}
|
||||
.trade-top-bar-main{display:flex;flex-wrap:wrap;gap:.5rem .65rem;align-items:center;flex:1;min-width:0}
|
||||
.trade-top-bar-actions{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center}
|
||||
.trade-top-hint{font-size:.72rem;white-space:nowrap}
|
||||
.btn-ctp-sm{padding:.4rem .9rem;font-size:.8rem;width:auto;white-space:nowrap}
|
||||
.trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column}
|
||||
.trade-card h2{margin-bottom:.35rem;flex-shrink:0}
|
||||
.trade-card .card-body{flex:1;min-height:0;display:flex;flex-direction:column}
|
||||
.trade-card-full{margin-bottom:1.5rem}
|
||||
.pos-hint{font-size:.75rem;margin:-.15rem 0 .5rem .25rem;color:var(--text-muted)}
|
||||
.trade-order-status{display:grid;gap:.55rem;margin:.5rem 0 .75rem;padding:.65rem .85rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.82rem}
|
||||
.trade-order-status-compact{margin-top:0}
|
||||
.trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem}
|
||||
.trade-form-rows{display:flex;flex-direction:column;gap:.75rem;margin-bottom:.85rem}
|
||||
.trade-form-line{display:grid;gap:.65rem;align-items:end}
|
||||
.trade-form-line.line-3{grid-template-columns:1.4fr 0.8fr 0.8fr}
|
||||
.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)}
|
||||
.trade-field select,.trade-field input{width:100%;box-sizing:border-box}
|
||||
.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default}
|
||||
.lots-warn{font-size:.7rem;margin-top:.25rem;margin-bottom:0}
|
||||
.price-type-tabs{display:flex;gap:.35rem;margin-bottom:.35rem}
|
||||
.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center;width:auto}
|
||||
.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)}
|
||||
.market-hint{font-size:.7rem;margin-top:.25rem}
|
||||
.trade-action-row{display:flex;flex-direction:column;gap:.45rem;margin:.85rem 0 .55rem}
|
||||
.trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%}
|
||||
.trade-action-row .btn-open:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.25)}
|
||||
.trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)}
|
||||
.trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none}
|
||||
.trailing-be-toggle input{width:auto;margin:0}
|
||||
.trade-rr-hint{font-size:.78rem;color:var(--text-accent);margin:0}
|
||||
.session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center}
|
||||
.trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem}
|
||||
.trade-order-msg.ok{color:var(--profit)}
|
||||
.trade-order-msg.err{color:var(--loss)}
|
||||
.trade-footer{background:var(--card-inner);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;border:1px solid var(--card-border);margin-top:.5rem}
|
||||
.trade-footer strong{color:var(--accent)}
|
||||
.rec-blocked td{opacity:.55}
|
||||
.rec-ok td:first-child{font-weight:600}
|
||||
.rec-trend-break td:first-child .trend-name{font-weight:700}
|
||||
.trend-badge{font-size:.72rem;white-space:nowrap}
|
||||
.trend-badge.break{color:var(--accent);font-weight:700;border:1px solid var(--accent);background:rgba(56,189,248,.12)}
|
||||
.trend-hint{font-size:.72rem;color:var(--text-muted);margin:.35rem 0 .65rem;line-height:1.5}
|
||||
.rec-sort-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .65rem;margin-bottom:.55rem;font-size:.78rem}
|
||||
.rec-sort-bar label{color:var(--text-muted);white-space:nowrap}
|
||||
.rec-sort-bar select{padding:.35rem .5rem;font-size:.78rem;min-width:7rem}
|
||||
.rec-stats{
|
||||
font-size:.78rem;color:var(--text-muted);margin-bottom:.45rem;line-height:1.5;
|
||||
}
|
||||
.rec-stats strong{color:var(--accent);font-weight:600}
|
||||
.rec-sort-dir-btn{
|
||||
border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);
|
||||
padding:.3rem .55rem;border-radius:6px;cursor:pointer;font-size:.78rem;min-width:2rem;
|
||||
}
|
||||
.rec-sort-dir-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.gap-badge{font-size:.72rem}
|
||||
.rec-change-up{color:var(--profit)}
|
||||
.rec-change-down{color:var(--loss)}
|
||||
#recommend .trade-table-wrap{max-height:min(70vh,520px)}
|
||||
#positions .card-body.card-scroll{flex:1;max-height:none;overflow-y:auto}
|
||||
.pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)}
|
||||
.pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem}
|
||||
.pos-pending-item{display:flex;justify-content:space-between;align-items:center;gap:.5rem;font-size:.75rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:.25rem;background:var(--list-item-bg)}
|
||||
.pos-pending-right{display:flex;align-items:center;gap:.45rem;flex-shrink:0}
|
||||
.pos-dismiss-btn{padding:.2rem .55rem;font-size:.68rem;border-radius:6px;border:1px solid var(--table-border);background:var(--card-inner);color:var(--text-muted);cursor:pointer;width:auto;min-height:auto;line-height:1.3}
|
||||
.pos-dismiss-btn:disabled{opacity:.55;cursor:wait}
|
||||
.pos-sl-btn{border-color:var(--accent);color:var(--accent)}
|
||||
.pos-pending-item.sl{border-left:3px solid var(--loss)}
|
||||
.pos-pending-item.tp{border-left:3px solid var(--profit)}
|
||||
.pos-pending-item.ctp{border-left:3px solid var(--accent)}
|
||||
.pos-card.is-pending{border:1px dashed var(--accent);opacity:.95}
|
||||
.pos-card.is-pending .badge.pending{background:rgba(56,189,248,.15);color:var(--accent)}
|
||||
.pos-card.is-pending .pos-metrics .cell.pnl-pending label{color:var(--accent)}
|
||||
.pos-close-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--loss);background:var(--loss-bg);color:var(--loss);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
|
||||
.pos-close-btn:disabled,.pos-close-btn.is-session-off{opacity:.45;cursor:not-allowed;border-color:var(--text-muted);background:var(--card-inner);color:var(--text-muted)}
|
||||
.pos-dismiss-btn:disabled,.pos-dismiss-btn.is-session-off{opacity:.45;cursor:not-allowed;color:var(--text-muted)}
|
||||
.pos-card-meta-line{font-size:.78rem;line-height:1.65;color:var(--text-muted);margin-bottom:.55rem}
|
||||
.pos-card-meta-line strong{color:var(--text)}
|
||||
.pos-card-actions{display:flex;gap:.35rem;flex-shrink:0;align-items:center}
|
||||
.pos-order-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--accent);background:rgba(56,189,248,.1);color:var(--accent);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
|
||||
.pos-order-btn:disabled,.pos-order-btn.pos-order-done{opacity:.55;cursor:default;border-color:var(--table-border);background:var(--card-inner);color:var(--text-muted)}
|
||||
.pos-order-btn:disabled:not(.pos-order-done){cursor:wait}
|
||||
|
||||
@media (min-width:768px) and (max-width:1100px){
|
||||
.trade-split .card{min-height:420px}
|
||||
.trade-form-line.line-3{grid-template-columns:1fr 1fr}
|
||||
.trade-form-line.line-3 .trade-field:first-child{grid-column:1/-1}
|
||||
}
|
||||
|
||||
@media (max-width:767px){
|
||||
.trade-top-bar{flex-direction:column;align-items:stretch}
|
||||
.trade-top-bar-actions{width:100%}
|
||||
.btn-ctp-sm{width:100%;min-height:44px}
|
||||
.trade-split .card{min-height:auto}
|
||||
.trade-form-line.line-3{grid-template-columns:1fr}
|
||||
.trade-card-full{margin-bottom:1rem}
|
||||
.trade-table-wrap{max-height:320px}
|
||||
}
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt */
|
||||
/* 持仓监控页 — 与 split-grid(关键位监控)同宽,全端自适应 */
|
||||
.trade-page{width:100%}
|
||||
.trade-split{margin-bottom:1.25rem}
|
||||
.trade-split .card{min-height:480px}
|
||||
.trade-top-bar{
|
||||
display:flex;flex-wrap:wrap;gap:.65rem 1rem;
|
||||
align-items:center;justify-content:space-between;
|
||||
margin-bottom:1.25rem;
|
||||
}
|
||||
.trade-top-bar-main{display:flex;flex-wrap:wrap;gap:.5rem .65rem;align-items:center;flex:1;min-width:0}
|
||||
.trade-top-bar-actions{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center}
|
||||
.trade-top-hint{font-size:.72rem;white-space:nowrap}
|
||||
.btn-ctp-sm{padding:.4rem .9rem;font-size:.8rem;width:auto;white-space:nowrap}
|
||||
.trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column}
|
||||
.trade-card h2{margin-bottom:.35rem;flex-shrink:0}
|
||||
.trade-card .card-body{flex:1;min-height:0;display:flex;flex-direction:column}
|
||||
.trade-card-full{margin-bottom:1.5rem}
|
||||
.pos-hint{font-size:.75rem;margin:-.15rem 0 .5rem .25rem;color:var(--text-muted)}
|
||||
.trade-order-status{display:grid;gap:.55rem;margin:.5rem 0 .75rem;padding:.65rem .85rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.82rem}
|
||||
.trade-order-status-compact{margin-top:0}
|
||||
.trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem}
|
||||
.trade-form-rows{display:flex;flex-direction:column;gap:.75rem;margin-bottom:.85rem}
|
||||
.trade-form-line{display:grid;gap:.65rem;align-items:end}
|
||||
.trade-form-line.line-3{grid-template-columns:1.4fr 0.8fr 0.8fr}
|
||||
.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)}
|
||||
.trade-field select,.trade-field input{width:100%;box-sizing:border-box}
|
||||
.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default}
|
||||
.lots-warn{font-size:.7rem;margin-top:.25rem;margin-bottom:0}
|
||||
.price-type-tabs{display:flex;gap:.35rem;margin-bottom:.35rem}
|
||||
.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center;width:auto}
|
||||
.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)}
|
||||
.market-hint{font-size:.7rem;margin-top:.25rem}
|
||||
.trade-action-row{display:flex;flex-direction:column;gap:.45rem;margin:.85rem 0 .55rem}
|
||||
.trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%}
|
||||
.trade-action-row .btn-open:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.25)}
|
||||
.trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)}
|
||||
.trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none}
|
||||
.trailing-be-toggle input{width:auto;margin:0}
|
||||
.trade-rr-hint{font-size:.78rem;color:var(--text-accent);margin:0}
|
||||
.session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center}
|
||||
.trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem}
|
||||
.trade-order-msg.ok{color:var(--profit)}
|
||||
.trade-order-msg.err{color:var(--loss)}
|
||||
.trade-footer{background:var(--card-inner);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;border:1px solid var(--card-border);margin-top:.5rem}
|
||||
.trade-footer strong{color:var(--accent)}
|
||||
.rec-blocked td{opacity:.55}
|
||||
.rec-ok td:first-child{font-weight:600}
|
||||
.rec-trend-break td:first-child .trend-name{font-weight:700}
|
||||
.trend-badge{font-size:.72rem;white-space:nowrap}
|
||||
.trend-badge.break{color:var(--accent);font-weight:700;border:1px solid var(--accent);background:rgba(56,189,248,.12)}
|
||||
.trend-hint{font-size:.72rem;color:var(--text-muted);margin:.35rem 0 .65rem;line-height:1.5}
|
||||
.rec-sort-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .65rem;margin-bottom:.55rem;font-size:.78rem}
|
||||
.rec-sort-bar label{color:var(--text-muted);white-space:nowrap}
|
||||
.rec-sort-bar select{padding:.35rem .5rem;font-size:.78rem;min-width:7rem}
|
||||
.rec-stats{
|
||||
font-size:.78rem;color:var(--text-muted);margin-bottom:.45rem;line-height:1.5;
|
||||
}
|
||||
.rec-stats strong{color:var(--accent);font-weight:600}
|
||||
.rec-sort-dir-btn{
|
||||
border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);
|
||||
padding:.3rem .55rem;border-radius:6px;cursor:pointer;font-size:.78rem;min-width:2rem;
|
||||
}
|
||||
.rec-sort-dir-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.gap-badge{font-size:.72rem}
|
||||
.rec-change-up{color:var(--profit)}
|
||||
.rec-change-down{color:var(--loss)}
|
||||
#recommend .trade-table-wrap{max-height:min(70vh,520px)}
|
||||
#positions .card-body.card-scroll{flex:1;max-height:none;overflow-y:auto}
|
||||
.pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)}
|
||||
.pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem}
|
||||
.pos-pending-item{display:flex;justify-content:space-between;align-items:center;gap:.5rem;font-size:.75rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:.25rem;background:var(--list-item-bg)}
|
||||
.pos-pending-right{display:flex;align-items:center;gap:.45rem;flex-shrink:0}
|
||||
.pos-dismiss-btn{padding:.2rem .55rem;font-size:.68rem;border-radius:6px;border:1px solid var(--table-border);background:var(--card-inner);color:var(--text-muted);cursor:pointer;width:auto;min-height:auto;line-height:1.3}
|
||||
.pos-dismiss-btn:disabled{opacity:.55;cursor:wait}
|
||||
.pos-sl-btn{border-color:var(--accent);color:var(--accent)}
|
||||
.pos-pending-item.sl{border-left:3px solid var(--loss)}
|
||||
.pos-pending-item.tp{border-left:3px solid var(--profit)}
|
||||
.pos-pending-item.ctp{border-left:3px solid var(--accent)}
|
||||
.pos-card.is-pending{border:1px dashed var(--accent);opacity:.95}
|
||||
.pos-card.is-pending .badge.pending{background:rgba(56,189,248,.15);color:var(--accent)}
|
||||
.pos-card.is-pending .pos-metrics .cell.pnl-pending label{color:var(--accent)}
|
||||
.pos-close-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--loss);background:var(--loss-bg);color:var(--loss);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
|
||||
.pos-close-btn:disabled,.pos-close-btn.is-session-off{opacity:.45;cursor:not-allowed;border-color:var(--text-muted);background:var(--card-inner);color:var(--text-muted)}
|
||||
.pos-dismiss-btn:disabled,.pos-dismiss-btn.is-session-off{opacity:.45;cursor:not-allowed;color:var(--text-muted)}
|
||||
.pos-card-meta-line{font-size:.78rem;line-height:1.65;color:var(--text-muted);margin-bottom:.55rem}
|
||||
.pos-card-meta-line strong{color:var(--text)}
|
||||
.pos-card-actions{display:flex;gap:.35rem;flex-shrink:0;align-items:center}
|
||||
.pos-order-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--accent);background:rgba(56,189,248,.1);color:var(--accent);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
|
||||
.pos-order-btn:disabled,.pos-order-btn.pos-order-done{opacity:.55;cursor:default;border-color:var(--table-border);background:var(--card-inner);color:var(--text-muted)}
|
||||
.pos-order-btn:disabled:not(.pos-order-done){cursor:wait}
|
||||
|
||||
@media (min-width:768px) and (max-width:1100px){
|
||||
.trade-split .card{min-height:420px}
|
||||
.trade-form-line.line-3{grid-template-columns:1fr 1fr}
|
||||
.trade-form-line.line-3 .trade-field:first-child{grid-column:1/-1}
|
||||
}
|
||||
|
||||
@media (max-width:767px){
|
||||
.trade-top-bar{flex-direction:column;align-items:stretch}
|
||||
.trade-top-bar-actions{width:100%}
|
||||
.btn-ctp-sm{width:100%;min-height:44px}
|
||||
.trade-split .card{min-height:auto}
|
||||
.trade-form-line.line-3{grid-template-columns:1fr}
|
||||
.trade-card-full{margin-bottom:1rem}
|
||||
.trade-table-wrap{max-height:320px}
|
||||
}
|
||||
|
||||
+27
-23
@@ -1,23 +1,27 @@
|
||||
(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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
+127
-123
@@ -1,123 +1,127 @@
|
||||
(function () {
|
||||
var el = document.getElementById('equity-curve-chart');
|
||||
var raw = window.__EQUITY_CURVE__;
|
||||
if (!el || !raw || !raw.length || !window.LightweightCharts) return;
|
||||
|
||||
var chart = null;
|
||||
var series = null;
|
||||
var chartData = [];
|
||||
|
||||
function cssVar(name, fallback) {
|
||||
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
function themeColors() {
|
||||
return {
|
||||
bg: 'transparent',
|
||||
text: cssVar('--text-muted', '#7a82a0'),
|
||||
grid: cssVar('--table-border', 'rgba(76,194,255,.1)'),
|
||||
border: cssVar('--card-border', 'rgba(76,194,255,.22)'),
|
||||
line: cssVar('--accent', '#4cc2ff'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTime(s) {
|
||||
if (!s) return null;
|
||||
var t = String(s).trim().replace(' ', 'T');
|
||||
if (t.length === 16) t += ':00';
|
||||
var d = new Date(t);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
function buildData() {
|
||||
var data = [];
|
||||
var lastTs = 0;
|
||||
raw.forEach(function (p) {
|
||||
var ts = parseTime(p.time);
|
||||
if (ts == null) return;
|
||||
if (ts <= lastTs) ts = lastTs + 1;
|
||||
lastTs = ts;
|
||||
data.push({ time: ts, value: Number(p.value) });
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function applyChartTheme() {
|
||||
if (!chart || !series) return;
|
||||
var c = themeColors();
|
||||
chart.applyOptions({
|
||||
layout: {
|
||||
background: { type: 'solid', color: c.bg },
|
||||
textColor: c.text,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: c.grid },
|
||||
horzLines: { color: c.grid },
|
||||
},
|
||||
rightPriceScale: { borderColor: c.border },
|
||||
timeScale: { borderColor: c.border },
|
||||
});
|
||||
series.applyOptions({ color: c.line });
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
chartData = buildData();
|
||||
if (!chartData.length) {
|
||||
el.innerHTML = '<p class="text-muted" style="padding:1rem">暂无资金曲线数据</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var c = themeColors();
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
chart = null;
|
||||
series = null;
|
||||
}
|
||||
|
||||
chart = LightweightCharts.createChart(el, {
|
||||
width: el.clientWidth || 800,
|
||||
height: 220,
|
||||
layout: {
|
||||
background: { type: 'solid', color: c.bg },
|
||||
textColor: c.text,
|
||||
fontSize: 11,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: c.grid },
|
||||
horzLines: { color: c.grid },
|
||||
},
|
||||
rightPriceScale: { borderColor: c.border },
|
||||
timeScale: { borderColor: c.border, timeVisible: true, secondsVisible: false },
|
||||
});
|
||||
series = chart.addLineSeries({
|
||||
color: c.line,
|
||||
lineWidth: 2,
|
||||
priceFormat: { type: 'price', precision: 2, minMove: 0.01 },
|
||||
});
|
||||
series.setData(chartData);
|
||||
chart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
renderChart();
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
if (chart) chart.applyOptions({ width: el.clientWidth || 800 });
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.closest('[data-theme-pick]')) {
|
||||
setTimeout(applyChartTheme, 50);
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
var obs = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (m) {
|
||||
if (m.attributeName === 'data-theme') applyChartTheme();
|
||||
});
|
||||
});
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
}
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var el = document.getElementById('equity-curve-chart');
|
||||
var raw = window.__EQUITY_CURVE__;
|
||||
if (!el || !raw || !raw.length || !window.LightweightCharts) return;
|
||||
|
||||
var chart = null;
|
||||
var series = null;
|
||||
var chartData = [];
|
||||
|
||||
function cssVar(name, fallback) {
|
||||
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
function themeColors() {
|
||||
return {
|
||||
bg: 'transparent',
|
||||
text: cssVar('--text-muted', '#7a82a0'),
|
||||
grid: cssVar('--table-border', 'rgba(76,194,255,.1)'),
|
||||
border: cssVar('--card-border', 'rgba(76,194,255,.22)'),
|
||||
line: cssVar('--accent', '#4cc2ff'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTime(s) {
|
||||
if (!s) return null;
|
||||
var t = String(s).trim().replace(' ', 'T');
|
||||
if (t.length === 16) t += ':00';
|
||||
var d = new Date(t);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
function buildData() {
|
||||
var data = [];
|
||||
var lastTs = 0;
|
||||
raw.forEach(function (p) {
|
||||
var ts = parseTime(p.time);
|
||||
if (ts == null) return;
|
||||
if (ts <= lastTs) ts = lastTs + 1;
|
||||
lastTs = ts;
|
||||
data.push({ time: ts, value: Number(p.value) });
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function applyChartTheme() {
|
||||
if (!chart || !series) return;
|
||||
var c = themeColors();
|
||||
chart.applyOptions({
|
||||
layout: {
|
||||
background: { type: 'solid', color: c.bg },
|
||||
textColor: c.text,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: c.grid },
|
||||
horzLines: { color: c.grid },
|
||||
},
|
||||
rightPriceScale: { borderColor: c.border },
|
||||
timeScale: { borderColor: c.border },
|
||||
});
|
||||
series.applyOptions({ color: c.line });
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
chartData = buildData();
|
||||
if (!chartData.length) {
|
||||
el.innerHTML = '<p class="text-muted" style="padding:1rem">暂无资金曲线数据</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var c = themeColors();
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
chart = null;
|
||||
series = null;
|
||||
}
|
||||
|
||||
chart = LightweightCharts.createChart(el, {
|
||||
width: el.clientWidth || 800,
|
||||
height: 220,
|
||||
layout: {
|
||||
background: { type: 'solid', color: c.bg },
|
||||
textColor: c.text,
|
||||
fontSize: 11,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: c.grid },
|
||||
horzLines: { color: c.grid },
|
||||
},
|
||||
rightPriceScale: { borderColor: c.border },
|
||||
timeScale: { borderColor: c.border, timeVisible: true, secondsVisible: false },
|
||||
});
|
||||
series = chart.addLineSeries({
|
||||
color: c.line,
|
||||
lineWidth: 2,
|
||||
priceFormat: { type: 'price', precision: 2, minMove: 0.01 },
|
||||
});
|
||||
series.setData(chartData);
|
||||
chart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
renderChart();
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
if (chart) chart.applyOptions({ width: el.clientWidth || 800 });
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.closest('[data-theme-pick]')) {
|
||||
setTimeout(applyChartTheme, 50);
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
var obs = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (m) {
|
||||
if (m.attributeName === 'data-theme') applyChartTheme();
|
||||
});
|
||||
});
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
}
|
||||
})();
|
||||
|
||||
+38
-34
@@ -1,34 +1,38 @@
|
||||
(function () {
|
||||
var keyTimer = null;
|
||||
|
||||
function fmtDist(v) {
|
||||
if (v === null || v === undefined) return '--';
|
||||
return Number(v).toFixed(2);
|
||||
}
|
||||
|
||||
function pollKeyPrices() {
|
||||
var list = document.getElementById('key-monitor-list');
|
||||
if (!list || !list.querySelector('.key-item')) return;
|
||||
|
||||
fetch('/api/key_prices')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (rows) {
|
||||
rows.forEach(function (row) {
|
||||
var el = list.querySelector('.key-item[data-key-id="' + row.id + '"]');
|
||||
if (!el) return;
|
||||
var priceEl = el.querySelector('.live-price');
|
||||
var upEl = el.querySelector('.dist-up');
|
||||
var downEl = el.querySelector('.dist-down');
|
||||
if (priceEl) priceEl.textContent = row.price != null ? row.price : '--';
|
||||
if (upEl) upEl.textContent = fmtDist(row.dist_upper);
|
||||
if (downEl) downEl.textContent = fmtDist(row.dist_lower);
|
||||
});
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
pollKeyPrices();
|
||||
keyTimer = setInterval(pollKeyPrices, 1000);
|
||||
});
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var keyTimer = null;
|
||||
|
||||
function fmtDist(v) {
|
||||
if (v === null || v === undefined) return '--';
|
||||
return Number(v).toFixed(2);
|
||||
}
|
||||
|
||||
function pollKeyPrices() {
|
||||
var list = document.getElementById('key-monitor-list');
|
||||
if (!list || !list.querySelector('.key-item')) return;
|
||||
|
||||
fetch('/api/key_prices')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (rows) {
|
||||
rows.forEach(function (row) {
|
||||
var el = list.querySelector('.key-item[data-key-id="' + row.id + '"]');
|
||||
if (!el) return;
|
||||
var priceEl = el.querySelector('.live-price');
|
||||
var upEl = el.querySelector('.dist-up');
|
||||
var downEl = el.querySelector('.dist-down');
|
||||
if (priceEl) priceEl.textContent = row.price != null ? row.price : '--';
|
||||
if (upEl) upEl.textContent = fmtDist(row.dist_upper);
|
||||
if (downEl) downEl.textContent = fmtDist(row.dist_lower);
|
||||
});
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
pollKeyPrices();
|
||||
keyTimer = setInterval(pollKeyPrices, 1000);
|
||||
});
|
||||
})();
|
||||
|
||||
+660
-656
File diff suppressed because it is too large
Load Diff
+57
-53
@@ -1,53 +1,57 @@
|
||||
(function () {
|
||||
var toggle = document.getElementById('nav-toggle');
|
||||
var nav = document.getElementById('site-nav');
|
||||
var backdrop = document.getElementById('nav-backdrop');
|
||||
if (!toggle || !nav) return;
|
||||
|
||||
function openNav() {
|
||||
nav.classList.add('open');
|
||||
if (backdrop) {
|
||||
backdrop.hidden = false;
|
||||
backdrop.classList.add('show');
|
||||
}
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
nav.classList.remove('open');
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove('show');
|
||||
backdrop.hidden = true;
|
||||
}
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function isMobileNav() {
|
||||
return window.matchMedia('(max-width: 767px)').matches;
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
if (nav.classList.contains('open')) closeNav();
|
||||
else openNav();
|
||||
});
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', closeNav);
|
||||
}
|
||||
|
||||
nav.querySelectorAll('a').forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
if (isMobileNav()) closeNav();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
if (!isMobileNav()) closeNav();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeNav();
|
||||
});
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var toggle = document.getElementById('nav-toggle');
|
||||
var nav = document.getElementById('site-nav');
|
||||
var backdrop = document.getElementById('nav-backdrop');
|
||||
if (!toggle || !nav) return;
|
||||
|
||||
function openNav() {
|
||||
nav.classList.add('open');
|
||||
if (backdrop) {
|
||||
backdrop.hidden = false;
|
||||
backdrop.classList.add('show');
|
||||
}
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
nav.classList.remove('open');
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove('show');
|
||||
backdrop.hidden = true;
|
||||
}
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function isMobileNav() {
|
||||
return window.matchMedia('(max-width: 767px)').matches;
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
if (nav.classList.contains('open')) closeNav();
|
||||
else openNav();
|
||||
});
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', closeNav);
|
||||
}
|
||||
|
||||
nav.querySelectorAll('a').forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
if (isMobileNav()) closeNav();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
if (!isMobileNav()) closeNav();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeNav();
|
||||
});
|
||||
})();
|
||||
|
||||
+49
-45
@@ -1,45 +1,49 @@
|
||||
(function () {
|
||||
var timer = null;
|
||||
|
||||
function fmtDist(v) {
|
||||
if (v === null || v === undefined) return '--';
|
||||
return v.toFixed(2);
|
||||
}
|
||||
|
||||
function pollPrices() {
|
||||
var list = document.getElementById('plan-monitor-list');
|
||||
if (!list || !list.querySelector('.plan-item')) return;
|
||||
|
||||
fetch('/api/plan_prices')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (rows) {
|
||||
rows.forEach(function (row) {
|
||||
var el = list.querySelector('.plan-item[data-plan-id="' + row.id + '"]');
|
||||
if (!el) return;
|
||||
var priceEl = el.querySelector('.live-price');
|
||||
var distEl = el.querySelector('.live-dist');
|
||||
var upEl = el.querySelector('.dist-up');
|
||||
var downEl = el.querySelector('.dist-down');
|
||||
if (priceEl) {
|
||||
priceEl.textContent = row.price != null ? row.price : '--';
|
||||
}
|
||||
if (row.in_zone && distEl) {
|
||||
distEl.innerHTML = '<span class="text-profit" style="font-weight:600">在区间内</span>';
|
||||
} else if (distEl) {
|
||||
distEl.innerHTML =
|
||||
'距上<span class="dist-up">' + fmtDist(row.dist_upper) + '</span> ' +
|
||||
'距下<span class="dist-down">' + fmtDist(row.dist_lower) + '</span>';
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (timer) clearInterval(timer);
|
||||
pollPrices();
|
||||
timer = setInterval(pollPrices, 1000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', startPolling);
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var timer = null;
|
||||
|
||||
function fmtDist(v) {
|
||||
if (v === null || v === undefined) return '--';
|
||||
return v.toFixed(2);
|
||||
}
|
||||
|
||||
function pollPrices() {
|
||||
var list = document.getElementById('plan-monitor-list');
|
||||
if (!list || !list.querySelector('.plan-item')) return;
|
||||
|
||||
fetch('/api/plan_prices')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (rows) {
|
||||
rows.forEach(function (row) {
|
||||
var el = list.querySelector('.plan-item[data-plan-id="' + row.id + '"]');
|
||||
if (!el) return;
|
||||
var priceEl = el.querySelector('.live-price');
|
||||
var distEl = el.querySelector('.live-dist');
|
||||
var upEl = el.querySelector('.dist-up');
|
||||
var downEl = el.querySelector('.dist-down');
|
||||
if (priceEl) {
|
||||
priceEl.textContent = row.price != null ? row.price : '--';
|
||||
}
|
||||
if (row.in_zone && distEl) {
|
||||
distEl.innerHTML = '<span class="text-profit" style="font-weight:600">在区间内</span>';
|
||||
} else if (distEl) {
|
||||
distEl.innerHTML =
|
||||
'距上<span class="dist-up">' + fmtDist(row.dist_upper) + '</span> ' +
|
||||
'距下<span class="dist-down">' + fmtDist(row.dist_lower) + '</span>';
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (timer) clearInterval(timer);
|
||||
pollPrices();
|
||||
timer = setInterval(pollPrices, 1000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', startPolling);
|
||||
})();
|
||||
|
||||
+79
-75
@@ -1,75 +1,79 @@
|
||||
(function () {
|
||||
var posTimer = null;
|
||||
|
||||
function fmtNum(v, digits) {
|
||||
if (v === null || v === undefined) return '--';
|
||||
return Number(v).toFixed(digits === undefined ? 2 : digits);
|
||||
}
|
||||
|
||||
function buildPosCard(row) {
|
||||
var pnlClass = '';
|
||||
if (row.float_pnl > 0) pnlClass = 'pnl-pos';
|
||||
if (row.float_pnl < 0) pnlClass = 'pnl-neg';
|
||||
var pnlText = '--';
|
||||
if (row.float_pnl != null) {
|
||||
var sign = row.float_pnl >= 0 ? '+' : '';
|
||||
pnlText = sign + fmtNum(row.float_pnl) + '元';
|
||||
if (row.float_pct != null) {
|
||||
pnlText += ' (' + sign + fmtNum(row.float_pct) + '%)';
|
||||
}
|
||||
}
|
||||
var rr = row.rr_ratio != null ? row.rr_ratio + ':1' : '--';
|
||||
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
|
||||
|
||||
return (
|
||||
'<div class="pos-card" data-pos-id="' + row.id + '">' +
|
||||
'<div class="pos-card-head">' +
|
||||
'<div><div class="title">' + row.symbol + ' <span class="badge dir">' + row.direction + '</span></div></div>' +
|
||||
'<form method="post" action="/close_position/' + row.id + '" style="display:inline" onsubmit="return confirm(\'确认平仓?\')">' +
|
||||
'<button type="submit" class="btn-del pos-del">平仓</button></form>' +
|
||||
'</div>' +
|
||||
'<div class="pos-card-meta">来源 <strong>手动输入</strong> · 风险 <strong>' +
|
||||
fmtNum(row.risk_pct) + '%≈' + fmtNum(row.risk_amount) + '元</strong></div>' +
|
||||
'<div class="pos-metrics">' +
|
||||
'<div class="cell"><label>成交价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
|
||||
'<div class="cell"><label>止损</label><div>' + fmtNum(row.stop_loss) + '</div></div>' +
|
||||
'<div class="cell"><label>止盈</label><div>' + fmtNum(row.take_profit) + '</div></div>' +
|
||||
'<div class="cell"><label>盈亏比</label><div>' + rr + '</div></div>' +
|
||||
'<div class="cell"><label>标记价</label><div>' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '</div></div>' +
|
||||
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' +
|
||||
'<div class="cell"><label>预估手续费</label><div>' + fmtNum(row.est_fee) + '元</div></div>' +
|
||||
'<div class="cell ' + (row.est_pnl_net > 0 ? 'pnl-pos' : (row.est_pnl_net < 0 ? 'pnl-neg' : '')) + '">' +
|
||||
'<label>扣费后</label><div>' + (row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + '元' : '--') + '</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="pos-footer">' +
|
||||
'<span>保证金 ' + fmtNum(row.margin) + '元</span>' +
|
||||
'<span>仓位占比 ' + fmtNum(row.position_pct) + '%</span>' +
|
||||
'<span>开仓 ' + (openT || '--') + '</span>' +
|
||||
'<span>持仓 ' + (row.holding_duration || '--') + '</span>' +
|
||||
'<span>张数 ' + row.lots + '</span>' +
|
||||
'<span>手续费(估) ' + fmtNum(row.est_fee) + '元 (' + (row.est_fee_close_type || '') + ')</span>' +
|
||||
'</div></div>'
|
||||
);
|
||||
}
|
||||
|
||||
function pollPositions() {
|
||||
var list = document.getElementById('position-live-list');
|
||||
if (!list) return;
|
||||
|
||||
fetch('/api/position_live')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (rows) {
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<div class="empty-hint">暂无持仓,左侧录入后显示</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(buildPosCard).join('');
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
pollPositions();
|
||||
posTimer = setInterval(pollPositions, 1000);
|
||||
});
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var posTimer = null;
|
||||
|
||||
function fmtNum(v, digits) {
|
||||
if (v === null || v === undefined) return '--';
|
||||
return Number(v).toFixed(digits === undefined ? 2 : digits);
|
||||
}
|
||||
|
||||
function buildPosCard(row) {
|
||||
var pnlClass = '';
|
||||
if (row.float_pnl > 0) pnlClass = 'pnl-pos';
|
||||
if (row.float_pnl < 0) pnlClass = 'pnl-neg';
|
||||
var pnlText = '--';
|
||||
if (row.float_pnl != null) {
|
||||
var sign = row.float_pnl >= 0 ? '+' : '';
|
||||
pnlText = sign + fmtNum(row.float_pnl) + '元';
|
||||
if (row.float_pct != null) {
|
||||
pnlText += ' (' + sign + fmtNum(row.float_pct) + '%)';
|
||||
}
|
||||
}
|
||||
var rr = row.rr_ratio != null ? row.rr_ratio + ':1' : '--';
|
||||
var openT = (row.open_time || '').replace('T', ' ').slice(0, 16);
|
||||
|
||||
return (
|
||||
'<div class="pos-card" data-pos-id="' + row.id + '">' +
|
||||
'<div class="pos-card-head">' +
|
||||
'<div><div class="title">' + row.symbol + ' <span class="badge dir">' + row.direction + '</span></div></div>' +
|
||||
'<form method="post" action="/close_position/' + row.id + '" style="display:inline" onsubmit="return confirm(\'确认平仓?\')">' +
|
||||
'<button type="submit" class="btn-del pos-del">平仓</button></form>' +
|
||||
'</div>' +
|
||||
'<div class="pos-card-meta">来源 <strong>手动输入</strong> · 风险 <strong>' +
|
||||
fmtNum(row.risk_pct) + '%≈' + fmtNum(row.risk_amount) + '元</strong></div>' +
|
||||
'<div class="pos-metrics">' +
|
||||
'<div class="cell"><label>成交价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
|
||||
'<div class="cell"><label>止损</label><div>' + fmtNum(row.stop_loss) + '</div></div>' +
|
||||
'<div class="cell"><label>止盈</label><div>' + fmtNum(row.take_profit) + '</div></div>' +
|
||||
'<div class="cell"><label>盈亏比</label><div>' + rr + '</div></div>' +
|
||||
'<div class="cell"><label>标记价</label><div>' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '</div></div>' +
|
||||
'<div class="cell ' + pnlClass + '"><label>浮盈亏</label><div>' + pnlText + '</div></div>' +
|
||||
'<div class="cell"><label>预估手续费</label><div>' + fmtNum(row.est_fee) + '元</div></div>' +
|
||||
'<div class="cell ' + (row.est_pnl_net > 0 ? 'pnl-pos' : (row.est_pnl_net < 0 ? 'pnl-neg' : '')) + '">' +
|
||||
'<label>扣费后</label><div>' + (row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + '元' : '--') + '</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="pos-footer">' +
|
||||
'<span>保证金 ' + fmtNum(row.margin) + '元</span>' +
|
||||
'<span>仓位占比 ' + fmtNum(row.position_pct) + '%</span>' +
|
||||
'<span>开仓 ' + (openT || '--') + '</span>' +
|
||||
'<span>持仓 ' + (row.holding_duration || '--') + '</span>' +
|
||||
'<span>张数 ' + row.lots + '</span>' +
|
||||
'<span>手续费(估) ' + fmtNum(row.est_fee) + '元 (' + (row.est_fee_close_type || '') + ')</span>' +
|
||||
'</div></div>'
|
||||
);
|
||||
}
|
||||
|
||||
function pollPositions() {
|
||||
var list = document.getElementById('position-live-list');
|
||||
if (!list) return;
|
||||
|
||||
fetch('/api/position_live')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (rows) {
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<div class="empty-hint">暂无持仓,左侧录入后显示</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(buildPosCard).join('');
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
pollPositions();
|
||||
posTimer = setInterval(pollPositions, 1000);
|
||||
});
|
||||
})();
|
||||
|
||||
+93
-89
@@ -1,89 +1,93 @@
|
||||
(function () {
|
||||
var deferredPrompt = null;
|
||||
var installBtn = document.getElementById('pwa-install-btn');
|
||||
var iosHint = document.getElementById('pwa-ios-hint');
|
||||
|
||||
function isStandalone() {
|
||||
return window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true;
|
||||
}
|
||||
|
||||
function isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
&& !window.MSStream;
|
||||
}
|
||||
|
||||
function isTouchDevice() {
|
||||
return window.matchMedia('(hover: none) and (pointer: coarse)').matches
|
||||
|| window.matchMedia('(max-width: 1024px)').matches;
|
||||
}
|
||||
|
||||
function updateThemeColor() {
|
||||
var meta = document.getElementById('meta-theme-color');
|
||||
if (!meta) return;
|
||||
var theme = document.documentElement.getAttribute('data-theme');
|
||||
meta.setAttribute('content', theme === 'light' ? '#e8eef8' : '#050508');
|
||||
}
|
||||
|
||||
function showInstallBtn() {
|
||||
if (installBtn && !isStandalone()) {
|
||||
installBtn.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showIosHint() {
|
||||
if (iosHint && isIOS() && !isStandalone()) {
|
||||
iosHint.classList.add('show');
|
||||
}
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () { /* ignore */ });
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', function (e) {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
showInstallBtn();
|
||||
});
|
||||
|
||||
if (installBtn) {
|
||||
installBtn.addEventListener('click', function () {
|
||||
if (!deferredPrompt) {
|
||||
if (isIOS()) showIosHint();
|
||||
return;
|
||||
}
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(function () {
|
||||
deferredPrompt = null;
|
||||
installBtn.hidden = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('appinstalled', function () {
|
||||
deferredPrompt = null;
|
||||
if (installBtn) installBtn.hidden = true;
|
||||
if (iosHint) iosHint.classList.remove('show');
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
updateThemeColor();
|
||||
showIosHint();
|
||||
if (isStandalone()) {
|
||||
if (installBtn) installBtn.hidden = true;
|
||||
if (iosHint) iosHint.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
if (isTouchDevice() && installBtn && deferredPrompt) {
|
||||
showInstallBtn();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var pick = e.target.closest('[data-theme-pick]');
|
||||
if (pick) setTimeout(updateThemeColor, 80);
|
||||
});
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var deferredPrompt = null;
|
||||
var installBtn = document.getElementById('pwa-install-btn');
|
||||
var iosHint = document.getElementById('pwa-ios-hint');
|
||||
|
||||
function isStandalone() {
|
||||
return window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true;
|
||||
}
|
||||
|
||||
function isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
&& !window.MSStream;
|
||||
}
|
||||
|
||||
function isTouchDevice() {
|
||||
return window.matchMedia('(hover: none) and (pointer: coarse)').matches
|
||||
|| window.matchMedia('(max-width: 1024px)').matches;
|
||||
}
|
||||
|
||||
function updateThemeColor() {
|
||||
var meta = document.getElementById('meta-theme-color');
|
||||
if (!meta) return;
|
||||
var theme = document.documentElement.getAttribute('data-theme');
|
||||
meta.setAttribute('content', theme === 'light' ? '#e8eef8' : '#050508');
|
||||
}
|
||||
|
||||
function showInstallBtn() {
|
||||
if (installBtn && !isStandalone()) {
|
||||
installBtn.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showIosHint() {
|
||||
if (iosHint && isIOS() && !isStandalone()) {
|
||||
iosHint.classList.add('show');
|
||||
}
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () { /* ignore */ });
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', function (e) {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
showInstallBtn();
|
||||
});
|
||||
|
||||
if (installBtn) {
|
||||
installBtn.addEventListener('click', function () {
|
||||
if (!deferredPrompt) {
|
||||
if (isIOS()) showIosHint();
|
||||
return;
|
||||
}
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(function () {
|
||||
deferredPrompt = null;
|
||||
installBtn.hidden = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('appinstalled', function () {
|
||||
deferredPrompt = null;
|
||||
if (installBtn) installBtn.hidden = true;
|
||||
if (iosHint) iosHint.classList.remove('show');
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
updateThemeColor();
|
||||
showIosHint();
|
||||
if (isStandalone()) {
|
||||
if (installBtn) installBtn.hidden = true;
|
||||
if (iosHint) iosHint.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
if (isTouchDevice() && installBtn && deferredPrompt) {
|
||||
showInstallBtn();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var pick = e.target.closest('[data-theme-pick]');
|
||||
if (pick) setTimeout(updateThemeColor, 80);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
function parseNum(v) {
|
||||
var n = parseFloat(v);
|
||||
|
||||
+159
-155
@@ -1,155 +1,159 @@
|
||||
(function () {
|
||||
var cache = null;
|
||||
|
||||
function fmtNum(v, suffix) {
|
||||
if (v === null || v === undefined || v === '') return '-';
|
||||
var n = Number(v);
|
||||
if (isNaN(n)) return String(v);
|
||||
var s = Number.isInteger(n) ? String(n) : n.toFixed(2);
|
||||
return suffix ? s + suffix : s;
|
||||
}
|
||||
|
||||
function fmtMoney(v) {
|
||||
if (v === null || v === undefined) return '-';
|
||||
return fmtNum(v) + ' 元';
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
if (v === null || v === undefined) return '-';
|
||||
return fmtNum(v) + '%';
|
||||
}
|
||||
|
||||
function setSummary(s) {
|
||||
var map = {
|
||||
total_trades: function () { return fmtNum(s.total_trades); },
|
||||
win_rate: function () { return fmtPct(s.win_rate); },
|
||||
avg_profit: function () { return fmtMoney(s.avg_profit); },
|
||||
avg_loss: function () { return fmtMoney(s.avg_loss); },
|
||||
profit_loss_ratio: function () { return fmtNum(s.profit_loss_ratio); },
|
||||
consecutive_losses: function () { return fmtNum(s.consecutive_losses); },
|
||||
max_drawdown: function () {
|
||||
var amt = fmtMoney(s.max_drawdown);
|
||||
var pct = s.max_drawdown_pct ? ' (' + fmtPct(s.max_drawdown_pct) + ')' : '';
|
||||
return amt + pct;
|
||||
},
|
||||
max_loss_amount: function () { return fmtMoney(s.max_loss_amount); },
|
||||
max_loss_pct: function () { return fmtPct(s.max_loss_pct); },
|
||||
max_profit_amount: function () { return fmtMoney(s.max_profit_amount); },
|
||||
max_profit_pct: function () { return fmtPct(s.max_profit_pct); },
|
||||
total_fee: function () { return fmtMoney(s.total_fee); },
|
||||
emotion_count: function () { return fmtNum(s.emotion_count); },
|
||||
emotion_ratio: function () { return fmtPct(s.emotion_ratio); },
|
||||
};
|
||||
document.querySelectorAll('#stats-summary [data-k]').forEach(function (el) {
|
||||
var key = el.getAttribute('data-k');
|
||||
el.textContent = map[key] ? map[key]() : '-';
|
||||
});
|
||||
}
|
||||
|
||||
function fillViewSelect(views, selected) {
|
||||
var sel = document.getElementById('stats-view-select');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
views.forEach(function (v) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v.key;
|
||||
opt.textContent = v.label;
|
||||
if (v.key === selected) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function cellClass(key, val) {
|
||||
if (key === 'total_net' || key === 'max_profit' || key === 'avg_profit') {
|
||||
if (val > 0) return 'text-profit';
|
||||
if (val < 0) return 'text-loss';
|
||||
}
|
||||
if (key === 'max_loss' || key === 'avg_loss' || key === 'total_fee') {
|
||||
return 'text-loss';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderBreakdown(key) {
|
||||
if (!cache || !cache.breakdowns) return;
|
||||
var block = cache.breakdowns[key];
|
||||
var head = document.getElementById('stats-breakdown-head');
|
||||
var body = document.getElementById('stats-breakdown-body');
|
||||
if (!block || !head || !body) return;
|
||||
|
||||
head.innerHTML = '';
|
||||
block.columns.forEach(function (col) {
|
||||
var th = document.createElement('th');
|
||||
th.textContent = col.label;
|
||||
head.appendChild(th);
|
||||
});
|
||||
|
||||
body.innerHTML = '';
|
||||
if (!block.rows || !block.rows.length) {
|
||||
var tr = document.createElement('tr');
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = block.columns.length;
|
||||
td.className = 'text-muted';
|
||||
td.textContent = '暂无数据';
|
||||
tr.appendChild(td);
|
||||
body.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
block.rows.forEach(function (row) {
|
||||
var tr = document.createElement('tr');
|
||||
block.columns.forEach(function (col) {
|
||||
var td = document.createElement('td');
|
||||
var val = row[col.key];
|
||||
if (col.key === 'win_rate') {
|
||||
td.textContent = fmtPct(val);
|
||||
} else if (col.key === 'label') {
|
||||
td.textContent = val || '-';
|
||||
} else if (typeof val === 'number') {
|
||||
td.textContent = fmtNum(val);
|
||||
td.className = cellClass(col.key, val);
|
||||
} else {
|
||||
td.textContent = val != null ? val : '-';
|
||||
}
|
||||
tr.appendChild(td);
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function applyData(data) {
|
||||
cache = data;
|
||||
setSummary(data.summary || {});
|
||||
var views = data.views || [];
|
||||
var sel = document.getElementById('stats-view-select');
|
||||
var current = sel && sel.value ? sel.value : (views[0] && views[0].key);
|
||||
fillViewSelect(views, current);
|
||||
renderBreakdown(current);
|
||||
var updated = document.getElementById('stats-updated');
|
||||
if (updated) {
|
||||
updated.textContent = data.updated_at
|
||||
? '统计更新于 ' + data.updated_at.replace('T', ' ')
|
||||
: '统计已加载';
|
||||
}
|
||||
}
|
||||
|
||||
function loadStats() {
|
||||
fetch('/api/stats')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(applyData)
|
||||
.catch(function () {
|
||||
var updated = document.getElementById('stats-updated');
|
||||
if (updated) updated.textContent = '加载失败,请刷新页面';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var viewSel = document.getElementById('stats-view-select');
|
||||
if (viewSel) {
|
||||
viewSel.addEventListener('change', function () {
|
||||
renderBreakdown(this.value);
|
||||
});
|
||||
}
|
||||
loadStats();
|
||||
});
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var cache = null;
|
||||
|
||||
function fmtNum(v, suffix) {
|
||||
if (v === null || v === undefined || v === '') return '-';
|
||||
var n = Number(v);
|
||||
if (isNaN(n)) return String(v);
|
||||
var s = Number.isInteger(n) ? String(n) : n.toFixed(2);
|
||||
return suffix ? s + suffix : s;
|
||||
}
|
||||
|
||||
function fmtMoney(v) {
|
||||
if (v === null || v === undefined) return '-';
|
||||
return fmtNum(v) + ' 元';
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
if (v === null || v === undefined) return '-';
|
||||
return fmtNum(v) + '%';
|
||||
}
|
||||
|
||||
function setSummary(s) {
|
||||
var map = {
|
||||
total_trades: function () { return fmtNum(s.total_trades); },
|
||||
win_rate: function () { return fmtPct(s.win_rate); },
|
||||
avg_profit: function () { return fmtMoney(s.avg_profit); },
|
||||
avg_loss: function () { return fmtMoney(s.avg_loss); },
|
||||
profit_loss_ratio: function () { return fmtNum(s.profit_loss_ratio); },
|
||||
consecutive_losses: function () { return fmtNum(s.consecutive_losses); },
|
||||
max_drawdown: function () {
|
||||
var amt = fmtMoney(s.max_drawdown);
|
||||
var pct = s.max_drawdown_pct ? ' (' + fmtPct(s.max_drawdown_pct) + ')' : '';
|
||||
return amt + pct;
|
||||
},
|
||||
max_loss_amount: function () { return fmtMoney(s.max_loss_amount); },
|
||||
max_loss_pct: function () { return fmtPct(s.max_loss_pct); },
|
||||
max_profit_amount: function () { return fmtMoney(s.max_profit_amount); },
|
||||
max_profit_pct: function () { return fmtPct(s.max_profit_pct); },
|
||||
total_fee: function () { return fmtMoney(s.total_fee); },
|
||||
emotion_count: function () { return fmtNum(s.emotion_count); },
|
||||
emotion_ratio: function () { return fmtPct(s.emotion_ratio); },
|
||||
};
|
||||
document.querySelectorAll('#stats-summary [data-k]').forEach(function (el) {
|
||||
var key = el.getAttribute('data-k');
|
||||
el.textContent = map[key] ? map[key]() : '-';
|
||||
});
|
||||
}
|
||||
|
||||
function fillViewSelect(views, selected) {
|
||||
var sel = document.getElementById('stats-view-select');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
views.forEach(function (v) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v.key;
|
||||
opt.textContent = v.label;
|
||||
if (v.key === selected) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function cellClass(key, val) {
|
||||
if (key === 'total_net' || key === 'max_profit' || key === 'avg_profit') {
|
||||
if (val > 0) return 'text-profit';
|
||||
if (val < 0) return 'text-loss';
|
||||
}
|
||||
if (key === 'max_loss' || key === 'avg_loss' || key === 'total_fee') {
|
||||
return 'text-loss';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderBreakdown(key) {
|
||||
if (!cache || !cache.breakdowns) return;
|
||||
var block = cache.breakdowns[key];
|
||||
var head = document.getElementById('stats-breakdown-head');
|
||||
var body = document.getElementById('stats-breakdown-body');
|
||||
if (!block || !head || !body) return;
|
||||
|
||||
head.innerHTML = '';
|
||||
block.columns.forEach(function (col) {
|
||||
var th = document.createElement('th');
|
||||
th.textContent = col.label;
|
||||
head.appendChild(th);
|
||||
});
|
||||
|
||||
body.innerHTML = '';
|
||||
if (!block.rows || !block.rows.length) {
|
||||
var tr = document.createElement('tr');
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = block.columns.length;
|
||||
td.className = 'text-muted';
|
||||
td.textContent = '暂无数据';
|
||||
tr.appendChild(td);
|
||||
body.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
block.rows.forEach(function (row) {
|
||||
var tr = document.createElement('tr');
|
||||
block.columns.forEach(function (col) {
|
||||
var td = document.createElement('td');
|
||||
var val = row[col.key];
|
||||
if (col.key === 'win_rate') {
|
||||
td.textContent = fmtPct(val);
|
||||
} else if (col.key === 'label') {
|
||||
td.textContent = val || '-';
|
||||
} else if (typeof val === 'number') {
|
||||
td.textContent = fmtNum(val);
|
||||
td.className = cellClass(col.key, val);
|
||||
} else {
|
||||
td.textContent = val != null ? val : '-';
|
||||
}
|
||||
tr.appendChild(td);
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function applyData(data) {
|
||||
cache = data;
|
||||
setSummary(data.summary || {});
|
||||
var views = data.views || [];
|
||||
var sel = document.getElementById('stats-view-select');
|
||||
var current = sel && sel.value ? sel.value : (views[0] && views[0].key);
|
||||
fillViewSelect(views, current);
|
||||
renderBreakdown(current);
|
||||
var updated = document.getElementById('stats-updated');
|
||||
if (updated) {
|
||||
updated.textContent = data.updated_at
|
||||
? '统计更新于 ' + data.updated_at.replace('T', ' ')
|
||||
: '统计已加载';
|
||||
}
|
||||
}
|
||||
|
||||
function loadStats() {
|
||||
fetch('/api/stats')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(applyData)
|
||||
.catch(function () {
|
||||
var updated = document.getElementById('stats-updated');
|
||||
if (updated) updated.textContent = '加载失败,请刷新页面';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var viewSel = document.getElementById('stats-view-select');
|
||||
if (viewSel) {
|
||||
viewSel.addEventListener('change', function () {
|
||||
renderBreakdown(this.value);
|
||||
});
|
||||
}
|
||||
loadStats();
|
||||
});
|
||||
})();
|
||||
|
||||
+140
-136
@@ -1,136 +1,140 @@
|
||||
(function () {
|
||||
var trendPayload = null;
|
||||
|
||||
function jsonPost(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {})
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function formData(form) {
|
||||
var fd = new FormData(form);
|
||||
var o = {};
|
||||
fd.forEach(function (v, k) { o[k] = v; });
|
||||
return o;
|
||||
}
|
||||
|
||||
function showPreview(el, text, ok) {
|
||||
if (!el) return;
|
||||
if (!text) {
|
||||
el.hidden = true;
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.textContent = text;
|
||||
el.style.color = ok === false ? 'var(--loss)' : '';
|
||||
}
|
||||
|
||||
function formatPlan(plan) {
|
||||
if (!plan) return '';
|
||||
var lines = [];
|
||||
if (plan.symbol) lines.push('品种:' + plan.symbol);
|
||||
if (plan.target_lots != null) lines.push('目标手数:' + plan.target_lots);
|
||||
if (plan.first_lots != null) lines.push('首仓:' + plan.first_lots + ' 手');
|
||||
if (plan.grid && plan.grid.length) {
|
||||
lines.push('补仓档位:' + plan.grid.map(function (g) { return g.price; }).join(' → '));
|
||||
}
|
||||
if (plan.message) lines.push(plan.message);
|
||||
return lines.length ? lines.join('\n') : JSON.stringify(plan, null, 2);
|
||||
}
|
||||
|
||||
function formatRoll(preview) {
|
||||
if (!preview) return '';
|
||||
var lines = [];
|
||||
if (preview.add_lots != null) lines.push('加仓手数:' + preview.add_lots);
|
||||
if (preview.new_stop_loss != null) lines.push('新止损:' + preview.new_stop_loss);
|
||||
if (preview.total_lots != null) lines.push('合计手数:' + preview.total_lots);
|
||||
if (preview.worst_loss != null) lines.push('最坏亏损:' + preview.worst_loss + ' 元');
|
||||
if (preview.message) lines.push(preview.message);
|
||||
return lines.length ? lines.join('\n') : JSON.stringify(preview, null, 2);
|
||||
}
|
||||
|
||||
var trendForm = document.getElementById('trend-form');
|
||||
var btnPreview = document.getElementById('btn-trend-preview');
|
||||
var btnExec = document.getElementById('btn-trend-exec');
|
||||
var previewEl = document.getElementById('trend-preview');
|
||||
|
||||
if (btnPreview && trendForm) {
|
||||
btnPreview.addEventListener('click', function () {
|
||||
btnPreview.disabled = true;
|
||||
jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) {
|
||||
if (!d.ok) {
|
||||
showPreview(previewEl, d.error || '预览失败', false);
|
||||
btnExec.hidden = true;
|
||||
return;
|
||||
}
|
||||
trendPayload = formData(trendForm);
|
||||
showPreview(previewEl, formatPlan(d.plan), true);
|
||||
btnExec.hidden = false;
|
||||
}).finally(function () {
|
||||
btnPreview.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (btnExec) {
|
||||
btnExec.addEventListener('click', function () {
|
||||
if (!trendPayload) return;
|
||||
btnExec.disabled = true;
|
||||
btnExec.textContent = '执行中…';
|
||||
jsonPost('/api/strategy/trend/execute', trendPayload).then(function (d) {
|
||||
if (!d.ok) { alert(d.error); return; }
|
||||
location.reload();
|
||||
}).finally(function () {
|
||||
btnExec.disabled = false;
|
||||
btnExec.textContent = '确认执行首仓';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var rollForm = document.getElementById('roll-form');
|
||||
var btnRollP = document.getElementById('btn-roll-preview');
|
||||
var btnRollE = document.getElementById('btn-roll-exec');
|
||||
var rollPrev = document.getElementById('roll-preview');
|
||||
if (btnRollP && rollForm) {
|
||||
btnRollP.addEventListener('click', function () {
|
||||
btnRollP.disabled = true;
|
||||
jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) {
|
||||
if (!d.ok) {
|
||||
showPreview(rollPrev, d.error, false);
|
||||
btnRollE.hidden = true;
|
||||
return;
|
||||
}
|
||||
showPreview(rollPrev, formatRoll(d.preview), true);
|
||||
btnRollE.hidden = false;
|
||||
}).finally(function () {
|
||||
btnRollP.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (btnRollE && rollForm) {
|
||||
btnRollE.addEventListener('click', function () {
|
||||
btnRollE.disabled = true;
|
||||
btnRollE.textContent = '执行中…';
|
||||
jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) {
|
||||
if (!d.ok) { alert(d.error); return; }
|
||||
location.reload();
|
||||
}).finally(function () {
|
||||
btnRollE.disabled = false;
|
||||
btnRollE.textContent = '执行滚仓';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var btnStop = document.getElementById('btn-trend-stop');
|
||||
if (btnStop) {
|
||||
btnStop.addEventListener('click', function () {
|
||||
var pid = document.querySelector('#trend-stop-form input[name=plan_id]');
|
||||
jsonPost('/api/strategy/trend/stop', { plan_id: pid ? pid.value : 0 }).then(function (d) {
|
||||
if (!d.ok) { alert(d.error); return; }
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var trendPayload = null;
|
||||
|
||||
function jsonPost(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {})
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function formData(form) {
|
||||
var fd = new FormData(form);
|
||||
var o = {};
|
||||
fd.forEach(function (v, k) { o[k] = v; });
|
||||
return o;
|
||||
}
|
||||
|
||||
function showPreview(el, text, ok) {
|
||||
if (!el) return;
|
||||
if (!text) {
|
||||
el.hidden = true;
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.textContent = text;
|
||||
el.style.color = ok === false ? 'var(--loss)' : '';
|
||||
}
|
||||
|
||||
function formatPlan(plan) {
|
||||
if (!plan) return '';
|
||||
var lines = [];
|
||||
if (plan.symbol) lines.push('品种:' + plan.symbol);
|
||||
if (plan.target_lots != null) lines.push('目标手数:' + plan.target_lots);
|
||||
if (plan.first_lots != null) lines.push('首仓:' + plan.first_lots + ' 手');
|
||||
if (plan.grid && plan.grid.length) {
|
||||
lines.push('补仓档位:' + plan.grid.map(function (g) { return g.price; }).join(' → '));
|
||||
}
|
||||
if (plan.message) lines.push(plan.message);
|
||||
return lines.length ? lines.join('\n') : JSON.stringify(plan, null, 2);
|
||||
}
|
||||
|
||||
function formatRoll(preview) {
|
||||
if (!preview) return '';
|
||||
var lines = [];
|
||||
if (preview.add_lots != null) lines.push('加仓手数:' + preview.add_lots);
|
||||
if (preview.new_stop_loss != null) lines.push('新止损:' + preview.new_stop_loss);
|
||||
if (preview.total_lots != null) lines.push('合计手数:' + preview.total_lots);
|
||||
if (preview.worst_loss != null) lines.push('最坏亏损:' + preview.worst_loss + ' 元');
|
||||
if (preview.message) lines.push(preview.message);
|
||||
return lines.length ? lines.join('\n') : JSON.stringify(preview, null, 2);
|
||||
}
|
||||
|
||||
var trendForm = document.getElementById('trend-form');
|
||||
var btnPreview = document.getElementById('btn-trend-preview');
|
||||
var btnExec = document.getElementById('btn-trend-exec');
|
||||
var previewEl = document.getElementById('trend-preview');
|
||||
|
||||
if (btnPreview && trendForm) {
|
||||
btnPreview.addEventListener('click', function () {
|
||||
btnPreview.disabled = true;
|
||||
jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) {
|
||||
if (!d.ok) {
|
||||
showPreview(previewEl, d.error || '预览失败', false);
|
||||
btnExec.hidden = true;
|
||||
return;
|
||||
}
|
||||
trendPayload = formData(trendForm);
|
||||
showPreview(previewEl, formatPlan(d.plan), true);
|
||||
btnExec.hidden = false;
|
||||
}).finally(function () {
|
||||
btnPreview.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (btnExec) {
|
||||
btnExec.addEventListener('click', function () {
|
||||
if (!trendPayload) return;
|
||||
btnExec.disabled = true;
|
||||
btnExec.textContent = '执行中…';
|
||||
jsonPost('/api/strategy/trend/execute', trendPayload).then(function (d) {
|
||||
if (!d.ok) { alert(d.error); return; }
|
||||
location.reload();
|
||||
}).finally(function () {
|
||||
btnExec.disabled = false;
|
||||
btnExec.textContent = '确认执行首仓';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var rollForm = document.getElementById('roll-form');
|
||||
var btnRollP = document.getElementById('btn-roll-preview');
|
||||
var btnRollE = document.getElementById('btn-roll-exec');
|
||||
var rollPrev = document.getElementById('roll-preview');
|
||||
if (btnRollP && rollForm) {
|
||||
btnRollP.addEventListener('click', function () {
|
||||
btnRollP.disabled = true;
|
||||
jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) {
|
||||
if (!d.ok) {
|
||||
showPreview(rollPrev, d.error, false);
|
||||
btnRollE.hidden = true;
|
||||
return;
|
||||
}
|
||||
showPreview(rollPrev, formatRoll(d.preview), true);
|
||||
btnRollE.hidden = false;
|
||||
}).finally(function () {
|
||||
btnRollP.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (btnRollE && rollForm) {
|
||||
btnRollE.addEventListener('click', function () {
|
||||
btnRollE.disabled = true;
|
||||
btnRollE.textContent = '执行中…';
|
||||
jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) {
|
||||
if (!d.ok) { alert(d.error); return; }
|
||||
location.reload();
|
||||
}).finally(function () {
|
||||
btnRollE.disabled = false;
|
||||
btnRollE.textContent = '执行滚仓';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var btnStop = document.getElementById('btn-trend-stop');
|
||||
if (btnStop) {
|
||||
btnStop.addEventListener('click', function () {
|
||||
var pid = document.querySelector('#trend-stop-form input[name=plan_id]');
|
||||
jsonPost('/api/strategy/trend/stop', { plan_id: pid ? pid.value : 0 }).then(function (d) {
|
||||
if (!d.ok) { alert(d.error); return; }
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
+286
-282
@@ -1,282 +1,286 @@
|
||||
(function () {
|
||||
var recommendedGroupsCache = null;
|
||||
var recommendedGroupsPromise = null;
|
||||
|
||||
function loadRecommendedGroups() {
|
||||
if (recommendedGroupsCache) {
|
||||
return Promise.resolve(recommendedGroupsCache);
|
||||
}
|
||||
if (recommendedGroupsPromise) {
|
||||
return recommendedGroupsPromise;
|
||||
}
|
||||
recommendedGroupsPromise = fetch('/api/symbols/recommended')
|
||||
.then(function (r) {
|
||||
if (!r.ok) {
|
||||
throw new Error('HTTP ' + r.status);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function (groups) {
|
||||
recommendedGroupsCache = Array.isArray(groups) ? groups : [];
|
||||
return recommendedGroupsCache;
|
||||
})
|
||||
.catch(function () {
|
||||
recommendedGroupsCache = null;
|
||||
throw new Error('load failed');
|
||||
})
|
||||
.finally(function () {
|
||||
recommendedGroupsPromise = null;
|
||||
});
|
||||
return recommendedGroupsPromise;
|
||||
}
|
||||
|
||||
function formatSub(item) {
|
||||
var sub = '同花顺 ' + item.ths_code +
|
||||
(item.market_code ? ' · ' + item.market_code : '') +
|
||||
' · ' + (item.exchange || '');
|
||||
if (item.max_lots != null && item.max_lots > 0) {
|
||||
sub += ' · 最大 ' + item.max_lots + ' 手';
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
function formatInputLabel(item) {
|
||||
return item.input_label || (item.name + ' ' + item.ths_code);
|
||||
}
|
||||
|
||||
function itemMatchesQuery(item, qLower) {
|
||||
if (!qLower) return true;
|
||||
var hay = (
|
||||
item.name + ' ' + item.ths_code + ' ' +
|
||||
(item.display || '') + ' ' + (item.contract || '') + ' ' +
|
||||
(item.exchange || '')
|
||||
).toLowerCase();
|
||||
return hay.indexOf(qLower) >= 0;
|
||||
}
|
||||
|
||||
function groupedHasMatch(groups, qLower) {
|
||||
if (!qLower) return true;
|
||||
return groups.some(function (group) {
|
||||
return group.items.some(function (item) {
|
||||
return itemMatchesQuery(item, qLower);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initSymbolInput(wrapper) {
|
||||
const input = wrapper.querySelector('.symbol-input');
|
||||
const hiddenThs = wrapper.querySelector('input[name="symbol"]')
|
||||
|| wrapper.querySelector('.symbol-ths-code');
|
||||
const hiddenName = wrapper.querySelector('input[name="symbol_name"]');
|
||||
const hiddenMarket = wrapper.querySelector('input[name="market_code"]');
|
||||
const hiddenSina = wrapper.querySelector('input[name="sina_code"]');
|
||||
const dropdown = wrapper.querySelector('.symbol-dropdown');
|
||||
const selectedEl = wrapper.querySelector('.symbol-selected');
|
||||
const isMarketPicker = wrapper.classList.contains('market-symbol-wrap');
|
||||
const useMainsPicker = isMarketPicker || wrapper.classList.contains('symbol-mains');
|
||||
let timer = null;
|
||||
let abortCtrl = null;
|
||||
const cache = new Map();
|
||||
let mainsCache = null;
|
||||
|
||||
function hideDropdown() {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
const label = formatInputLabel(item);
|
||||
input.value = label;
|
||||
if (hiddenThs) hiddenThs.value = item.ths_code;
|
||||
if (hiddenName) hiddenName.value = item.name;
|
||||
if (hiddenMarket) hiddenMarket.value = item.market_code || '';
|
||||
if (hiddenSina) hiddenSina.value = item.sina_code || '';
|
||||
if (selectedEl) selectedEl.textContent = formatSub(item);
|
||||
hideDropdown();
|
||||
input.dispatchEvent(new CustomEvent('symbol-selected', { detail: item, bubbles: true }));
|
||||
}
|
||||
|
||||
function buildOptionEl(item) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'symbol-option';
|
||||
if (item.near_expiry) {
|
||||
div.classList.add('near-expiry');
|
||||
}
|
||||
var label = item.display || (item.name + ' ' + item.ths_code);
|
||||
if (item.near_expiry) {
|
||||
label += ' <span class="near-expiry-tag">临期</span>';
|
||||
}
|
||||
div.innerHTML = label +
|
||||
'<div class="sub">' + formatSub(item) + '</div>';
|
||||
div.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
selectItem(item);
|
||||
});
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
dropdown.innerHTML = '';
|
||||
if (!items.length) {
|
||||
dropdown.innerHTML = '<div class="symbol-option">无匹配,可输入同花顺代码如 ag2608</div>';
|
||||
} else {
|
||||
items.forEach(function (item) {
|
||||
dropdown.appendChild(buildOptionEl(item));
|
||||
});
|
||||
}
|
||||
dropdown.classList.add('show');
|
||||
}
|
||||
|
||||
function renderGrouped(groups, filterQ) {
|
||||
dropdown.innerHTML = '';
|
||||
const qLower = (filterQ || '').trim().toLowerCase();
|
||||
let any = false;
|
||||
groups.forEach(function (group) {
|
||||
const items = group.items.filter(function (item) {
|
||||
return itemMatchesQuery(item, qLower);
|
||||
});
|
||||
if (!items.length) return;
|
||||
any = true;
|
||||
const head = document.createElement('div');
|
||||
head.className = 'symbol-group-head';
|
||||
head.textContent = group.category;
|
||||
dropdown.appendChild(head);
|
||||
items.forEach(function (item) {
|
||||
dropdown.appendChild(buildOptionEl(item));
|
||||
});
|
||||
});
|
||||
if (!any) {
|
||||
dropdown.innerHTML = '<div class="symbol-option">无匹配品种,可输入合约代码如 ag2608</div>';
|
||||
}
|
||||
dropdown.classList.add('show');
|
||||
}
|
||||
|
||||
function showMarketMains(filterQ, onEmpty) {
|
||||
const q = (filterQ || '').trim();
|
||||
const qLower = q.toLowerCase();
|
||||
if (mainsCache) {
|
||||
if (!q || groupedHasMatch(mainsCache, qLower)) {
|
||||
renderGrouped(mainsCache, q);
|
||||
return;
|
||||
}
|
||||
if (typeof onEmpty === 'function') {
|
||||
onEmpty(q);
|
||||
return;
|
||||
}
|
||||
renderGrouped(mainsCache, q);
|
||||
return;
|
||||
}
|
||||
dropdown.innerHTML = '<div class="symbol-option">正在加载推荐品种…</div>';
|
||||
dropdown.classList.add('show');
|
||||
loadRecommendedGroups()
|
||||
.then(function (groups) {
|
||||
mainsCache = groups;
|
||||
if (!groups.length) {
|
||||
dropdown.innerHTML =
|
||||
'<div class="symbol-option">当前资金下暂无推荐品种,可输入合约代码搜索</div>';
|
||||
dropdown.classList.add('show');
|
||||
return;
|
||||
}
|
||||
showMarketMains(filterQ, onEmpty);
|
||||
})
|
||||
.catch(function () {
|
||||
dropdown.innerHTML =
|
||||
'<div class="symbol-option">推荐品种加载失败,请刷新页面或输入合约代码搜索</div>';
|
||||
dropdown.classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
function search(q) {
|
||||
if (cache.has(q)) {
|
||||
renderItems(cache.get(q));
|
||||
return;
|
||||
}
|
||||
if (abortCtrl) {
|
||||
abortCtrl.abort();
|
||||
}
|
||||
abortCtrl = new AbortController();
|
||||
fetch('/api/symbols/search?q=' + encodeURIComponent(q), {
|
||||
signal: abortCtrl.signal,
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (items) {
|
||||
cache.set(q, items);
|
||||
renderItems(items);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (err && err.name === 'AbortError') return;
|
||||
hideDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
function handleQuery(q) {
|
||||
if (useMainsPicker) {
|
||||
showMarketMains(q, function (query) {
|
||||
search(query);
|
||||
});
|
||||
} else {
|
||||
search(q);
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
if (hiddenThs) hiddenThs.value = '';
|
||||
if (hiddenName) hiddenName.value = '';
|
||||
if (hiddenMarket) hiddenMarket.value = '';
|
||||
if (hiddenSina) hiddenSina.value = '';
|
||||
if (selectedEl) selectedEl.textContent = '';
|
||||
const q = input.value.trim();
|
||||
if (!q) {
|
||||
if (useMainsPicker) {
|
||||
showMarketMains('');
|
||||
} else {
|
||||
hideDropdown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
handleQuery(q);
|
||||
}, 120);
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function () {
|
||||
setTimeout(hideDropdown, 150);
|
||||
});
|
||||
|
||||
input.addEventListener('focus', function () {
|
||||
const q = input.value.trim();
|
||||
if (useMainsPicker) {
|
||||
showMarketMains(q, function (query) {
|
||||
if (query) search(query);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (q && hiddenThs && !hiddenThs.value) {
|
||||
search(q);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.symbol-wrap').forEach(initSymbolInput);
|
||||
|
||||
document.querySelectorAll('form').forEach(function (form) {
|
||||
if (!form.querySelector('.symbol-wrap')) return;
|
||||
if (form.id === 'market-form') return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
const ths = form.querySelector('input[name="symbol"]')
|
||||
|| form.querySelector('.symbol-ths-code');
|
||||
const market = form.querySelector('input[name="market_code"]');
|
||||
if (ths && !ths.value.trim()) {
|
||||
e.preventDefault();
|
||||
alert('请从下拉列表选择品种');
|
||||
return;
|
||||
}
|
||||
if (market && !market.value.trim()) {
|
||||
e.preventDefault();
|
||||
alert('请从下拉列表选择品种(需含同花顺行情代码)');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var recommendedGroupsCache = null;
|
||||
var recommendedGroupsPromise = null;
|
||||
|
||||
function loadRecommendedGroups() {
|
||||
if (recommendedGroupsCache) {
|
||||
return Promise.resolve(recommendedGroupsCache);
|
||||
}
|
||||
if (recommendedGroupsPromise) {
|
||||
return recommendedGroupsPromise;
|
||||
}
|
||||
recommendedGroupsPromise = fetch('/api/symbols/recommended')
|
||||
.then(function (r) {
|
||||
if (!r.ok) {
|
||||
throw new Error('HTTP ' + r.status);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function (groups) {
|
||||
recommendedGroupsCache = Array.isArray(groups) ? groups : [];
|
||||
return recommendedGroupsCache;
|
||||
})
|
||||
.catch(function () {
|
||||
recommendedGroupsCache = null;
|
||||
throw new Error('load failed');
|
||||
})
|
||||
.finally(function () {
|
||||
recommendedGroupsPromise = null;
|
||||
});
|
||||
return recommendedGroupsPromise;
|
||||
}
|
||||
|
||||
function formatSub(item) {
|
||||
var sub = '同花顺 ' + item.ths_code +
|
||||
(item.market_code ? ' · ' + item.market_code : '') +
|
||||
' · ' + (item.exchange || '');
|
||||
if (item.max_lots != null && item.max_lots > 0) {
|
||||
sub += ' · 最大 ' + item.max_lots + ' 手';
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
function formatInputLabel(item) {
|
||||
return item.input_label || (item.name + ' ' + item.ths_code);
|
||||
}
|
||||
|
||||
function itemMatchesQuery(item, qLower) {
|
||||
if (!qLower) return true;
|
||||
var hay = (
|
||||
item.name + ' ' + item.ths_code + ' ' +
|
||||
(item.display || '') + ' ' + (item.contract || '') + ' ' +
|
||||
(item.exchange || '')
|
||||
).toLowerCase();
|
||||
return hay.indexOf(qLower) >= 0;
|
||||
}
|
||||
|
||||
function groupedHasMatch(groups, qLower) {
|
||||
if (!qLower) return true;
|
||||
return groups.some(function (group) {
|
||||
return group.items.some(function (item) {
|
||||
return itemMatchesQuery(item, qLower);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initSymbolInput(wrapper) {
|
||||
const input = wrapper.querySelector('.symbol-input');
|
||||
const hiddenThs = wrapper.querySelector('input[name="symbol"]')
|
||||
|| wrapper.querySelector('.symbol-ths-code');
|
||||
const hiddenName = wrapper.querySelector('input[name="symbol_name"]');
|
||||
const hiddenMarket = wrapper.querySelector('input[name="market_code"]');
|
||||
const hiddenSina = wrapper.querySelector('input[name="sina_code"]');
|
||||
const dropdown = wrapper.querySelector('.symbol-dropdown');
|
||||
const selectedEl = wrapper.querySelector('.symbol-selected');
|
||||
const isMarketPicker = wrapper.classList.contains('market-symbol-wrap');
|
||||
const useMainsPicker = isMarketPicker || wrapper.classList.contains('symbol-mains');
|
||||
let timer = null;
|
||||
let abortCtrl = null;
|
||||
const cache = new Map();
|
||||
let mainsCache = null;
|
||||
|
||||
function hideDropdown() {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
const label = formatInputLabel(item);
|
||||
input.value = label;
|
||||
if (hiddenThs) hiddenThs.value = item.ths_code;
|
||||
if (hiddenName) hiddenName.value = item.name;
|
||||
if (hiddenMarket) hiddenMarket.value = item.market_code || '';
|
||||
if (hiddenSina) hiddenSina.value = item.sina_code || '';
|
||||
if (selectedEl) selectedEl.textContent = formatSub(item);
|
||||
hideDropdown();
|
||||
input.dispatchEvent(new CustomEvent('symbol-selected', { detail: item, bubbles: true }));
|
||||
}
|
||||
|
||||
function buildOptionEl(item) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'symbol-option';
|
||||
if (item.near_expiry) {
|
||||
div.classList.add('near-expiry');
|
||||
}
|
||||
var label = item.display || (item.name + ' ' + item.ths_code);
|
||||
if (item.near_expiry) {
|
||||
label += ' <span class="near-expiry-tag">临期</span>';
|
||||
}
|
||||
div.innerHTML = label +
|
||||
'<div class="sub">' + formatSub(item) + '</div>';
|
||||
div.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
selectItem(item);
|
||||
});
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
dropdown.innerHTML = '';
|
||||
if (!items.length) {
|
||||
dropdown.innerHTML = '<div class="symbol-option">无匹配,可输入同花顺代码如 ag2608</div>';
|
||||
} else {
|
||||
items.forEach(function (item) {
|
||||
dropdown.appendChild(buildOptionEl(item));
|
||||
});
|
||||
}
|
||||
dropdown.classList.add('show');
|
||||
}
|
||||
|
||||
function renderGrouped(groups, filterQ) {
|
||||
dropdown.innerHTML = '';
|
||||
const qLower = (filterQ || '').trim().toLowerCase();
|
||||
let any = false;
|
||||
groups.forEach(function (group) {
|
||||
const items = group.items.filter(function (item) {
|
||||
return itemMatchesQuery(item, qLower);
|
||||
});
|
||||
if (!items.length) return;
|
||||
any = true;
|
||||
const head = document.createElement('div');
|
||||
head.className = 'symbol-group-head';
|
||||
head.textContent = group.category;
|
||||
dropdown.appendChild(head);
|
||||
items.forEach(function (item) {
|
||||
dropdown.appendChild(buildOptionEl(item));
|
||||
});
|
||||
});
|
||||
if (!any) {
|
||||
dropdown.innerHTML = '<div class="symbol-option">无匹配品种,可输入合约代码如 ag2608</div>';
|
||||
}
|
||||
dropdown.classList.add('show');
|
||||
}
|
||||
|
||||
function showMarketMains(filterQ, onEmpty) {
|
||||
const q = (filterQ || '').trim();
|
||||
const qLower = q.toLowerCase();
|
||||
if (mainsCache) {
|
||||
if (!q || groupedHasMatch(mainsCache, qLower)) {
|
||||
renderGrouped(mainsCache, q);
|
||||
return;
|
||||
}
|
||||
if (typeof onEmpty === 'function') {
|
||||
onEmpty(q);
|
||||
return;
|
||||
}
|
||||
renderGrouped(mainsCache, q);
|
||||
return;
|
||||
}
|
||||
dropdown.innerHTML = '<div class="symbol-option">正在加载推荐品种…</div>';
|
||||
dropdown.classList.add('show');
|
||||
loadRecommendedGroups()
|
||||
.then(function (groups) {
|
||||
mainsCache = groups;
|
||||
if (!groups.length) {
|
||||
dropdown.innerHTML =
|
||||
'<div class="symbol-option">当前资金下暂无推荐品种,可输入合约代码搜索</div>';
|
||||
dropdown.classList.add('show');
|
||||
return;
|
||||
}
|
||||
showMarketMains(filterQ, onEmpty);
|
||||
})
|
||||
.catch(function () {
|
||||
dropdown.innerHTML =
|
||||
'<div class="symbol-option">推荐品种加载失败,请刷新页面或输入合约代码搜索</div>';
|
||||
dropdown.classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
function search(q) {
|
||||
if (cache.has(q)) {
|
||||
renderItems(cache.get(q));
|
||||
return;
|
||||
}
|
||||
if (abortCtrl) {
|
||||
abortCtrl.abort();
|
||||
}
|
||||
abortCtrl = new AbortController();
|
||||
fetch('/api/symbols/search?q=' + encodeURIComponent(q), {
|
||||
signal: abortCtrl.signal,
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (items) {
|
||||
cache.set(q, items);
|
||||
renderItems(items);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (err && err.name === 'AbortError') return;
|
||||
hideDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
function handleQuery(q) {
|
||||
if (useMainsPicker) {
|
||||
showMarketMains(q, function (query) {
|
||||
search(query);
|
||||
});
|
||||
} else {
|
||||
search(q);
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
if (hiddenThs) hiddenThs.value = '';
|
||||
if (hiddenName) hiddenName.value = '';
|
||||
if (hiddenMarket) hiddenMarket.value = '';
|
||||
if (hiddenSina) hiddenSina.value = '';
|
||||
if (selectedEl) selectedEl.textContent = '';
|
||||
const q = input.value.trim();
|
||||
if (!q) {
|
||||
if (useMainsPicker) {
|
||||
showMarketMains('');
|
||||
} else {
|
||||
hideDropdown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
handleQuery(q);
|
||||
}, 120);
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function () {
|
||||
setTimeout(hideDropdown, 150);
|
||||
});
|
||||
|
||||
input.addEventListener('focus', function () {
|
||||
const q = input.value.trim();
|
||||
if (useMainsPicker) {
|
||||
showMarketMains(q, function (query) {
|
||||
if (query) search(query);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (q && hiddenThs && !hiddenThs.value) {
|
||||
search(q);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.symbol-wrap').forEach(initSymbolInput);
|
||||
|
||||
document.querySelectorAll('form').forEach(function (form) {
|
||||
if (!form.querySelector('.symbol-wrap')) return;
|
||||
if (form.id === 'market-form') return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
const ths = form.querySelector('input[name="symbol"]')
|
||||
|| form.querySelector('.symbol-ths-code');
|
||||
const market = form.querySelector('input[name="market_code"]');
|
||||
if (ths && !ths.value.trim()) {
|
||||
e.preventDefault();
|
||||
alert('请从下拉列表选择品种');
|
||||
return;
|
||||
}
|
||||
if (market && !market.value.trim()) {
|
||||
e.preventDefault();
|
||||
alert('请从下拉列表选择品种(需含同花顺行情代码)');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
+57
-53
@@ -1,53 +1,57 @@
|
||||
(function () {
|
||||
var KEY = 'qihuo-theme';
|
||||
|
||||
function updateButtons(theme) {
|
||||
document.querySelectorAll('[data-theme-pick]').forEach(function (btn) {
|
||||
var pick = btn.getAttribute('data-theme-pick');
|
||||
var on = pick === theme;
|
||||
btn.classList.toggle('active', on);
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
if (theme !== 'light' && theme !== 'dark') {
|
||||
theme = 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try {
|
||||
localStorage.setItem(KEY, theme);
|
||||
} catch (e) { /* ignore */ }
|
||||
updateButtons(theme);
|
||||
}
|
||||
|
||||
var saved = null;
|
||||
try {
|
||||
saved = localStorage.getItem(KEY);
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
apply(saved);
|
||||
} else {
|
||||
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
apply(prefersLight ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-theme-pick]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
apply(btn.getAttribute('data-theme-pick'));
|
||||
});
|
||||
|
||||
function syncButtons() {
|
||||
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
updateButtons(cur);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', syncButtons);
|
||||
} else {
|
||||
syncButtons();
|
||||
}
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var KEY = 'qihuo-theme';
|
||||
|
||||
function updateButtons(theme) {
|
||||
document.querySelectorAll('[data-theme-pick]').forEach(function (btn) {
|
||||
var pick = btn.getAttribute('data-theme-pick');
|
||||
var on = pick === theme;
|
||||
btn.classList.toggle('active', on);
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
if (theme !== 'light' && theme !== 'dark') {
|
||||
theme = 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try {
|
||||
localStorage.setItem(KEY, theme);
|
||||
} catch (e) { /* ignore */ }
|
||||
updateButtons(theme);
|
||||
}
|
||||
|
||||
var saved = null;
|
||||
try {
|
||||
saved = localStorage.getItem(KEY);
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
apply(saved);
|
||||
} else {
|
||||
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
apply(prefersLight ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-theme-pick]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
apply(btn.getAttribute('data-theme-pick'));
|
||||
});
|
||||
|
||||
function syncButtons() {
|
||||
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
updateButtons(cur);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', syncButtons);
|
||||
} else {
|
||||
syncButtons();
|
||||
}
|
||||
})();
|
||||
|
||||
+1487
-1483
File diff suppressed because it is too large
Load Diff
+46
-42
@@ -1,42 +1,46 @@
|
||||
(function () {
|
||||
var switchEl = document.getElementById('trade-edit-switch');
|
||||
if (!switchEl) return;
|
||||
|
||||
function setEditMode(on) {
|
||||
document.querySelectorAll('.cell-edit-hide').forEach(function (el) {
|
||||
el.style.display = on ? 'none' : '';
|
||||
});
|
||||
document.querySelectorAll('.cell-edit-show').forEach(function (el) {
|
||||
if (el.type === 'hidden') return;
|
||||
el.style.display = on ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.trade-save-btn').forEach(function (btn) {
|
||||
btn.disabled = !on;
|
||||
});
|
||||
}
|
||||
|
||||
switchEl.addEventListener('change', function () {
|
||||
setEditMode(switchEl.checked);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.trade-save-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var row = btn.closest('tr[data-trade-id]');
|
||||
if (!row) return;
|
||||
var id = row.getAttribute('data-trade-id');
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/update_trade/' + id;
|
||||
row.querySelectorAll('.cell-edit-show').forEach(function (el) {
|
||||
if (!el.name) return;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = el.name;
|
||||
input.value = el.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var switchEl = document.getElementById('trade-edit-switch');
|
||||
if (!switchEl) return;
|
||||
|
||||
function setEditMode(on) {
|
||||
document.querySelectorAll('.cell-edit-hide').forEach(function (el) {
|
||||
el.style.display = on ? 'none' : '';
|
||||
});
|
||||
document.querySelectorAll('.cell-edit-show').forEach(function (el) {
|
||||
if (el.type === 'hidden') return;
|
||||
el.style.display = on ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.trade-save-btn').forEach(function (btn) {
|
||||
btn.disabled = !on;
|
||||
});
|
||||
}
|
||||
|
||||
switchEl.addEventListener('change', function () {
|
||||
setEditMode(switchEl.checked);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.trade-save-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var row = btn.closest('tr[data-trade-id]');
|
||||
if (!row) return;
|
||||
var id = row.getAttribute('data-trade-id');
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/update_trade/' + id;
|
||||
row.querySelectorAll('.cell-edit-show').forEach(function (el) {
|
||||
if (!el.name) return;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = el.name;
|
||||
input.value = el.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
+68
-64
@@ -1,64 +1,68 @@
|
||||
var CACHE_VERSION = 'qihuo-v3';
|
||||
var STATIC_CACHE = CACHE_VERSION + '-static';
|
||||
var STATIC_ASSETS = [
|
||||
'/static/css/tech.css',
|
||||
'/static/css/responsive.css',
|
||||
'/static/css/trade.css',
|
||||
'/static/js/theme.js',
|
||||
'/static/js/nav.js',
|
||||
'/static/js/pwa.js',
|
||||
'/static/js/symbol.js',
|
||||
'/static/js/trade.js',
|
||||
'/static/icons/icon-192.png',
|
||||
'/static/icons/icon-512.png',
|
||||
'/static/icons/icon.svg',
|
||||
'/static/manifest.json',
|
||||
'/login'
|
||||
];
|
||||
|
||||
self.addEventListener('install', function (event) {
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE).then(function (cache) {
|
||||
return cache.addAll(STATIC_ASSETS).catch(function () { /* ignore partial */ });
|
||||
}).then(function () { return self.skipWaiting(); })
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.filter(function (k) {
|
||||
return k.startsWith('qihuo-') && k !== STATIC_CACHE;
|
||||
}).map(function (k) { return caches.delete(k); }));
|
||||
}).then(function () { return self.clients.claim(); })
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
var req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
var url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname.indexOf('/static/') === 0) {
|
||||
event.respondWith(
|
||||
caches.match(req).then(function (cached) {
|
||||
return cached || fetch(req).then(function (res) {
|
||||
var copy = res.clone();
|
||||
caches.open(STATIC_CACHE).then(function (cache) { cache.put(req, copy); });
|
||||
return res;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.mode === 'navigate' || (req.headers.get('accept') || '').indexOf('text/html') !== -1) {
|
||||
event.respondWith(
|
||||
fetch(req).catch(function () {
|
||||
return caches.match('/login');
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
var CACHE_VERSION = 'qihuo-v3';
|
||||
var STATIC_CACHE = CACHE_VERSION + '-static';
|
||||
var STATIC_ASSETS = [
|
||||
'/static/css/tech.css',
|
||||
'/static/css/responsive.css',
|
||||
'/static/css/trade.css',
|
||||
'/static/js/theme.js',
|
||||
'/static/js/nav.js',
|
||||
'/static/js/pwa.js',
|
||||
'/static/js/symbol.js',
|
||||
'/static/js/trade.js',
|
||||
'/static/icons/icon-192.png',
|
||||
'/static/icons/icon-512.png',
|
||||
'/static/icons/icon.svg',
|
||||
'/static/manifest.json',
|
||||
'/login'
|
||||
];
|
||||
|
||||
self.addEventListener('install', function (event) {
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE).then(function (cache) {
|
||||
return cache.addAll(STATIC_ASSETS).catch(function () { /* ignore partial */ });
|
||||
}).then(function () { return self.skipWaiting(); })
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.filter(function (k) {
|
||||
return k.startsWith('qihuo-') && k !== STATIC_CACHE;
|
||||
}).map(function (k) { return caches.delete(k); }));
|
||||
}).then(function () { return self.clients.claim(); })
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
var req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
var url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname.indexOf('/static/') === 0) {
|
||||
event.respondWith(
|
||||
caches.match(req).then(function (cached) {
|
||||
return cached || fetch(req).then(function (res) {
|
||||
var copy = res.clone();
|
||||
caches.open(STATIC_CACHE).then(function (cache) { cache.put(req, copy); });
|
||||
return res;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.mode === 'navigate' || (req.headers.get('accept') || '').indexOf('text/html') !== -1) {
|
||||
event.respondWith(
|
||||
fetch(req).catch(function () {
|
||||
return caches.match('/login');
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+315
-310
@@ -1,310 +1,315 @@
|
||||
"""交易统计计算与缓存结构。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
STATS_VIEWS = [
|
||||
{"key": "by_time", "label": "按时间统计"},
|
||||
{"key": "by_week", "label": "周统计"},
|
||||
{"key": "by_month", "label": "月统计"},
|
||||
{"key": "by_symbol", "label": "按品种统计"},
|
||||
{"key": "by_fee", "label": "按手续费统计"},
|
||||
{"key": "by_direction", "label": "按方向统计"},
|
||||
{"key": "by_trade_type", "label": "按交易类型统计"},
|
||||
{"key": "by_emotion", "label": "情绪单统计"},
|
||||
]
|
||||
|
||||
BREAKDOWN_COLUMNS = [
|
||||
{"key": "label", "label": "维度"},
|
||||
{"key": "count", "label": "交易次数"},
|
||||
{"key": "wins", "label": "盈利笔数"},
|
||||
{"key": "losses", "label": "亏损笔数"},
|
||||
{"key": "win_rate", "label": "胜率(%)"},
|
||||
{"key": "avg_profit", "label": "平均盈利"},
|
||||
{"key": "avg_loss", "label": "平均亏损"},
|
||||
{"key": "profit_loss_ratio", "label": "盈亏比"},
|
||||
{"key": "total_fee", "label": "累计手续费"},
|
||||
{"key": "total_net", "label": "净盈亏合计"},
|
||||
{"key": "max_loss", "label": "最大亏损"},
|
||||
{"key": "max_profit", "label": "最大盈利"},
|
||||
]
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip().replace(" ", "T")
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _row_dict(row) -> dict:
|
||||
return dict(row) if row is not None else {}
|
||||
|
||||
|
||||
def _net_pnl(row: dict) -> float:
|
||||
if row.get("pnl_net") is not None:
|
||||
return float(row["pnl_net"])
|
||||
pnl = float(row.get("pnl") or 0)
|
||||
fee = float(row.get("fee") or 0)
|
||||
return round(pnl - fee, 2)
|
||||
|
||||
|
||||
def _fee(row: dict) -> float:
|
||||
return float(row.get("fee") or 0)
|
||||
|
||||
|
||||
def _margin_pct(pnl_net: float, margin: Optional[float]) -> Optional[float]:
|
||||
if margin and margin > 0:
|
||||
return round(pnl_net / margin * 100, 2)
|
||||
return None
|
||||
|
||||
|
||||
def _agg_group(rows: list[dict], key_fn) -> list[dict]:
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
key = key_fn(row) or "未知"
|
||||
groups.setdefault(key, []).append(row)
|
||||
result = []
|
||||
for label, items in sorted(groups.items(), key=lambda x: x[0]):
|
||||
result.append(_agg_metrics(label, items))
|
||||
return result
|
||||
|
||||
|
||||
def _agg_metrics(label: str, items: list[dict]) -> dict:
|
||||
nets = [_net_pnl(r) for r in items]
|
||||
wins = [n for n in nets if n > 0]
|
||||
losses = [n for n in nets if n < 0]
|
||||
count = len(items)
|
||||
win_cnt = len(wins)
|
||||
loss_cnt = len(losses)
|
||||
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
|
||||
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
|
||||
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
|
||||
total_fee = round(sum(_fee(r) for r in items), 2)
|
||||
total_net = round(sum(nets), 2)
|
||||
max_loss = round(min(nets), 2) if nets else 0.0
|
||||
max_profit = round(max(nets), 2) if nets else 0.0
|
||||
win_rate = round(win_cnt / count * 100, 2) if count else 0.0
|
||||
return {
|
||||
"label": label,
|
||||
"count": count,
|
||||
"wins": win_cnt,
|
||||
"losses": loss_cnt,
|
||||
"win_rate": win_rate,
|
||||
"avg_profit": avg_profit,
|
||||
"avg_loss": avg_loss,
|
||||
"profit_loss_ratio": pl_ratio,
|
||||
"total_fee": total_fee,
|
||||
"total_net": total_net,
|
||||
"max_loss": max_loss,
|
||||
"max_profit": max_profit,
|
||||
}
|
||||
|
||||
|
||||
def _max_consecutive_losses(nets: list[float]) -> int:
|
||||
streak = 0
|
||||
best = 0
|
||||
for n in nets:
|
||||
if n < 0:
|
||||
streak += 1
|
||||
best = max(best, streak)
|
||||
else:
|
||||
streak = 0
|
||||
return best
|
||||
|
||||
|
||||
def _max_drawdown(nets: list[float], initial_capital: float) -> tuple[float, float]:
|
||||
equity = initial_capital
|
||||
peak = initial_capital
|
||||
max_dd = 0.0
|
||||
max_dd_pct = 0.0
|
||||
for n in nets:
|
||||
equity += n
|
||||
if equity > peak:
|
||||
peak = equity
|
||||
dd = peak - equity
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
if peak > 0:
|
||||
pct = dd / peak * 100
|
||||
if pct > max_dd_pct:
|
||||
max_dd_pct = pct
|
||||
return round(max_dd, 2), round(max_dd_pct, 2)
|
||||
|
||||
|
||||
def fetch_trade_rows(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trade_logs ORDER BY close_time ASC, id ASC"
|
||||
).fetchall()
|
||||
return [_row_dict(r) for r in rows]
|
||||
|
||||
|
||||
def fetch_review_rows(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM review_records ORDER BY close_time ASC, id ASC"
|
||||
).fetchall()
|
||||
return [_row_dict(r) for r in rows]
|
||||
|
||||
|
||||
def compute_summary(trades: list[dict], reviews: list[dict], live_capital: float) -> dict:
|
||||
nets = [_net_pnl(t) for t in trades]
|
||||
count = len(trades)
|
||||
wins = [n for n in nets if n > 0]
|
||||
losses = [n for n in nets if n < 0]
|
||||
win_cnt = len(wins)
|
||||
loss_cnt = len(losses)
|
||||
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
|
||||
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
|
||||
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
|
||||
total_fee = round(sum(_fee(t) for t in trades) + sum(_fee(r) for r in reviews), 2)
|
||||
max_loss_amt = round(min(nets), 2) if nets else 0.0
|
||||
max_profit_amt = round(max(nets), 2) if nets else 0.0
|
||||
|
||||
margins_loss = [
|
||||
_margin_pct(_net_pnl(t), t.get("margin"))
|
||||
for t in trades
|
||||
if _net_pnl(t) < 0 and t.get("margin")
|
||||
]
|
||||
margins_profit = [
|
||||
_margin_pct(_net_pnl(t), t.get("margin"))
|
||||
for t in trades
|
||||
if _net_pnl(t) > 0 and t.get("margin")
|
||||
]
|
||||
max_loss_pct = round(min(margins_loss), 2) if margins_loss else 0.0
|
||||
max_profit_pct = round(max(margins_profit), 2) if margins_profit else 0.0
|
||||
|
||||
consec_loss = _max_consecutive_losses(nets)
|
||||
max_dd, max_dd_pct = _max_drawdown(nets, live_capital)
|
||||
|
||||
emotion_cnt = sum(1 for r in reviews if r.get("is_emotion"))
|
||||
review_cnt = len(reviews)
|
||||
denom = count if count else review_cnt
|
||||
emotion_ratio = round(emotion_cnt / denom * 100, 2) if denom else 0.0
|
||||
|
||||
return {
|
||||
"total_trades": count,
|
||||
"win_rate": round(win_cnt / count * 100, 2) if count else 0.0,
|
||||
"avg_profit": avg_profit,
|
||||
"avg_loss": avg_loss,
|
||||
"profit_loss_ratio": pl_ratio,
|
||||
"consecutive_losses": consec_loss,
|
||||
"max_drawdown": max_dd,
|
||||
"max_drawdown_pct": max_dd_pct,
|
||||
"max_loss_amount": max_loss_amt,
|
||||
"max_loss_pct": max_loss_pct,
|
||||
"max_profit_amount": max_profit_amt,
|
||||
"max_profit_pct": max_profit_pct,
|
||||
"total_fee": total_fee,
|
||||
"emotion_count": emotion_cnt,
|
||||
"emotion_ratio": emotion_ratio,
|
||||
"review_count": review_cnt,
|
||||
"win_count": win_cnt,
|
||||
"loss_count": loss_cnt,
|
||||
}
|
||||
|
||||
|
||||
def compute_breakdowns(trades: list[dict], reviews: list[dict]) -> dict[str, dict]:
|
||||
def day_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
return dt.date().isoformat() if dt else "未知"
|
||||
|
||||
def week_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
if not dt:
|
||||
return "未知"
|
||||
iso = dt.isocalendar()
|
||||
return f"{iso.year}-W{iso.week:02d}"
|
||||
|
||||
def month_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
return dt.strftime("%Y-%m") if dt else "未知"
|
||||
|
||||
def symbol_key(row: dict) -> str:
|
||||
return row.get("symbol_name") or row.get("symbol") or "未知"
|
||||
|
||||
def direction_key(row: dict) -> str:
|
||||
d = row.get("direction") or ""
|
||||
return "做多" if d == "long" else ("做空" if d == "short" else d or "未知")
|
||||
|
||||
def type_key(row: dict) -> str:
|
||||
return row.get("monitor_type") or "未知"
|
||||
|
||||
by_fee_rows = []
|
||||
fee_groups = {}
|
||||
for t in trades:
|
||||
key = symbol_key(t)
|
||||
fee_groups.setdefault(key, []).append(t)
|
||||
for label, items in sorted(fee_groups.items()):
|
||||
row = _agg_metrics(label, items)
|
||||
row["avg_fee"] = round(row["total_fee"] / row["count"], 2) if row["count"] else 0.0
|
||||
by_fee_rows.append(row)
|
||||
|
||||
emotion_trades = [r for r in reviews if r.get("is_emotion")]
|
||||
non_emotion = [r for r in reviews if not r.get("is_emotion")]
|
||||
emotion_rows = [
|
||||
_agg_metrics("情绪单", emotion_trades),
|
||||
_agg_metrics("非情绪单", non_emotion),
|
||||
]
|
||||
|
||||
fee_columns = BREAKDOWN_COLUMNS + [{"key": "avg_fee", "label": "平均手续费"}]
|
||||
|
||||
return {
|
||||
"by_time": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, day_key)},
|
||||
"by_week": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, week_key)},
|
||||
"by_month": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, month_key)},
|
||||
"by_symbol": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, symbol_key)},
|
||||
"by_fee": {"columns": fee_columns, "rows": by_fee_rows},
|
||||
"by_direction": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, direction_key)},
|
||||
"by_trade_type": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, type_key)},
|
||||
"by_emotion": {"columns": BREAKDOWN_COLUMNS, "rows": emotion_rows},
|
||||
}
|
||||
|
||||
|
||||
def build_all_stats(conn, live_capital: float = 0.0) -> dict:
|
||||
trades = fetch_trade_rows(conn)
|
||||
reviews = fetch_review_rows(conn)
|
||||
summary = compute_summary(trades, reviews, live_capital)
|
||||
breakdowns = compute_breakdowns(trades, reviews)
|
||||
return {
|
||||
"updated_at": datetime.now(TZ).isoformat(timespec="seconds"),
|
||||
"summary": summary,
|
||||
"views": STATS_VIEWS,
|
||||
"breakdowns": breakdowns,
|
||||
}
|
||||
|
||||
|
||||
def save_stats_cache(conn, data: dict) -> None:
|
||||
conn.execute(
|
||||
"""INSERT INTO stats_cache (key, data_json, updated_at)
|
||||
VALUES ('all', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET data_json=excluded.data_json, updated_at=excluded.updated_at""",
|
||||
(json.dumps(data, ensure_ascii=False), data["updated_at"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def load_stats_cache(conn) -> Optional[dict]:
|
||||
row = conn.execute(
|
||||
"SELECT data_json FROM stats_cache WHERE key='all'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row["data_json"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict:
|
||||
data = build_all_stats(conn, live_capital)
|
||||
save_stats_cache(conn, data)
|
||||
return data
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""交易统计计算与缓存结构。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
STATS_VIEWS = [
|
||||
{"key": "by_time", "label": "按时间统计"},
|
||||
{"key": "by_week", "label": "周统计"},
|
||||
{"key": "by_month", "label": "月统计"},
|
||||
{"key": "by_symbol", "label": "按品种统计"},
|
||||
{"key": "by_fee", "label": "按手续费统计"},
|
||||
{"key": "by_direction", "label": "按方向统计"},
|
||||
{"key": "by_trade_type", "label": "按交易类型统计"},
|
||||
{"key": "by_emotion", "label": "情绪单统计"},
|
||||
]
|
||||
|
||||
BREAKDOWN_COLUMNS = [
|
||||
{"key": "label", "label": "维度"},
|
||||
{"key": "count", "label": "交易次数"},
|
||||
{"key": "wins", "label": "盈利笔数"},
|
||||
{"key": "losses", "label": "亏损笔数"},
|
||||
{"key": "win_rate", "label": "胜率(%)"},
|
||||
{"key": "avg_profit", "label": "平均盈利"},
|
||||
{"key": "avg_loss", "label": "平均亏损"},
|
||||
{"key": "profit_loss_ratio", "label": "盈亏比"},
|
||||
{"key": "total_fee", "label": "累计手续费"},
|
||||
{"key": "total_net", "label": "净盈亏合计"},
|
||||
{"key": "max_loss", "label": "最大亏损"},
|
||||
{"key": "max_profit", "label": "最大盈利"},
|
||||
]
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip().replace(" ", "T")
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _row_dict(row) -> dict:
|
||||
return dict(row) if row is not None else {}
|
||||
|
||||
|
||||
def _net_pnl(row: dict) -> float:
|
||||
if row.get("pnl_net") is not None:
|
||||
return float(row["pnl_net"])
|
||||
pnl = float(row.get("pnl") or 0)
|
||||
fee = float(row.get("fee") or 0)
|
||||
return round(pnl - fee, 2)
|
||||
|
||||
|
||||
def _fee(row: dict) -> float:
|
||||
return float(row.get("fee") or 0)
|
||||
|
||||
|
||||
def _margin_pct(pnl_net: float, margin: Optional[float]) -> Optional[float]:
|
||||
if margin and margin > 0:
|
||||
return round(pnl_net / margin * 100, 2)
|
||||
return None
|
||||
|
||||
|
||||
def _agg_group(rows: list[dict], key_fn) -> list[dict]:
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
key = key_fn(row) or "未知"
|
||||
groups.setdefault(key, []).append(row)
|
||||
result = []
|
||||
for label, items in sorted(groups.items(), key=lambda x: x[0]):
|
||||
result.append(_agg_metrics(label, items))
|
||||
return result
|
||||
|
||||
|
||||
def _agg_metrics(label: str, items: list[dict]) -> dict:
|
||||
nets = [_net_pnl(r) for r in items]
|
||||
wins = [n for n in nets if n > 0]
|
||||
losses = [n for n in nets if n < 0]
|
||||
count = len(items)
|
||||
win_cnt = len(wins)
|
||||
loss_cnt = len(losses)
|
||||
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
|
||||
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
|
||||
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
|
||||
total_fee = round(sum(_fee(r) for r in items), 2)
|
||||
total_net = round(sum(nets), 2)
|
||||
max_loss = round(min(nets), 2) if nets else 0.0
|
||||
max_profit = round(max(nets), 2) if nets else 0.0
|
||||
win_rate = round(win_cnt / count * 100, 2) if count else 0.0
|
||||
return {
|
||||
"label": label,
|
||||
"count": count,
|
||||
"wins": win_cnt,
|
||||
"losses": loss_cnt,
|
||||
"win_rate": win_rate,
|
||||
"avg_profit": avg_profit,
|
||||
"avg_loss": avg_loss,
|
||||
"profit_loss_ratio": pl_ratio,
|
||||
"total_fee": total_fee,
|
||||
"total_net": total_net,
|
||||
"max_loss": max_loss,
|
||||
"max_profit": max_profit,
|
||||
}
|
||||
|
||||
|
||||
def _max_consecutive_losses(nets: list[float]) -> int:
|
||||
streak = 0
|
||||
best = 0
|
||||
for n in nets:
|
||||
if n < 0:
|
||||
streak += 1
|
||||
best = max(best, streak)
|
||||
else:
|
||||
streak = 0
|
||||
return best
|
||||
|
||||
|
||||
def _max_drawdown(nets: list[float], initial_capital: float) -> tuple[float, float]:
|
||||
equity = initial_capital
|
||||
peak = initial_capital
|
||||
max_dd = 0.0
|
||||
max_dd_pct = 0.0
|
||||
for n in nets:
|
||||
equity += n
|
||||
if equity > peak:
|
||||
peak = equity
|
||||
dd = peak - equity
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
if peak > 0:
|
||||
pct = dd / peak * 100
|
||||
if pct > max_dd_pct:
|
||||
max_dd_pct = pct
|
||||
return round(max_dd, 2), round(max_dd_pct, 2)
|
||||
|
||||
|
||||
def fetch_trade_rows(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trade_logs ORDER BY close_time ASC, id ASC"
|
||||
).fetchall()
|
||||
return [_row_dict(r) for r in rows]
|
||||
|
||||
|
||||
def fetch_review_rows(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM review_records ORDER BY close_time ASC, id ASC"
|
||||
).fetchall()
|
||||
return [_row_dict(r) for r in rows]
|
||||
|
||||
|
||||
def compute_summary(trades: list[dict], reviews: list[dict], live_capital: float) -> dict:
|
||||
nets = [_net_pnl(t) for t in trades]
|
||||
count = len(trades)
|
||||
wins = [n for n in nets if n > 0]
|
||||
losses = [n for n in nets if n < 0]
|
||||
win_cnt = len(wins)
|
||||
loss_cnt = len(losses)
|
||||
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
|
||||
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
|
||||
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
|
||||
total_fee = round(sum(_fee(t) for t in trades) + sum(_fee(r) for r in reviews), 2)
|
||||
max_loss_amt = round(min(nets), 2) if nets else 0.0
|
||||
max_profit_amt = round(max(nets), 2) if nets else 0.0
|
||||
|
||||
margins_loss = [
|
||||
_margin_pct(_net_pnl(t), t.get("margin"))
|
||||
for t in trades
|
||||
if _net_pnl(t) < 0 and t.get("margin")
|
||||
]
|
||||
margins_profit = [
|
||||
_margin_pct(_net_pnl(t), t.get("margin"))
|
||||
for t in trades
|
||||
if _net_pnl(t) > 0 and t.get("margin")
|
||||
]
|
||||
max_loss_pct = round(min(margins_loss), 2) if margins_loss else 0.0
|
||||
max_profit_pct = round(max(margins_profit), 2) if margins_profit else 0.0
|
||||
|
||||
consec_loss = _max_consecutive_losses(nets)
|
||||
max_dd, max_dd_pct = _max_drawdown(nets, live_capital)
|
||||
|
||||
emotion_cnt = sum(1 for r in reviews if r.get("is_emotion"))
|
||||
review_cnt = len(reviews)
|
||||
denom = count if count else review_cnt
|
||||
emotion_ratio = round(emotion_cnt / denom * 100, 2) if denom else 0.0
|
||||
|
||||
return {
|
||||
"total_trades": count,
|
||||
"win_rate": round(win_cnt / count * 100, 2) if count else 0.0,
|
||||
"avg_profit": avg_profit,
|
||||
"avg_loss": avg_loss,
|
||||
"profit_loss_ratio": pl_ratio,
|
||||
"consecutive_losses": consec_loss,
|
||||
"max_drawdown": max_dd,
|
||||
"max_drawdown_pct": max_dd_pct,
|
||||
"max_loss_amount": max_loss_amt,
|
||||
"max_loss_pct": max_loss_pct,
|
||||
"max_profit_amount": max_profit_amt,
|
||||
"max_profit_pct": max_profit_pct,
|
||||
"total_fee": total_fee,
|
||||
"emotion_count": emotion_cnt,
|
||||
"emotion_ratio": emotion_ratio,
|
||||
"review_count": review_cnt,
|
||||
"win_count": win_cnt,
|
||||
"loss_count": loss_cnt,
|
||||
}
|
||||
|
||||
|
||||
def compute_breakdowns(trades: list[dict], reviews: list[dict]) -> dict[str, dict]:
|
||||
def day_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
return dt.date().isoformat() if dt else "未知"
|
||||
|
||||
def week_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
if not dt:
|
||||
return "未知"
|
||||
iso = dt.isocalendar()
|
||||
return f"{iso.year}-W{iso.week:02d}"
|
||||
|
||||
def month_key(row: dict) -> str:
|
||||
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
|
||||
return dt.strftime("%Y-%m") if dt else "未知"
|
||||
|
||||
def symbol_key(row: dict) -> str:
|
||||
return row.get("symbol_name") or row.get("symbol") or "未知"
|
||||
|
||||
def direction_key(row: dict) -> str:
|
||||
d = row.get("direction") or ""
|
||||
return "做多" if d == "long" else ("做空" if d == "short" else d or "未知")
|
||||
|
||||
def type_key(row: dict) -> str:
|
||||
return row.get("monitor_type") or "未知"
|
||||
|
||||
by_fee_rows = []
|
||||
fee_groups = {}
|
||||
for t in trades:
|
||||
key = symbol_key(t)
|
||||
fee_groups.setdefault(key, []).append(t)
|
||||
for label, items in sorted(fee_groups.items()):
|
||||
row = _agg_metrics(label, items)
|
||||
row["avg_fee"] = round(row["total_fee"] / row["count"], 2) if row["count"] else 0.0
|
||||
by_fee_rows.append(row)
|
||||
|
||||
emotion_trades = [r for r in reviews if r.get("is_emotion")]
|
||||
non_emotion = [r for r in reviews if not r.get("is_emotion")]
|
||||
emotion_rows = [
|
||||
_agg_metrics("情绪单", emotion_trades),
|
||||
_agg_metrics("非情绪单", non_emotion),
|
||||
]
|
||||
|
||||
fee_columns = BREAKDOWN_COLUMNS + [{"key": "avg_fee", "label": "平均手续费"}]
|
||||
|
||||
return {
|
||||
"by_time": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, day_key)},
|
||||
"by_week": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, week_key)},
|
||||
"by_month": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, month_key)},
|
||||
"by_symbol": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, symbol_key)},
|
||||
"by_fee": {"columns": fee_columns, "rows": by_fee_rows},
|
||||
"by_direction": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, direction_key)},
|
||||
"by_trade_type": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, type_key)},
|
||||
"by_emotion": {"columns": BREAKDOWN_COLUMNS, "rows": emotion_rows},
|
||||
}
|
||||
|
||||
|
||||
def build_all_stats(conn, live_capital: float = 0.0) -> dict:
|
||||
trades = fetch_trade_rows(conn)
|
||||
reviews = fetch_review_rows(conn)
|
||||
summary = compute_summary(trades, reviews, live_capital)
|
||||
breakdowns = compute_breakdowns(trades, reviews)
|
||||
return {
|
||||
"updated_at": datetime.now(TZ).isoformat(timespec="seconds"),
|
||||
"summary": summary,
|
||||
"views": STATS_VIEWS,
|
||||
"breakdowns": breakdowns,
|
||||
}
|
||||
|
||||
|
||||
def save_stats_cache(conn, data: dict) -> None:
|
||||
conn.execute(
|
||||
"""INSERT INTO stats_cache (key, data_json, updated_at)
|
||||
VALUES ('all', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET data_json=excluded.data_json, updated_at=excluded.updated_at""",
|
||||
(json.dumps(data, ensure_ascii=False), data["updated_at"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def load_stats_cache(conn) -> Optional[dict]:
|
||||
row = conn.execute(
|
||||
"SELECT data_json FROM stats_cache WHERE key='all'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row["data_json"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict:
|
||||
data = build_all_stats(conn, live_capital)
|
||||
save_stats_cache(conn, data)
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
|
||||
+23
-18
@@ -1,18 +1,23 @@
|
||||
"""斐波计算(自 crypto_monitor 复制,期货共用)。"""
|
||||
|
||||
def calc_fib_plan(direction, upper, lower, ratio):
|
||||
try:
|
||||
h = float(upper)
|
||||
l = float(lower)
|
||||
r = float(ratio)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if h <= l or r <= 0 or r >= 1:
|
||||
return None
|
||||
span = h - l
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
entry = l + r * span
|
||||
return entry, h, l
|
||||
entry = h - r * span
|
||||
return entry, l, h
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""斐波计算(自 crypto_monitor 复制,期货共用)。"""
|
||||
|
||||
def calc_fib_plan(direction, upper, lower, ratio):
|
||||
try:
|
||||
h = float(upper)
|
||||
l = float(lower)
|
||||
r = float(ratio)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if h <= l or r <= 0 or r >= 1:
|
||||
return None
|
||||
span = h - l
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
entry = l + r * span
|
||||
return entry, h, l
|
||||
entry = h - r * span
|
||||
return entry, l, h
|
||||
|
||||
+144
-139
@@ -1,139 +1,144 @@
|
||||
"""策略相关表结构。"""
|
||||
from __future__ import annotations
|
||||
|
||||
ROLL_GROUPS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS roll_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_monitor_id INTEGER,
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
initial_take_profit REAL,
|
||||
initial_stop_loss REAL,
|
||||
current_stop_loss REAL,
|
||||
risk_percent REAL DEFAULT 2,
|
||||
leg_count INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
ROLL_LEGS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS roll_legs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
roll_group_id INTEGER NOT NULL,
|
||||
leg_index INTEGER NOT NULL,
|
||||
add_mode TEXT NOT NULL,
|
||||
fill_price REAL,
|
||||
lots INTEGER,
|
||||
new_stop_loss REAL,
|
||||
status TEXT DEFAULT 'filled',
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
TREND_PLANS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS trend_pullback_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
status TEXT DEFAULT 'active',
|
||||
symbol TEXT NOT NULL,
|
||||
symbol_name TEXT,
|
||||
direction TEXT NOT NULL DEFAULT 'long',
|
||||
stop_loss REAL NOT NULL,
|
||||
add_upper REAL NOT NULL,
|
||||
take_profit REAL NOT NULL,
|
||||
risk_percent REAL DEFAULT 5,
|
||||
capital_snapshot REAL,
|
||||
plan_margin REAL,
|
||||
target_lots INTEGER,
|
||||
first_lots INTEGER,
|
||||
remainder_lots INTEGER,
|
||||
dca_legs INTEGER DEFAULT 5,
|
||||
leg_amounts_json TEXT,
|
||||
grid_prices_json TEXT,
|
||||
legs_done INTEGER DEFAULT 0,
|
||||
first_order_done INTEGER DEFAULT 0,
|
||||
avg_entry_price REAL,
|
||||
lots_open INTEGER DEFAULT 0,
|
||||
opened_at TEXT,
|
||||
message TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
STRATEGY_SNAPSHOTS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS strategy_trade_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_type TEXT NOT NULL,
|
||||
source_id INTEGER,
|
||||
symbol TEXT,
|
||||
direction TEXT,
|
||||
result_label TEXT,
|
||||
opened_at TEXT,
|
||||
closed_at TEXT,
|
||||
pnl_amount REAL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
TRADE_ORDER_MONITORS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS trade_order_monitors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
symbol_name TEXT,
|
||||
market_code TEXT,
|
||||
direction TEXT NOT NULL,
|
||||
lots INTEGER NOT NULL,
|
||||
entry_price REAL,
|
||||
stop_loss REAL,
|
||||
take_profit REAL,
|
||||
open_time TEXT,
|
||||
monitor_type TEXT DEFAULT 'manual',
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
CTP_SIM_ACCOUNT_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS ctp_sim_account (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
balance REAL DEFAULT 100000,
|
||||
available REAL DEFAULT 100000,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
CTP_SIM_POSITIONS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS ctp_sim_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
lots INTEGER NOT NULL,
|
||||
avg_price REAL NOT NULL,
|
||||
updated_at TEXT,
|
||||
UNIQUE(symbol, direction)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
_TABLES_READY = False
|
||||
|
||||
|
||||
def init_strategy_tables(conn) -> None:
|
||||
global _TABLES_READY
|
||||
if _TABLES_READY:
|
||||
return
|
||||
for sql in (
|
||||
ROLL_GROUPS_SQL,
|
||||
ROLL_LEGS_SQL,
|
||||
TREND_PLANS_SQL,
|
||||
STRATEGY_SNAPSHOTS_SQL,
|
||||
TRADE_ORDER_MONITORS_SQL,
|
||||
CTP_SIM_ACCOUNT_SQL,
|
||||
CTP_SIM_POSITIONS_SQL,
|
||||
):
|
||||
conn.execute(sql)
|
||||
if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone():
|
||||
conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)")
|
||||
conn.commit()
|
||||
_TABLES_READY = True
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""策略相关表结构。"""
|
||||
from __future__ import annotations
|
||||
|
||||
ROLL_GROUPS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS roll_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_monitor_id INTEGER,
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
initial_take_profit REAL,
|
||||
initial_stop_loss REAL,
|
||||
current_stop_loss REAL,
|
||||
risk_percent REAL DEFAULT 2,
|
||||
leg_count INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
ROLL_LEGS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS roll_legs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
roll_group_id INTEGER NOT NULL,
|
||||
leg_index INTEGER NOT NULL,
|
||||
add_mode TEXT NOT NULL,
|
||||
fill_price REAL,
|
||||
lots INTEGER,
|
||||
new_stop_loss REAL,
|
||||
status TEXT DEFAULT 'filled',
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
TREND_PLANS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS trend_pullback_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
status TEXT DEFAULT 'active',
|
||||
symbol TEXT NOT NULL,
|
||||
symbol_name TEXT,
|
||||
direction TEXT NOT NULL DEFAULT 'long',
|
||||
stop_loss REAL NOT NULL,
|
||||
add_upper REAL NOT NULL,
|
||||
take_profit REAL NOT NULL,
|
||||
risk_percent REAL DEFAULT 5,
|
||||
capital_snapshot REAL,
|
||||
plan_margin REAL,
|
||||
target_lots INTEGER,
|
||||
first_lots INTEGER,
|
||||
remainder_lots INTEGER,
|
||||
dca_legs INTEGER DEFAULT 5,
|
||||
leg_amounts_json TEXT,
|
||||
grid_prices_json TEXT,
|
||||
legs_done INTEGER DEFAULT 0,
|
||||
first_order_done INTEGER DEFAULT 0,
|
||||
avg_entry_price REAL,
|
||||
lots_open INTEGER DEFAULT 0,
|
||||
opened_at TEXT,
|
||||
message TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
STRATEGY_SNAPSHOTS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS strategy_trade_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_type TEXT NOT NULL,
|
||||
source_id INTEGER,
|
||||
symbol TEXT,
|
||||
direction TEXT,
|
||||
result_label TEXT,
|
||||
opened_at TEXT,
|
||||
closed_at TEXT,
|
||||
pnl_amount REAL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
TRADE_ORDER_MONITORS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS trade_order_monitors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
symbol_name TEXT,
|
||||
market_code TEXT,
|
||||
direction TEXT NOT NULL,
|
||||
lots INTEGER NOT NULL,
|
||||
entry_price REAL,
|
||||
stop_loss REAL,
|
||||
take_profit REAL,
|
||||
open_time TEXT,
|
||||
monitor_type TEXT DEFAULT 'manual',
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
CTP_SIM_ACCOUNT_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS ctp_sim_account (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
balance REAL DEFAULT 100000,
|
||||
available REAL DEFAULT 100000,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
CTP_SIM_POSITIONS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS ctp_sim_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
lots INTEGER NOT NULL,
|
||||
avg_price REAL NOT NULL,
|
||||
updated_at TEXT,
|
||||
UNIQUE(symbol, direction)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
_TABLES_READY = False
|
||||
|
||||
|
||||
def init_strategy_tables(conn) -> None:
|
||||
global _TABLES_READY
|
||||
if _TABLES_READY:
|
||||
return
|
||||
for sql in (
|
||||
ROLL_GROUPS_SQL,
|
||||
ROLL_LEGS_SQL,
|
||||
TREND_PLANS_SQL,
|
||||
STRATEGY_SNAPSHOTS_SQL,
|
||||
TRADE_ORDER_MONITORS_SQL,
|
||||
CTP_SIM_ACCOUNT_SQL,
|
||||
CTP_SIM_POSITIONS_SQL,
|
||||
):
|
||||
conn.execute(sql)
|
||||
if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone():
|
||||
conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)")
|
||||
conn.commit()
|
||||
_TABLES_READY = True
|
||||
|
||||
+164
-159
@@ -1,159 +1,164 @@
|
||||
"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from strategy.fib_lib import calc_fib_plan
|
||||
|
||||
ROLL_MAX_LEGS_LONG = 3
|
||||
ROLL_MAX_LEGS_SHORT = 3
|
||||
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
|
||||
FIB_MODES = frozenset({"fib_618", "fib_786"})
|
||||
|
||||
|
||||
def fib_ratio_from_mode(mode: str) -> Optional[float]:
|
||||
m = (mode or "").strip().lower()
|
||||
if m in ("fib_618", "618", "0.618"):
|
||||
return 0.618
|
||||
if m in ("fib_786", "786", "0.786"):
|
||||
return 0.786
|
||||
return None
|
||||
|
||||
|
||||
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
|
||||
ratio = fib_ratio_from_mode(mode)
|
||||
if ratio is None:
|
||||
return None, "斐波档位无效"
|
||||
h, l = float(upper), float(lower)
|
||||
if h <= l:
|
||||
return None, "上沿须大于下沿"
|
||||
direction = (direction or "long").strip().lower()
|
||||
plan = calc_fib_plan(direction, h, l, ratio)
|
||||
if not plan:
|
||||
return None, "无法计算斐波限价"
|
||||
entry, _sl, _tp = plan
|
||||
return float(entry), None
|
||||
|
||||
|
||||
def max_roll_legs(direction: str) -> int:
|
||||
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
|
||||
|
||||
|
||||
def lots_precise(raw: float) -> int:
|
||||
if raw is None or raw < 1:
|
||||
return 0
|
||||
return max(1, int(math.floor(float(raw))))
|
||||
|
||||
|
||||
def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float:
|
||||
avg_f = float(avg)
|
||||
pct = float(offset_pct) / 100.0
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
return avg_f * (1.0 + pct)
|
||||
return avg_f * (1.0 - pct)
|
||||
|
||||
|
||||
def avg_entry_after_add(qty_existing: float, entry_existing: float, add_qty: float, add_price: float) -> float:
|
||||
q1, e1, q2, e2 = float(qty_existing), float(entry_existing), float(add_qty), float(add_price)
|
||||
total = q1 + q2
|
||||
return (q1 * e1 + q2 * e2) / total if total > 0 else 0.0
|
||||
|
||||
|
||||
def solve_add_lots_for_total_risk(
|
||||
direction: str,
|
||||
qty_existing: float,
|
||||
entry_existing: float,
|
||||
add_price: float,
|
||||
new_stop: float,
|
||||
risk_budget: float,
|
||||
mult: int,
|
||||
) -> Tuple[Optional[int], Optional[str]]:
|
||||
q1, e1, e2, sl, b = float(qty_existing), float(entry_existing), float(add_price), float(new_stop), float(risk_budget)
|
||||
m = float(mult)
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
denom = (sl - e2) * m
|
||||
numer = b - q1 * (sl - e1) * m
|
||||
else:
|
||||
denom = (e2 - sl) * m
|
||||
numer = b - q1 * (e1 - sl) * m
|
||||
if denom <= 0:
|
||||
return None, "止损与加仓价关系无效"
|
||||
q2 = numer / denom
|
||||
lots = lots_precise(q2)
|
||||
if lots < 1:
|
||||
return None, "按总风险%无需再加仓或无法再加"
|
||||
return lots, None
|
||||
|
||||
|
||||
def preview_roll(
|
||||
*,
|
||||
direction: str,
|
||||
symbol: str,
|
||||
qty_existing: float,
|
||||
entry_existing: float,
|
||||
initial_take_profit: float,
|
||||
add_mode: str,
|
||||
new_stop_loss: float,
|
||||
risk_percent: float,
|
||||
capital_base: float,
|
||||
mult: int,
|
||||
add_price: Optional[float] = None,
|
||||
fib_upper: Optional[float] = None,
|
||||
fib_lower: Optional[float] = None,
|
||||
legs_done: int = 0,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
direction = (direction or "long").strip().lower()
|
||||
if legs_done >= max_roll_legs(direction):
|
||||
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
|
||||
mode = (add_mode or "market").strip().lower()
|
||||
if mode == "market":
|
||||
if not add_price or add_price <= 0:
|
||||
return None, "需要有效参考价"
|
||||
entry_add = float(add_price)
|
||||
mode_label = "市价"
|
||||
elif mode in FIB_MODES:
|
||||
if fib_upper is None or fib_lower is None:
|
||||
return None, "斐波须填上沿/下沿"
|
||||
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
|
||||
if err:
|
||||
return None, err
|
||||
mode_label = "斐波0.618" if "618" in mode else "斐波0.786"
|
||||
else:
|
||||
return None, "加仓方式无效"
|
||||
sl = float(new_stop_loss)
|
||||
tp = float(initial_take_profit)
|
||||
if sl <= 0 or tp <= 0:
|
||||
return None, "止损/止盈无效"
|
||||
risk_budget = float(capital_base) * float(risk_percent) / 100.0
|
||||
q2, err = solve_add_lots_for_total_risk(
|
||||
direction, qty_existing, entry_existing, entry_add, sl, risk_budget, mult
|
||||
)
|
||||
if err:
|
||||
return None, err
|
||||
new_qty = qty_existing + q2
|
||||
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
|
||||
m = float(mult)
|
||||
if direction == "long":
|
||||
loss_at_sl = (new_avg - sl) * new_qty * m
|
||||
reward_at_tp = (tp - new_avg) * new_qty * m
|
||||
else:
|
||||
loss_at_sl = (sl - new_avg) * new_qty * m
|
||||
reward_at_tp = (new_avg - tp) * new_qty * m
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"add_mode_label": mode_label,
|
||||
"add_price": round(entry_add, 4),
|
||||
"new_stop_loss": round(sl, 4),
|
||||
"initial_take_profit": tp,
|
||||
"risk_percent": float(risk_percent),
|
||||
"add_lots": q2,
|
||||
"qty_after": int(new_qty),
|
||||
"avg_entry_after": round(new_avg, 4),
|
||||
"loss_at_sl": round(loss_at_sl, 2),
|
||||
"reward_at_tp": round(reward_at_tp, 2),
|
||||
"legs_done": legs_done,
|
||||
}, None
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from strategy.fib_lib import calc_fib_plan
|
||||
|
||||
ROLL_MAX_LEGS_LONG = 3
|
||||
ROLL_MAX_LEGS_SHORT = 3
|
||||
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
|
||||
FIB_MODES = frozenset({"fib_618", "fib_786"})
|
||||
|
||||
|
||||
def fib_ratio_from_mode(mode: str) -> Optional[float]:
|
||||
m = (mode or "").strip().lower()
|
||||
if m in ("fib_618", "618", "0.618"):
|
||||
return 0.618
|
||||
if m in ("fib_786", "786", "0.786"):
|
||||
return 0.786
|
||||
return None
|
||||
|
||||
|
||||
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
|
||||
ratio = fib_ratio_from_mode(mode)
|
||||
if ratio is None:
|
||||
return None, "斐波档位无效"
|
||||
h, l = float(upper), float(lower)
|
||||
if h <= l:
|
||||
return None, "上沿须大于下沿"
|
||||
direction = (direction or "long").strip().lower()
|
||||
plan = calc_fib_plan(direction, h, l, ratio)
|
||||
if not plan:
|
||||
return None, "无法计算斐波限价"
|
||||
entry, _sl, _tp = plan
|
||||
return float(entry), None
|
||||
|
||||
|
||||
def max_roll_legs(direction: str) -> int:
|
||||
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
|
||||
|
||||
|
||||
def lots_precise(raw: float) -> int:
|
||||
if raw is None or raw < 1:
|
||||
return 0
|
||||
return max(1, int(math.floor(float(raw))))
|
||||
|
||||
|
||||
def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float:
|
||||
avg_f = float(avg)
|
||||
pct = float(offset_pct) / 100.0
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
return avg_f * (1.0 + pct)
|
||||
return avg_f * (1.0 - pct)
|
||||
|
||||
|
||||
def avg_entry_after_add(qty_existing: float, entry_existing: float, add_qty: float, add_price: float) -> float:
|
||||
q1, e1, q2, e2 = float(qty_existing), float(entry_existing), float(add_qty), float(add_price)
|
||||
total = q1 + q2
|
||||
return (q1 * e1 + q2 * e2) / total if total > 0 else 0.0
|
||||
|
||||
|
||||
def solve_add_lots_for_total_risk(
|
||||
direction: str,
|
||||
qty_existing: float,
|
||||
entry_existing: float,
|
||||
add_price: float,
|
||||
new_stop: float,
|
||||
risk_budget: float,
|
||||
mult: int,
|
||||
) -> Tuple[Optional[int], Optional[str]]:
|
||||
q1, e1, e2, sl, b = float(qty_existing), float(entry_existing), float(add_price), float(new_stop), float(risk_budget)
|
||||
m = float(mult)
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
denom = (sl - e2) * m
|
||||
numer = b - q1 * (sl - e1) * m
|
||||
else:
|
||||
denom = (e2 - sl) * m
|
||||
numer = b - q1 * (e1 - sl) * m
|
||||
if denom <= 0:
|
||||
return None, "止损与加仓价关系无效"
|
||||
q2 = numer / denom
|
||||
lots = lots_precise(q2)
|
||||
if lots < 1:
|
||||
return None, "按总风险%无需再加仓或无法再加"
|
||||
return lots, None
|
||||
|
||||
|
||||
def preview_roll(
|
||||
*,
|
||||
direction: str,
|
||||
symbol: str,
|
||||
qty_existing: float,
|
||||
entry_existing: float,
|
||||
initial_take_profit: float,
|
||||
add_mode: str,
|
||||
new_stop_loss: float,
|
||||
risk_percent: float,
|
||||
capital_base: float,
|
||||
mult: int,
|
||||
add_price: Optional[float] = None,
|
||||
fib_upper: Optional[float] = None,
|
||||
fib_lower: Optional[float] = None,
|
||||
legs_done: int = 0,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
direction = (direction or "long").strip().lower()
|
||||
if legs_done >= max_roll_legs(direction):
|
||||
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
|
||||
mode = (add_mode or "market").strip().lower()
|
||||
if mode == "market":
|
||||
if not add_price or add_price <= 0:
|
||||
return None, "需要有效参考价"
|
||||
entry_add = float(add_price)
|
||||
mode_label = "市价"
|
||||
elif mode in FIB_MODES:
|
||||
if fib_upper is None or fib_lower is None:
|
||||
return None, "斐波须填上沿/下沿"
|
||||
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
|
||||
if err:
|
||||
return None, err
|
||||
mode_label = "斐波0.618" if "618" in mode else "斐波0.786"
|
||||
else:
|
||||
return None, "加仓方式无效"
|
||||
sl = float(new_stop_loss)
|
||||
tp = float(initial_take_profit)
|
||||
if sl <= 0 or tp <= 0:
|
||||
return None, "止损/止盈无效"
|
||||
risk_budget = float(capital_base) * float(risk_percent) / 100.0
|
||||
q2, err = solve_add_lots_for_total_risk(
|
||||
direction, qty_existing, entry_existing, entry_add, sl, risk_budget, mult
|
||||
)
|
||||
if err:
|
||||
return None, err
|
||||
new_qty = qty_existing + q2
|
||||
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
|
||||
m = float(mult)
|
||||
if direction == "long":
|
||||
loss_at_sl = (new_avg - sl) * new_qty * m
|
||||
reward_at_tp = (tp - new_avg) * new_qty * m
|
||||
else:
|
||||
loss_at_sl = (sl - new_avg) * new_qty * m
|
||||
reward_at_tp = (new_avg - tp) * new_qty * m
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"add_mode_label": mode_label,
|
||||
"add_price": round(entry_add, 4),
|
||||
"new_stop_loss": round(sl, 4),
|
||||
"initial_take_profit": tp,
|
||||
"risk_percent": float(risk_percent),
|
||||
"add_lots": q2,
|
||||
"qty_after": int(new_qty),
|
||||
"avg_entry_after": round(new_avg, 4),
|
||||
"loss_at_sl": round(loss_at_sl, 2),
|
||||
"reward_at_tp": round(reward_at_tp, 2),
|
||||
"legs_done": legs_done,
|
||||
}, None
|
||||
|
||||
@@ -1,70 +1,75 @@
|
||||
"""策略结束快照。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
STRATEGY_TREND = "trend_pullback"
|
||||
STRATEGY_ROLL = "roll"
|
||||
MAX_ROWS = 100
|
||||
|
||||
|
||||
def save_snapshot(
|
||||
conn,
|
||||
*,
|
||||
strategy_type: str,
|
||||
source_id: int,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
result_label: str,
|
||||
payload: dict,
|
||||
pnl: float | None = None,
|
||||
opened_at: str = "",
|
||||
) -> None:
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""INSERT INTO strategy_trade_snapshots (
|
||||
strategy_type, source_id, symbol, direction, result_label,
|
||||
opened_at, closed_at, pnl_amount, snapshot_json, created_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
strategy_type,
|
||||
source_id,
|
||||
symbol,
|
||||
direction,
|
||||
result_label,
|
||||
opened_at,
|
||||
now,
|
||||
pnl,
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""DELETE FROM strategy_trade_snapshots WHERE id NOT IN (
|
||||
SELECT id FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?
|
||||
)""",
|
||||
(MAX_ROWS,),
|
||||
)
|
||||
|
||||
|
||||
def list_snapshots(conn, limit: int = 100) -> tuple[list[dict], list[dict]]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?",
|
||||
(max(1, min(limit, 200)),),
|
||||
).fetchall()
|
||||
trend, roll = [], []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
|
||||
except Exception:
|
||||
d["snapshot"] = {}
|
||||
st = d.get("strategy_type")
|
||||
d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓"
|
||||
if st == STRATEGY_TREND:
|
||||
trend.append(d)
|
||||
else:
|
||||
roll.append(d)
|
||||
return trend, roll
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""策略结束快照。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
STRATEGY_TREND = "trend_pullback"
|
||||
STRATEGY_ROLL = "roll"
|
||||
MAX_ROWS = 100
|
||||
|
||||
|
||||
def save_snapshot(
|
||||
conn,
|
||||
*,
|
||||
strategy_type: str,
|
||||
source_id: int,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
result_label: str,
|
||||
payload: dict,
|
||||
pnl: float | None = None,
|
||||
opened_at: str = "",
|
||||
) -> None:
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""INSERT INTO strategy_trade_snapshots (
|
||||
strategy_type, source_id, symbol, direction, result_label,
|
||||
opened_at, closed_at, pnl_amount, snapshot_json, created_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
strategy_type,
|
||||
source_id,
|
||||
symbol,
|
||||
direction,
|
||||
result_label,
|
||||
opened_at,
|
||||
now,
|
||||
pnl,
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""DELETE FROM strategy_trade_snapshots WHERE id NOT IN (
|
||||
SELECT id FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?
|
||||
)""",
|
||||
(MAX_ROWS,),
|
||||
)
|
||||
|
||||
|
||||
def list_snapshots(conn, limit: int = 100) -> tuple[list[dict], list[dict]]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?",
|
||||
(max(1, min(limit, 200)),),
|
||||
).fetchall()
|
||||
trend, roll = [], []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
|
||||
except Exception:
|
||||
d["snapshot"] = {}
|
||||
st = d.get("strategy_type")
|
||||
d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓"
|
||||
if st == STRATEGY_TREND:
|
||||
trend.append(d)
|
||||
else:
|
||||
roll.append(d)
|
||||
return trend, roll
|
||||
|
||||
+113
-108
@@ -1,108 +1,113 @@
|
||||
"""趋势回调:纯计算(期货整数手)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
|
||||
|
||||
def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]:
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "long":
|
||||
if not (float(stop_loss) < float(add_upper)):
|
||||
return "做多:止损须低于补仓上沿"
|
||||
else:
|
||||
if not (float(stop_loss) > float(add_upper)):
|
||||
return "做空:止损须高于补仓下沿"
|
||||
return None
|
||||
|
||||
|
||||
def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]:
|
||||
sl, upper = float(sl), float(upper)
|
||||
out: list[float] = []
|
||||
if n_legs <= 0:
|
||||
return out
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "long":
|
||||
if upper <= sl:
|
||||
return out
|
||||
span = upper - sl
|
||||
for i in range(1, n_legs + 1):
|
||||
out.append(sl + (i / float(n_legs + 1)) * span)
|
||||
out.sort(reverse=True)
|
||||
else:
|
||||
if sl <= upper:
|
||||
return out
|
||||
span = sl - upper
|
||||
for i in range(1, n_legs + 1):
|
||||
out.append(upper + (i / float(n_legs + 1)) * span)
|
||||
out.sort()
|
||||
return [round(p, 4) for p in out]
|
||||
|
||||
|
||||
def compute_trend_plan_futures(
|
||||
*,
|
||||
direction: str,
|
||||
stop_loss: float,
|
||||
add_upper: float,
|
||||
take_profit: float,
|
||||
risk_percent: float,
|
||||
capital: float,
|
||||
live_price: float,
|
||||
ths_code: str,
|
||||
dca_legs: int = 5,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
err = validate_trend_bounds(direction, stop_loss, add_upper)
|
||||
if err:
|
||||
return None, err
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec["mult"]
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
worst_per_lot = (float(stop_loss) - float(add_upper)) * mult
|
||||
else:
|
||||
worst_per_lot = (float(add_upper) - float(stop_loss)) * mult
|
||||
if worst_per_lot <= 0:
|
||||
return None, "止损与补仓边界无法计算风险"
|
||||
budget = float(capital) * float(risk_percent) / 100.0
|
||||
total_lots = int(math.floor(budget / worst_per_lot))
|
||||
if total_lots < 3:
|
||||
return None, f"按 {risk_percent}% 风险,总手数至少需 3 手才能拆分首仓+补仓(当前 {total_lots} 手)"
|
||||
first_lots = total_lots // 2
|
||||
remainder = total_lots - first_lots
|
||||
legs = max(1, min(int(dca_legs), remainder))
|
||||
per_leg = remainder // legs
|
||||
leg_amounts = [per_leg] * (legs - 1) + [remainder - per_leg * (legs - 1)]
|
||||
if any(x < 1 for x in leg_amounts):
|
||||
legs = 1
|
||||
leg_amounts = [remainder]
|
||||
grid = build_grid_prices(d, stop_loss, add_upper, len(leg_amounts))
|
||||
margin_rate = spec["margin_rate"]
|
||||
plan_margin = float(live_price) * mult * total_lots * margin_rate
|
||||
return {
|
||||
"direction": d,
|
||||
"stop_loss": float(stop_loss),
|
||||
"add_upper": float(add_upper),
|
||||
"take_profit": float(take_profit),
|
||||
"risk_percent": float(risk_percent),
|
||||
"capital_snapshot": float(capital),
|
||||
"live_price_ref": float(live_price),
|
||||
"target_lots": total_lots,
|
||||
"first_lots": first_lots,
|
||||
"remainder_lots": remainder,
|
||||
"dca_legs": len(leg_amounts),
|
||||
"leg_amounts": leg_amounts,
|
||||
"leg_amounts_json": json.dumps(leg_amounts),
|
||||
"grid_prices_json": json.dumps(grid),
|
||||
"grid": grid,
|
||||
"plan_margin": round(plan_margin, 2),
|
||||
"mult": mult,
|
||||
}, None
|
||||
|
||||
|
||||
def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool:
|
||||
d = (direction or "long").strip().lower()
|
||||
pf, lv = float(mark_price), float(level)
|
||||
return pf <= lv if d == "long" else pf >= lv
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""趋势回调:纯计算(期货整数手)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
|
||||
|
||||
def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]:
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "long":
|
||||
if not (float(stop_loss) < float(add_upper)):
|
||||
return "做多:止损须低于补仓上沿"
|
||||
else:
|
||||
if not (float(stop_loss) > float(add_upper)):
|
||||
return "做空:止损须高于补仓下沿"
|
||||
return None
|
||||
|
||||
|
||||
def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]:
|
||||
sl, upper = float(sl), float(upper)
|
||||
out: list[float] = []
|
||||
if n_legs <= 0:
|
||||
return out
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "long":
|
||||
if upper <= sl:
|
||||
return out
|
||||
span = upper - sl
|
||||
for i in range(1, n_legs + 1):
|
||||
out.append(sl + (i / float(n_legs + 1)) * span)
|
||||
out.sort(reverse=True)
|
||||
else:
|
||||
if sl <= upper:
|
||||
return out
|
||||
span = sl - upper
|
||||
for i in range(1, n_legs + 1):
|
||||
out.append(upper + (i / float(n_legs + 1)) * span)
|
||||
out.sort()
|
||||
return [round(p, 4) for p in out]
|
||||
|
||||
|
||||
def compute_trend_plan_futures(
|
||||
*,
|
||||
direction: str,
|
||||
stop_loss: float,
|
||||
add_upper: float,
|
||||
take_profit: float,
|
||||
risk_percent: float,
|
||||
capital: float,
|
||||
live_price: float,
|
||||
ths_code: str,
|
||||
dca_legs: int = 5,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
err = validate_trend_bounds(direction, stop_loss, add_upper)
|
||||
if err:
|
||||
return None, err
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec["mult"]
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
worst_per_lot = (float(stop_loss) - float(add_upper)) * mult
|
||||
else:
|
||||
worst_per_lot = (float(add_upper) - float(stop_loss)) * mult
|
||||
if worst_per_lot <= 0:
|
||||
return None, "止损与补仓边界无法计算风险"
|
||||
budget = float(capital) * float(risk_percent) / 100.0
|
||||
total_lots = int(math.floor(budget / worst_per_lot))
|
||||
if total_lots < 3:
|
||||
return None, f"按 {risk_percent}% 风险,总手数至少需 3 手才能拆分首仓+补仓(当前 {total_lots} 手)"
|
||||
first_lots = total_lots // 2
|
||||
remainder = total_lots - first_lots
|
||||
legs = max(1, min(int(dca_legs), remainder))
|
||||
per_leg = remainder // legs
|
||||
leg_amounts = [per_leg] * (legs - 1) + [remainder - per_leg * (legs - 1)]
|
||||
if any(x < 1 for x in leg_amounts):
|
||||
legs = 1
|
||||
leg_amounts = [remainder]
|
||||
grid = build_grid_prices(d, stop_loss, add_upper, len(leg_amounts))
|
||||
margin_rate = spec["margin_rate"]
|
||||
plan_margin = float(live_price) * mult * total_lots * margin_rate
|
||||
return {
|
||||
"direction": d,
|
||||
"stop_loss": float(stop_loss),
|
||||
"add_upper": float(add_upper),
|
||||
"take_profit": float(take_profit),
|
||||
"risk_percent": float(risk_percent),
|
||||
"capital_snapshot": float(capital),
|
||||
"live_price_ref": float(live_price),
|
||||
"target_lots": total_lots,
|
||||
"first_lots": first_lots,
|
||||
"remainder_lots": remainder,
|
||||
"dca_legs": len(leg_amounts),
|
||||
"leg_amounts": leg_amounts,
|
||||
"leg_amounts_json": json.dumps(leg_amounts),
|
||||
"grid_prices_json": json.dumps(grid),
|
||||
"grid": grid,
|
||||
"plan_margin": round(plan_margin, 2),
|
||||
"mult": mult,
|
||||
}, None
|
||||
|
||||
|
||||
def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool:
|
||||
d = (direction or "long").strip().lower()
|
||||
pf, lv = float(mark_price), float(level)
|
||||
return pf <= lv if d == "long" else pf >= lv
|
||||
|
||||
+561
-556
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
|
||||
+48
-47
@@ -1,47 +1,48 @@
|
||||
{% 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 %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% 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 %}
|
||||
|
||||
+121
-120
@@ -1,120 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}手续费配置 - 国内期货监控系统{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.fees-status-card .card-body{display:flex;flex-wrap:wrap;gap:.75rem 1.25rem;align-items:center}
|
||||
.fees-status-card .fees-meta{font-size:.85rem;color:var(--text-muted)}
|
||||
.fees-table-card .card-body{padding:.75rem 1rem 1rem}
|
||||
.fees-table-card .trade-table-wrap{
|
||||
max-height:min(70vh,560px);
|
||||
width:100%;
|
||||
overflow:auto;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
border:1px solid var(--table-border);
|
||||
border-radius:10px;
|
||||
background:var(--card-inner);
|
||||
}
|
||||
.fees-table-card .trade-table{
|
||||
width:100%;
|
||||
min-width:0;
|
||||
table-layout:fixed;
|
||||
font-size:.8rem;
|
||||
}
|
||||
.fees-table-card .trade-table thead th{
|
||||
position:sticky;
|
||||
top:0;
|
||||
z-index:2;
|
||||
background:var(--card-inner);
|
||||
box-shadow:0 1px 0 var(--table-border);
|
||||
}
|
||||
.fees-table-card .trade-table th,
|
||||
.fees-table-card .trade-table td{
|
||||
padding:.5rem .4rem;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.fees-table-card .trade-table th:last-child,
|
||||
.fees-table-card .trade-table td:last-child{
|
||||
position:static;
|
||||
box-shadow:none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card fees-status-card">
|
||||
<h2>CTP 手续费</h2>
|
||||
<div class="card-body">
|
||||
<p class="fees-meta" style="margin:0;flex:1;min-width:220px">
|
||||
费率由后台从 <strong>CTP 柜台</strong> 同步写入数据库,<strong>每日自动更新一次</strong>,本页只读展示。
|
||||
</p>
|
||||
{% if ctp_connected %}
|
||||
<span class="badge profit">CTP 已连接</span>
|
||||
{% else %}
|
||||
<span class="badge planned">CTP 未连接</span>
|
||||
{% endif %}
|
||||
{% if fee_synced_today %}
|
||||
<span class="badge profit">今日已同步</span>
|
||||
{% else %}
|
||||
<span class="badge planned">今日未同步</span>
|
||||
{% endif %}
|
||||
{% if fee_last_sync %}
|
||||
<span class="text-muted" style="font-size:.8rem">上次:{{ fee_last_sync[:16] }}</span>
|
||||
{% endif %}
|
||||
{% if fee_sync_running %}
|
||||
<span class="badge planned">同步中…</span>
|
||||
{% endif %}
|
||||
{% if fee_counts.get('ctp') %}
|
||||
<span class="text-muted" style="font-size:.8rem">共 {{ fee_counts.ctp }} 个品种</span>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('fees') }}" method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="sync_ctp">
|
||||
<input type="hidden" name="force" value="1">
|
||||
<button type="submit" class="btn-primary" {% if not ctp_connected %}disabled title="请先连接 CTP"{% endif %}>立即同步</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card fees-table-card">
|
||||
<h2>品种费率表</h2>
|
||||
<div class="card-body">
|
||||
<div class="trade-table-wrap">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品种</th><th>交易所</th><th>乘数</th>
|
||||
<th>开仓(元/手)</th><th>开仓(比例)</th>
|
||||
<th>平昨(元/手)</th><th>平昨(比例)</th>
|
||||
<th>平今(元/手)</th><th>平今(比例)</th>
|
||||
<th>更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rates %}
|
||||
<tr>
|
||||
<td><strong>{{ r.product }}</strong></td>
|
||||
<td class="cell-readonly">{{ r.exchange or '—' }}</td>
|
||||
<td class="cell-readonly">{{ r.mult }}</td>
|
||||
<td class="cell-readonly">{{ r.open_fixed }}</td>
|
||||
<td class="cell-readonly">{{ r.open_ratio }}</td>
|
||||
<td class="cell-readonly">{{ r.close_yesterday_fixed }}</td>
|
||||
<td class="cell-readonly">{{ r.close_yesterday_ratio }}</td>
|
||||
<td class="cell-readonly">{{ r.close_today_fixed }}</td>
|
||||
<td class="cell-readonly">{{ r.close_today_ratio }}</td>
|
||||
<td class="text-muted" style="font-size:.72rem">{{ (r.updated_at or '')[:16] }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="10" class="text-muted">暂无 CTP 费率,请连接 CTP 后等待自动同步或点击「立即同步」</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:.75rem;padding:0 1rem 1rem">
|
||||
公式:单边 = 固定(元/手)×手数 + 比例×价格×乘数×手数;往返 = 开仓 + 平仓(平今/平昨自动判断)。
|
||||
{% if ctp_connected and not fee_counts.get('ctp') %}
|
||||
<br><strong class="text-loss">数据库尚无 CTP 费率,请点击「立即同步」或等待后台每日任务。</strong>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}手续费配置 - 国内期货监控系统{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.fees-status-card .card-body{display:flex;flex-wrap:wrap;gap:.75rem 1.25rem;align-items:center}
|
||||
.fees-status-card .fees-meta{font-size:.85rem;color:var(--text-muted)}
|
||||
.fees-table-card .card-body{padding:.75rem 1rem 1rem}
|
||||
.fees-table-card .trade-table-wrap{
|
||||
max-height:min(70vh,560px);
|
||||
width:100%;
|
||||
overflow:auto;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
border:1px solid var(--table-border);
|
||||
border-radius:10px;
|
||||
background:var(--card-inner);
|
||||
}
|
||||
.fees-table-card .trade-table{
|
||||
width:100%;
|
||||
min-width:0;
|
||||
table-layout:fixed;
|
||||
font-size:.8rem;
|
||||
}
|
||||
.fees-table-card .trade-table thead th{
|
||||
position:sticky;
|
||||
top:0;
|
||||
z-index:2;
|
||||
background:var(--card-inner);
|
||||
box-shadow:0 1px 0 var(--table-border);
|
||||
}
|
||||
.fees-table-card .trade-table th,
|
||||
.fees-table-card .trade-table td{
|
||||
padding:.5rem .4rem;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.fees-table-card .trade-table th:last-child,
|
||||
.fees-table-card .trade-table td:last-child{
|
||||
position:static;
|
||||
box-shadow:none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card fees-status-card">
|
||||
<h2>CTP 手续费</h2>
|
||||
<div class="card-body">
|
||||
<p class="fees-meta" style="margin:0;flex:1;min-width:220px">
|
||||
费率由后台从 <strong>CTP 柜台</strong> 同步写入数据库,<strong>每日自动更新一次</strong>,本页只读展示。
|
||||
</p>
|
||||
{% if ctp_connected %}
|
||||
<span class="badge profit">CTP 已连接</span>
|
||||
{% else %}
|
||||
<span class="badge planned">CTP 未连接</span>
|
||||
{% endif %}
|
||||
{% if fee_synced_today %}
|
||||
<span class="badge profit">今日已同步</span>
|
||||
{% else %}
|
||||
<span class="badge planned">今日未同步</span>
|
||||
{% endif %}
|
||||
{% if fee_last_sync %}
|
||||
<span class="text-muted" style="font-size:.8rem">上次:{{ fee_last_sync[:16] }}</span>
|
||||
{% endif %}
|
||||
{% if fee_sync_running %}
|
||||
<span class="badge planned">同步中…</span>
|
||||
{% endif %}
|
||||
{% if fee_counts.get('ctp') %}
|
||||
<span class="text-muted" style="font-size:.8rem">共 {{ fee_counts.ctp }} 个品种</span>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('fees') }}" method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="sync_ctp">
|
||||
<input type="hidden" name="force" value="1">
|
||||
<button type="submit" class="btn-primary" {% if not ctp_connected %}disabled title="请先连接 CTP"{% endif %}>立即同步</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card fees-table-card">
|
||||
<h2>品种费率表</h2>
|
||||
<div class="card-body">
|
||||
<div class="trade-table-wrap">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品种</th><th>交易所</th><th>乘数</th>
|
||||
<th>开仓(元/手)</th><th>开仓(比例)</th>
|
||||
<th>平昨(元/手)</th><th>平昨(比例)</th>
|
||||
<th>平今(元/手)</th><th>平今(比例)</th>
|
||||
<th>更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rates %}
|
||||
<tr>
|
||||
<td><strong>{{ r.product }}</strong></td>
|
||||
<td class="cell-readonly">{{ r.exchange or '—' }}</td>
|
||||
<td class="cell-readonly">{{ r.mult }}</td>
|
||||
<td class="cell-readonly">{{ r.open_fixed }}</td>
|
||||
<td class="cell-readonly">{{ r.open_ratio }}</td>
|
||||
<td class="cell-readonly">{{ r.close_yesterday_fixed }}</td>
|
||||
<td class="cell-readonly">{{ r.close_yesterday_ratio }}</td>
|
||||
<td class="cell-readonly">{{ r.close_today_fixed }}</td>
|
||||
<td class="cell-readonly">{{ r.close_today_ratio }}</td>
|
||||
<td class="text-muted" style="font-size:.72rem">{{ (r.updated_at or '')[:16] }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="10" class="text-muted">暂无 CTP 费率,请连接 CTP 后等待自动同步或点击「立即同步」</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:.75rem;padding:0 1rem 1rem">
|
||||
公式:单边 = 固定(元/手)×手数 + 比例×价格×乘数×手数;往返 = 开仓 + 平仓(平今/平昨自动判断)。
|
||||
{% if ctp_connected and not fee_counts.get('ctp') %}
|
||||
<br><strong class="text-loss">数据库尚无 CTP 费率,请点击「立即同步」或等待后台每日任务。</strong>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+86
-85
@@ -1,85 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}关键位监控 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>新增监控</h2>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('add_key') }}" method="post" class="form-compact">
|
||||
<div class="form-line line-3">
|
||||
<div class="symbol-wrap symbol-mains">
|
||||
<input type="text" class="symbol-input" placeholder="主力合约" autocomplete="off" required>
|
||||
<input type="hidden" name="symbol" required>
|
||||
<input type="hidden" name="symbol_name">
|
||||
<input type="hidden" name="market_code" required>
|
||||
<input type="hidden" name="sina_code">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected"></div>
|
||||
</div>
|
||||
<select name="type" required>
|
||||
<option value="箱体突破">箱体突破</option>
|
||||
<option value="收敛突破">收敛突破</option>
|
||||
<option value="关键阻力位">关键阻力位</option>
|
||||
<option value="关键支撑位">关键支撑位</option>
|
||||
</select>
|
||||
<select name="direction" required>
|
||||
<option value="">方向</option>
|
||||
<option value="long">做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-line line-3">
|
||||
<input name="upper" type="number" step="0.0001" placeholder="上沿/阻力" required>
|
||||
<input name="lower" type="number" step="0.0001" placeholder="下沿/支撑" required>
|
||||
<button type="submit" class="btn-primary">添加</button>
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="section-label">监控列表</h3>
|
||||
<div class="list card-scroll" id="key-monitor-list">
|
||||
{% for k in keys %}
|
||||
<div class="list-item key-item" data-key-id="{{ k.id }}" style="padding:.75rem;font-size:.85rem">
|
||||
<div>
|
||||
<strong>{{ k.symbol_name or k.symbol }}</strong> {{ k.monitor_type }}
|
||||
<span class="badge dir">{{ '多' if k.direction == 'long' else '空' }}</span>
|
||||
</div>
|
||||
<div class="key-live">
|
||||
<span class="live-price-line">现价:<span class="live-price">--</span></span>
|
||||
<span class="live-dist">距上<span class="dist-up">--</span> 距下<span class="dist-down">--</span></span>
|
||||
</div>
|
||||
<div>上{{ k.upper }} 下{{ k.lower }}</div>
|
||||
<a href="{{ url_for('del_key', pid=k.id) }}" class="btn-del" onclick="return confirm('移入历史?')">删</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-hint">暂无监控</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>监控历史</h2>
|
||||
<div class="card-body card-scroll">
|
||||
<table>
|
||||
<thead><tr><th>品种</th><th>类型</th><th>方向</th><th>上沿</th><th>下沿</th><th>归档</th></tr></thead>
|
||||
<tbody>
|
||||
{% for k in history %}
|
||||
<tr>
|
||||
<td>{{ k.symbol_name or k.symbol }}</td>
|
||||
<td>{{ k.monitor_type }}</td>
|
||||
<td><span class="badge dir">{{ '多' if k.direction == 'long' else '空' }}</span></td>
|
||||
<td>{{ k.upper }}</td>
|
||||
<td>{{ k.lower }}</td>
|
||||
<td>{{ k.archived_at[:16] if k.archived_at else '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="text-muted">暂无历史</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/keys.js') }}"></script>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}关键位监控 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>新增监控</h2>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('add_key') }}" method="post" class="form-compact">
|
||||
<div class="form-line line-3">
|
||||
<div class="symbol-wrap symbol-mains">
|
||||
<input type="text" class="symbol-input" placeholder="主力合约" autocomplete="off" required>
|
||||
<input type="hidden" name="symbol" required>
|
||||
<input type="hidden" name="symbol_name">
|
||||
<input type="hidden" name="market_code" required>
|
||||
<input type="hidden" name="sina_code">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected"></div>
|
||||
</div>
|
||||
<select name="type" required>
|
||||
<option value="箱体突破">箱体突破</option>
|
||||
<option value="收敛突破">收敛突破</option>
|
||||
<option value="关键阻力位">关键阻力位</option>
|
||||
<option value="关键支撑位">关键支撑位</option>
|
||||
</select>
|
||||
<select name="direction" required>
|
||||
<option value="">方向</option>
|
||||
<option value="long">做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-line line-3">
|
||||
<input name="upper" type="number" step="0.0001" placeholder="上沿/阻力" required>
|
||||
<input name="lower" type="number" step="0.0001" placeholder="下沿/支撑" required>
|
||||
<button type="submit" class="btn-primary">添加</button>
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="section-label">监控列表</h3>
|
||||
<div class="list card-scroll" id="key-monitor-list">
|
||||
{% for k in keys %}
|
||||
<div class="list-item key-item" data-key-id="{{ k.id }}" style="padding:.75rem;font-size:.85rem">
|
||||
<div>
|
||||
<strong>{{ k.symbol_name or k.symbol }}</strong> {{ k.monitor_type }}
|
||||
<span class="badge dir">{{ '多' if k.direction == 'long' else '空' }}</span>
|
||||
</div>
|
||||
<div class="key-live">
|
||||
<span class="live-price-line">现价:<span class="live-price">--</span></span>
|
||||
<span class="live-dist">距上<span class="dist-up">--</span> 距下<span class="dist-down">--</span></span>
|
||||
</div>
|
||||
<div>上{{ k.upper }} 下{{ k.lower }}</div>
|
||||
<a href="{{ url_for('del_key', pid=k.id) }}" class="btn-del" onclick="return confirm('移入历史?')">删</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-hint">暂无监控</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>监控历史</h2>
|
||||
<div class="card-body card-scroll">
|
||||
<table>
|
||||
<thead><tr><th>品种</th><th>类型</th><th>方向</th><th>上沿</th><th>下沿</th><th>归档</th></tr></thead>
|
||||
<tbody>
|
||||
{% for k in history %}
|
||||
<tr>
|
||||
<td>{{ k.symbol_name or k.symbol }}</td>
|
||||
<td>{{ k.monitor_type }}</td>
|
||||
<td><span class="badge dir">{{ '多' if k.direction == 'long' else '空' }}</span></td>
|
||||
<td>{{ k.upper }}</td>
|
||||
<td>{{ k.lower }}</td>
|
||||
<td>{{ k.archived_at[:16] if k.archived_at else '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="text-muted">暂无历史</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/keys.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
|
||||
+140
-139
@@ -1,139 +1,140 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}行情K线 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card market-card">
|
||||
<h2>行情 K 线</h2>
|
||||
<form class="market-toolbar" id="market-form" onsubmit="return false;">
|
||||
<div class="symbol-wrap market-symbol-wrap">
|
||||
<input type="text" class="symbol-input" id="market-symbol-input" placeholder="点击选择主力合约,或输入搜索" autocomplete="off" value="{{ symbol }}">
|
||||
<input type="hidden" name="symbol" id="market-symbol-hidden" value="{{ symbol }}">
|
||||
<input type="hidden" name="symbol_name" id="market-symbol-name">
|
||||
<input type="hidden" name="market_code" id="market-market-code">
|
||||
<input type="hidden" name="sina_code" id="market-sina-code">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected" id="market-symbol-selected"></div>
|
||||
</div>
|
||||
<div class="market-period-tabs" id="market-period-tabs">
|
||||
{% for p in market_periods %}
|
||||
<button type="button" class="period-tab{% if p.key == period %} active{% endif %}" data-period="{{ p.key }}">{{ p.label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="market-load-btn">查看</button>
|
||||
</form>
|
||||
<div class="market-quote" id="market-quote">
|
||||
<span class="market-quote-name" id="market-quote-name">—</span>
|
||||
<span class="market-quote-price" id="market-quote-price">—</span>
|
||||
<span class="market-quote-prev text-muted" id="market-quote-prev"></span>
|
||||
<span class="market-quote-meta text-muted" id="market-quote-meta"></span>
|
||||
</div>
|
||||
<div class="market-chart-toolbar">
|
||||
<div class="market-chart-options">
|
||||
<label class="chart-opt"><input type="checkbox" id="chart-opt-prev-close">昨收线</label>
|
||||
<label class="chart-opt"><input type="checkbox" id="chart-opt-ma">均线 21/55</label>
|
||||
<label class="chart-opt"><input type="checkbox" id="chart-opt-gap-day">间隔日</label>
|
||||
</div>
|
||||
<div class="market-chart-zoom">
|
||||
<button type="button" class="chart-zoom-btn" id="chart-zoom-in" title="放大">+</button>
|
||||
<button type="button" class="chart-zoom-btn" id="chart-zoom-out" title="缩小">-</button>
|
||||
<button type="button" class="chart-zoom-btn chart-zoom-reset" id="chart-zoom-reset">重置</button>
|
||||
</div>
|
||||
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
|
||||
</div>
|
||||
<div class="market-chart-wrap" id="market-chart-wrap">
|
||||
<div id="market-chart" class="market-chart" aria-label="K线图"></div>
|
||||
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
|
||||
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
|
||||
</div>
|
||||
<p class="hint">图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白。</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.market-card{overflow:visible}
|
||||
.market-card h2{margin-bottom:.75rem}
|
||||
.market-toolbar{
|
||||
display:flex;flex-wrap:wrap;gap:.65rem;align-items:flex-end;
|
||||
margin-bottom:.75rem;position:relative;z-index:2;
|
||||
min-height:2.5rem;
|
||||
}
|
||||
.market-symbol-wrap{flex:1;min-width:200px;max-width:360px;z-index:3}
|
||||
.market-symbol-wrap .symbol-dropdown{max-height:min(70vh,420px)}
|
||||
.market-symbol-wrap .symbol-selected{display:none}
|
||||
.market-period-tabs{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
|
||||
.period-tab{
|
||||
padding:.4rem .65rem;border-radius:999px;
|
||||
border:1px solid var(--input-border);
|
||||
background:var(--toggle-bg);color:var(--text-muted);
|
||||
font-size:.78rem;cursor:pointer;width:auto;white-space:nowrap;
|
||||
}
|
||||
.period-tab:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.period-tab.active{
|
||||
background:linear-gradient(135deg,var(--accent),var(--accent-2));
|
||||
border-color:transparent;color:#fff;
|
||||
}
|
||||
#market-load-btn{width:auto;padding:.55rem 1.25rem;font-size:.85rem}
|
||||
.market-quote{
|
||||
display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem 1rem;
|
||||
margin-bottom:.75rem;padding:.65rem .85rem;
|
||||
background:var(--card-inner);border-radius:10px;border:1px solid var(--card-border);
|
||||
min-height:2.75rem;
|
||||
}
|
||||
.market-quote-name{font-weight:600;color:var(--text-title)}
|
||||
.market-quote-price{font-size:1.35rem;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums}
|
||||
.market-quote-prev{font-size:.78rem}
|
||||
.market-chart-toolbar{
|
||||
display:flex;align-items:center;justify-content:space-between;gap:.75rem;
|
||||
margin-bottom:.5rem;flex-wrap:wrap;
|
||||
min-height:2rem;
|
||||
}
|
||||
.market-chart-options{display:flex;flex-wrap:wrap;gap:.5rem .85rem;align-items:center}
|
||||
.chart-opt{
|
||||
display:flex;align-items:center;gap:.35rem;font-size:.78rem;
|
||||
color:var(--text-muted);cursor:pointer;user-select:none;
|
||||
}
|
||||
.chart-opt input{width:auto;margin:0;cursor:pointer}
|
||||
.market-chart-zoom{display:flex;gap:.35rem;align-items:center}
|
||||
.chart-zoom-btn{
|
||||
width:32px;height:32px;padding:0;border-radius:8px;
|
||||
border:1px solid var(--input-border);background:var(--toggle-bg);
|
||||
color:var(--text-primary);font-size:1rem;line-height:1;cursor:pointer;
|
||||
}
|
||||
.chart-zoom-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.chart-zoom-reset{width:auto;padding:0 .65rem;font-size:.75rem}
|
||||
.market-refresh-hint{font-size:.72rem}
|
||||
.market-chart-wrap{
|
||||
position:relative;border-radius:12px;border:1px solid var(--card-border);
|
||||
background:var(--card-inner);
|
||||
height:min(68vh,560px);min-height:420px;
|
||||
}
|
||||
.market-chart{width:100%;height:100%}
|
||||
.market-chart-empty,
|
||||
.market-chart-loading{
|
||||
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||||
color:var(--text-muted);font-size:.9rem;pointer-events:none;
|
||||
}
|
||||
.market-chart-loading{display:none}
|
||||
html[data-theme="light"] .market-chart-loading{background:rgba(244,247,252,.75)}
|
||||
.market-chart-wrap.has-data .market-chart-empty{display:none}
|
||||
.market-chart-wrap.loading .market-chart-loading{
|
||||
display:flex;background:rgba(10,12,20,.35);
|
||||
}
|
||||
html[data-theme="light"] .market-chart-wrap.loading .market-chart-loading{background:rgba(244,247,252,.75)}
|
||||
.market-chart-wrap.loading .market-chart-empty{display:none}
|
||||
@media(max-width:767px){
|
||||
.market-toolbar{align-items:stretch}
|
||||
.market-symbol-wrap{max-width:none}
|
||||
.market-period-tabs{order:3;width:100%}
|
||||
#market-load-btn{order:4;width:100%}
|
||||
.market-chart-wrap{min-height:300px;height:50vh}
|
||||
.market-chart-toolbar{flex-direction:column;align-items:stretch}
|
||||
.market-chart-options{order:1}
|
||||
.market-chart-zoom{order:2}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/market.js') }}"></script>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}行情K线 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card market-card">
|
||||
<h2>行情 K 线</h2>
|
||||
<form class="market-toolbar" id="market-form" onsubmit="return false;">
|
||||
<div class="symbol-wrap market-symbol-wrap">
|
||||
<input type="text" class="symbol-input" id="market-symbol-input" placeholder="点击选择主力合约,或输入搜索" autocomplete="off" value="{{ symbol }}">
|
||||
<input type="hidden" name="symbol" id="market-symbol-hidden" value="{{ symbol }}">
|
||||
<input type="hidden" name="symbol_name" id="market-symbol-name">
|
||||
<input type="hidden" name="market_code" id="market-market-code">
|
||||
<input type="hidden" name="sina_code" id="market-sina-code">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected" id="market-symbol-selected"></div>
|
||||
</div>
|
||||
<div class="market-period-tabs" id="market-period-tabs">
|
||||
{% for p in market_periods %}
|
||||
<button type="button" class="period-tab{% if p.key == period %} active{% endif %}" data-period="{{ p.key }}">{{ p.label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="market-load-btn">查看</button>
|
||||
</form>
|
||||
<div class="market-quote" id="market-quote">
|
||||
<span class="market-quote-name" id="market-quote-name">—</span>
|
||||
<span class="market-quote-price" id="market-quote-price">—</span>
|
||||
<span class="market-quote-prev text-muted" id="market-quote-prev"></span>
|
||||
<span class="market-quote-meta text-muted" id="market-quote-meta"></span>
|
||||
</div>
|
||||
<div class="market-chart-toolbar">
|
||||
<div class="market-chart-options">
|
||||
<label class="chart-opt"><input type="checkbox" id="chart-opt-prev-close">昨收线</label>
|
||||
<label class="chart-opt"><input type="checkbox" id="chart-opt-ma">均线 21/55</label>
|
||||
<label class="chart-opt"><input type="checkbox" id="chart-opt-gap-day">间隔日</label>
|
||||
</div>
|
||||
<div class="market-chart-zoom">
|
||||
<button type="button" class="chart-zoom-btn" id="chart-zoom-in" title="放大">+</button>
|
||||
<button type="button" class="chart-zoom-btn" id="chart-zoom-out" title="缩小">-</button>
|
||||
<button type="button" class="chart-zoom-btn chart-zoom-reset" id="chart-zoom-reset">重置</button>
|
||||
</div>
|
||||
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
|
||||
</div>
|
||||
<div class="market-chart-wrap" id="market-chart-wrap">
|
||||
<div id="market-chart" class="market-chart" aria-label="K线图"></div>
|
||||
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
|
||||
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
|
||||
</div>
|
||||
<p class="hint">图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白。</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.market-card{overflow:visible}
|
||||
.market-card h2{margin-bottom:.75rem}
|
||||
.market-toolbar{
|
||||
display:flex;flex-wrap:wrap;gap:.65rem;align-items:flex-end;
|
||||
margin-bottom:.75rem;position:relative;z-index:2;
|
||||
min-height:2.5rem;
|
||||
}
|
||||
.market-symbol-wrap{flex:1;min-width:200px;max-width:360px;z-index:3}
|
||||
.market-symbol-wrap .symbol-dropdown{max-height:min(70vh,420px)}
|
||||
.market-symbol-wrap .symbol-selected{display:none}
|
||||
.market-period-tabs{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
|
||||
.period-tab{
|
||||
padding:.4rem .65rem;border-radius:999px;
|
||||
border:1px solid var(--input-border);
|
||||
background:var(--toggle-bg);color:var(--text-muted);
|
||||
font-size:.78rem;cursor:pointer;width:auto;white-space:nowrap;
|
||||
}
|
||||
.period-tab:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.period-tab.active{
|
||||
background:linear-gradient(135deg,var(--accent),var(--accent-2));
|
||||
border-color:transparent;color:#fff;
|
||||
}
|
||||
#market-load-btn{width:auto;padding:.55rem 1.25rem;font-size:.85rem}
|
||||
.market-quote{
|
||||
display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem 1rem;
|
||||
margin-bottom:.75rem;padding:.65rem .85rem;
|
||||
background:var(--card-inner);border-radius:10px;border:1px solid var(--card-border);
|
||||
min-height:2.75rem;
|
||||
}
|
||||
.market-quote-name{font-weight:600;color:var(--text-title)}
|
||||
.market-quote-price{font-size:1.35rem;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums}
|
||||
.market-quote-prev{font-size:.78rem}
|
||||
.market-chart-toolbar{
|
||||
display:flex;align-items:center;justify-content:space-between;gap:.75rem;
|
||||
margin-bottom:.5rem;flex-wrap:wrap;
|
||||
min-height:2rem;
|
||||
}
|
||||
.market-chart-options{display:flex;flex-wrap:wrap;gap:.5rem .85rem;align-items:center}
|
||||
.chart-opt{
|
||||
display:flex;align-items:center;gap:.35rem;font-size:.78rem;
|
||||
color:var(--text-muted);cursor:pointer;user-select:none;
|
||||
}
|
||||
.chart-opt input{width:auto;margin:0;cursor:pointer}
|
||||
.market-chart-zoom{display:flex;gap:.35rem;align-items:center}
|
||||
.chart-zoom-btn{
|
||||
width:32px;height:32px;padding:0;border-radius:8px;
|
||||
border:1px solid var(--input-border);background:var(--toggle-bg);
|
||||
color:var(--text-primary);font-size:1rem;line-height:1;cursor:pointer;
|
||||
}
|
||||
.chart-zoom-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.chart-zoom-reset{width:auto;padding:0 .65rem;font-size:.75rem}
|
||||
.market-refresh-hint{font-size:.72rem}
|
||||
.market-chart-wrap{
|
||||
position:relative;border-radius:12px;border:1px solid var(--card-border);
|
||||
background:var(--card-inner);
|
||||
height:min(68vh,560px);min-height:420px;
|
||||
}
|
||||
.market-chart{width:100%;height:100%}
|
||||
.market-chart-empty,
|
||||
.market-chart-loading{
|
||||
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||||
color:var(--text-muted);font-size:.9rem;pointer-events:none;
|
||||
}
|
||||
.market-chart-loading{display:none}
|
||||
html[data-theme="light"] .market-chart-loading{background:rgba(244,247,252,.75)}
|
||||
.market-chart-wrap.has-data .market-chart-empty{display:none}
|
||||
.market-chart-wrap.loading .market-chart-loading{
|
||||
display:flex;background:rgba(10,12,20,.35);
|
||||
}
|
||||
html[data-theme="light"] .market-chart-wrap.loading .market-chart-loading{background:rgba(244,247,252,.75)}
|
||||
.market-chart-wrap.loading .market-chart-empty{display:none}
|
||||
@media(max-width:767px){
|
||||
.market-toolbar{align-items:stretch}
|
||||
.market-symbol-wrap{max-width:none}
|
||||
.market-period-tabs{order:3;width:100%}
|
||||
#market-load-btn{order:4;width:100%}
|
||||
.market-chart-wrap{min-height:300px;height:50vh}
|
||||
.market-chart-toolbar{flex-direction:column;align-items:stretch}
|
||||
.market-chart-options{order:1}
|
||||
.market-chart-zoom{order:2}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/market.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}开单计划 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
+48
-47
@@ -1,47 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}持仓监控 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>持仓录入</h2>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('add_position') }}" method="post" class="form-compact">
|
||||
<div class="form-line line-3">
|
||||
<div class="symbol-wrap">
|
||||
<input type="text" class="symbol-input" placeholder="主力合约" autocomplete="off" required>
|
||||
<input type="hidden" name="symbol" required>
|
||||
<input type="hidden" name="symbol_name">
|
||||
<input type="hidden" name="market_code" required>
|
||||
<input type="hidden" name="sina_code">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected"></div>
|
||||
</div>
|
||||
<input type="datetime-local" name="open_time" required title="开仓时间">
|
||||
<input name="lots" type="number" step="1" min="1" value="1" placeholder="张数" required>
|
||||
</div>
|
||||
<div class="form-line line-3">
|
||||
<input name="entry_price" type="number" step="0.0001" placeholder="成交价格" required>
|
||||
<input name="stop_loss" type="number" step="0.0001" placeholder="止损" required>
|
||||
<input name="take_profit" type="number" step="0.0001" placeholder="止盈" required>
|
||||
</div>
|
||||
<div class="form-line line-btn">
|
||||
<button type="submit" class="btn-primary">添加持仓</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="hint" style="margin-top:.5rem">方向根据止损与成交价自动判断;风险比例依赖系统设置中的实盘资金。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>实时持仓</h2>
|
||||
<div class="card-body card-scroll" id="position-live-list">
|
||||
{% if not positions %}
|
||||
<div class="empty-hint">暂无持仓,左侧录入后显示</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/positions.js') }}"></script>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}持仓监控 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>持仓录入</h2>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('add_position') }}" method="post" class="form-compact">
|
||||
<div class="form-line line-3">
|
||||
<div class="symbol-wrap">
|
||||
<input type="text" class="symbol-input" placeholder="主力合约" autocomplete="off" required>
|
||||
<input type="hidden" name="symbol" required>
|
||||
<input type="hidden" name="symbol_name">
|
||||
<input type="hidden" name="market_code" required>
|
||||
<input type="hidden" name="sina_code">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected"></div>
|
||||
</div>
|
||||
<input type="datetime-local" name="open_time" required title="开仓时间">
|
||||
<input name="lots" type="number" step="1" min="1" value="1" placeholder="张数" required>
|
||||
</div>
|
||||
<div class="form-line line-3">
|
||||
<input name="entry_price" type="number" step="0.0001" placeholder="成交价格" required>
|
||||
<input name="stop_loss" type="number" step="0.0001" placeholder="止损" required>
|
||||
<input name="take_profit" type="number" step="0.0001" placeholder="止盈" required>
|
||||
</div>
|
||||
<div class="form-line line-btn">
|
||||
<button type="submit" class="btn-primary">添加持仓</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="hint" style="margin-top:.5rem">方向根据止损与成交价自动判断;风险比例依赖系统设置中的实盘资金。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>实时持仓</h2>
|
||||
<div class="card-body card-scroll" id="position-live-list">
|
||||
{% if not positions %}
|
||||
<div class="empty-hint">暂无持仓,左侧录入后显示</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/positions.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
+33
-32
@@ -1,32 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}品种推荐 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>品种推荐 · 按资金筛选</h2>
|
||||
<p class="hint">当前权益 <strong class="text-accent">{{ '%.2f'|format(capital) }}</strong> 元({{ trading_mode_label }})。
|
||||
优先展示可开 1 手且 1% 风险规则下较友好的品种;灰色为保证金不足。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="trade-table-wrap">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品种</th><th>交易所</th><th>参考价</th><th>1手保证金</th><th>建议最低资金</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="rec-{{ r.status }}">
|
||||
<td><strong>{{ r.name }}</strong> <span class="text-muted">{{ r.ths }}</span></td>
|
||||
<td>{{ r.exchange }}</td>
|
||||
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td><span class="badge {% if r.status=='ok' %}profit{% elif r.status=='blocked' %}loss{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}可开仓品种 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>可开仓品种 · 按资金筛选</h2>
|
||||
<p class="hint">当前权益 <strong class="text-accent">{{ '%.2f'|format(capital) }}</strong> 元({{ trading_mode_label }})。
|
||||
优先展示可开 1 手且 1% 风险规则下较友好的品种;灰色为保证金不足。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="trade-table-wrap">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品种</th><th>交易所</th><th>参考价</th><th>1手保证金</th><th>建议最低资金</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="rec-{{ r.status }}">
|
||||
<td><strong>{{ r.name }}</strong> <span class="text-muted">{{ r.ths }}</span></td>
|
||||
<td>{{ r.exchange }}</td>
|
||||
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td><span class="badge {% if r.status=='ok' %}profit{% elif r.status=='blocked' %}loss{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
+407
-406
@@ -1,406 +1,407 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}系统设置 - 国内期货监控系统{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.settings-page{display:flex;flex-direction:column;gap:1.25rem}
|
||||
.settings-page .split-grid{margin-bottom:0}
|
||||
.settings-page .split-grid .card{margin-bottom:0;min-height:100%;height:100%;display:flex;flex-direction:column}
|
||||
.settings-page .split-grid .card > form,
|
||||
.settings-page .split-grid .card > .card-inner{flex:1;display:flex;flex-direction:column}
|
||||
.settings-password-form{display:grid;grid-template-columns:1fr 1fr;gap:.65rem .75rem}
|
||||
.settings-password-form .field-full{grid-column:1/-1}
|
||||
.settings-password-form .field label{font-size:.78rem}
|
||||
.settings-password-form input{padding:.55rem .7rem;font-size:.85rem}
|
||||
.settings-tips{flex:1;display:flex;flex-direction:column;justify-content:center;gap:.5rem;margin:0;padding:0;list-style:none;font-size:.85rem;color:var(--text-muted);line-height:1.55}
|
||||
.settings-tips li{padding-left:1rem;position:relative}
|
||||
.settings-tips li::before{content:"";position:absolute;left:0;top:.55em;width:5px;height:5px;border-radius:50%;background:var(--accent)}
|
||||
.settings-ctp-grid{display:grid;grid-template-columns:1fr 1fr;gap:.65rem .75rem}
|
||||
.settings-ctp-grid .field-full{grid-column:1/-1}
|
||||
.settings-ctp-wrap .card-body{padding-top:0}
|
||||
.settings-ctp-cards-row{
|
||||
display:grid;grid-template-columns:1fr 1fr;gap:.75rem;
|
||||
align-items:start;margin-bottom:.75rem;
|
||||
}
|
||||
.settings-ctp-cards-row .settings-ctp-fold.card{margin-bottom:0;height:100%}
|
||||
.settings-ctp-cards-row .settings-ctp-grid{
|
||||
grid-template-columns:repeat(6,minmax(0,1fr));
|
||||
gap:.5rem .6rem;
|
||||
}
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field{grid-column:span 2}
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field-ctp-front-span{grid-column:span 3}
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field label{font-size:.75rem}
|
||||
.settings-ctp-cards-row .settings-ctp-grid input,
|
||||
.settings-ctp-cards-row .settings-ctp-grid select{
|
||||
padding:.45rem .55rem;font-size:.8rem;
|
||||
}
|
||||
.settings-ctp-fold.card{
|
||||
margin-bottom:.75rem;padding:0;overflow:hidden;
|
||||
border:1px solid var(--border);border-radius:8px;background:var(--card-inner);
|
||||
}
|
||||
.settings-ctp-fold.card:last-of-type{margin-bottom:0}
|
||||
.settings-ctp-fold-head{
|
||||
width:100%;display:flex;align-items:center;justify-content:space-between;gap:.75rem;
|
||||
padding:.7rem 1rem;margin:0;border:none;background:transparent;cursor:pointer;
|
||||
font-size:.92rem;font-weight:600;color:var(--text-title);text-align:left;
|
||||
}
|
||||
.settings-ctp-fold-head:hover{color:var(--accent)}
|
||||
.settings-ctp-fold-title{display:flex;align-items:center;gap:.5rem}
|
||||
.settings-ctp-fold-chevron{
|
||||
flex-shrink:0;font-size:.72rem;color:var(--text-muted);
|
||||
transition:transform .2s ease;
|
||||
}
|
||||
.settings-ctp-fold.is-collapsed .settings-ctp-fold-chevron{transform:rotate(-90deg)}
|
||||
.settings-ctp-fold-body{padding:0 1rem .85rem}
|
||||
.settings-ctp-fold.is-collapsed .settings-ctp-fold-body{display:none}
|
||||
.settings-ctp-status{font-size:.82rem;color:var(--text-muted);margin-top:.75rem;line-height:1.5}
|
||||
@media(max-width:900px){
|
||||
.settings-password-form{grid-template-columns:1fr}
|
||||
.settings-ctp-cards-row{grid-template-columns:1fr}
|
||||
.settings-ctp-grid{grid-template-columns:1fr}
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field,
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field-ctp-front-span{grid-column:span 1}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>导航显示</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="nav">
|
||||
<p class="hint" style="margin-bottom:.75rem">关闭后顶栏隐藏对应入口,直接访问 URL 也会跳转回下单监控。</p>
|
||||
<div class="check-row">
|
||||
{% for key, label in nav_toggles.items() %}
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;white-space:nowrap">
|
||||
<input type="checkbox" name="nav_{{ key }}" {% if nav_items[key] %}checked{% endif %}>
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存导航</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>交易模式</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="trading">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>交易通道</label>
|
||||
<select name="trading_mode">
|
||||
<option value="simulation" {% if trading_mode == 'simulation' %}selected{% endif %}>SimNow(vnpy CTP)</option>
|
||||
<option value="live" {% if trading_mode == 'live' %}selected{% endif %}>期货公司 CTP(后期接入)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>计仓模式</label>
|
||||
<select name="position_sizing_mode" id="position-sizing-mode">
|
||||
<option value="fixed" {% if position_sizing_mode == 'fixed' %}selected{% endif %}>固定手数</option>
|
||||
<option value="amount" {% if position_sizing_mode in ('amount', 'risk') %}selected{% endif %}>固定金额</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="field-fixed-lots" {% if position_sizing_mode in ('amount', 'risk') %}hidden{% endif %}>
|
||||
<label>固定手数(手)</label>
|
||||
<input name="fixed_lots" type="number" step="1" min="1" value="{{ fixed_lots }}">
|
||||
</div>
|
||||
<div class="field" id="field-fixed-amount" {% if position_sizing_mode not in ('amount', 'risk') %}hidden{% endif %}>
|
||||
<label>固定金额(元)</label>
|
||||
<input name="fixed_amount" type="number" step="1" min="1" value="{{ fixed_amount }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>保证金占用上限(%)</label>
|
||||
<input name="max_margin_pct" type="number" step="1" min="1" max="100" value="{{ max_margin_pct }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>移动保本缓冲(最小变动价位倍数)</label>
|
||||
<input name="trailing_be_tick_buffer" type="number" step="1" min="1" max="20" value="{{ trailing_be_tick_buffer }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>开仓挂单超时(分钟)</label>
|
||||
<input name="pending_order_timeout_min" type="number" step="1" min="1" max="60" value="{{ pending_order_timeout_min }}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
|
||||
<p class="hint" style="margin-top:.75rem;margin-bottom:0">
|
||||
保证金上限用于开仓校验与品种最大手数估算(默认 30%)。<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。
|
||||
<strong>挂单超时</strong>:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。CTP 账号与前置在下方「CTP 连接」中配置。
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-ctp-wrap">
|
||||
<h2>CTP 连接</h2>
|
||||
<div class="card-body">
|
||||
<p class="hint" style="margin-bottom:.85rem">
|
||||
投资者代码、密码、前置地址在此维护(优先于 <code>.env</code>)。保存后将自动断开并用新地址重连 CTP。
|
||||
{% if ctp_status.connected %}
|
||||
<span class="badge profit" style="margin-left:.35rem">已连接</span>
|
||||
{% elif ctp_status.connecting %}
|
||||
<span class="badge planned" style="margin-left:.35rem">连接中</span>
|
||||
{% elif ctp_status.last_error %}
|
||||
<span class="text-loss" style="display:block;margin-top:.35rem">{{ ctp_status.last_error }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<form action="{{ url_for('settings') }}" method="post" id="ctp-settings-form">
|
||||
<input type="hidden" name="action" value="ctp">
|
||||
|
||||
<div class="settings-ctp-cards-row">
|
||||
<div class="settings-ctp-fold card{% if trading_mode != 'simulation' %} is-collapsed{% endif %}" data-ctp-fold="simnow">
|
||||
<button type="button" class="settings-ctp-fold-head" aria-expanded="{{ 'true' if trading_mode == 'simulation' else 'false' }}">
|
||||
<span class="settings-ctp-fold-title">
|
||||
SimNow 模拟盘
|
||||
{% if trading_mode == 'simulation' %}<span class="badge planned" style="font-size:.7rem">当前通道</span>{% endif %}
|
||||
</span>
|
||||
<span class="settings-ctp-fold-chevron" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
<div class="settings-ctp-fold-body">
|
||||
<div class="settings-ctp-grid">
|
||||
<div class="field">
|
||||
<label>投资者代码</label>
|
||||
<input name="simnow_user" value="{{ ctp_cfg.simnow_user }}" placeholder="非手机号">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>交易密码</label>
|
||||
<input id="simnow_password" name="simnow_password" type="password"
|
||||
autocomplete="off" spellcheck="false"
|
||||
placeholder="{% if ctp_cfg.simnow_password_set %}已设置:须重新输入才会更新{% else %}SimNow 交易密码(必填){% endif %}">
|
||||
<p class="hint" style="margin:.25rem 0 0;font-size:.75rem">
|
||||
与快期相同密码,保存前须在此<strong>手打</strong>;留空则不改。下方「修改密码」是网页登录密码,不是 SimNow。
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>经纪商代码</label>
|
||||
<input name="simnow_broker_id" value="{{ ctp_cfg.simnow_broker_id }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>柜台环境</label>
|
||||
<select name="simnow_env">
|
||||
<option value="实盘" {% if ctp_cfg.simnow_env == '实盘' %}selected{% endif %}>实盘(看穿式,推荐)</option>
|
||||
<option value="测试" {% if ctp_cfg.simnow_env == '测试' %}selected{% endif %}>测试</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>AppID</label>
|
||||
<input name="simnow_app_id" value="{{ ctp_cfg.simnow_app_id }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>授权编码</label>
|
||||
<input name="simnow_auth_code" value="{{ ctp_cfg.simnow_auth_code }}">
|
||||
</div>
|
||||
<div class="field field-ctp-front-span">
|
||||
<label>行情前置</label>
|
||||
<input name="simnow_md_address" value="{{ ctp_cfg.simnow_md_address }}" placeholder="tcp://180.168.146.187:10211">
|
||||
</div>
|
||||
<div class="field field-ctp-front-span">
|
||||
<label>交易前置</label>
|
||||
<input name="simnow_td_address" value="{{ ctp_cfg.simnow_td_address }}" placeholder="tcp://180.168.146.187:10201">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-ctp-fold card{% if trading_mode != 'live' %} is-collapsed{% endif %}" data-ctp-fold="live">
|
||||
<button type="button" class="settings-ctp-fold-head" aria-expanded="{{ 'true' if trading_mode == 'live' else 'false' }}">
|
||||
<span class="settings-ctp-fold-title">
|
||||
期货公司实盘
|
||||
{% if trading_mode == 'live' %}<span class="badge planned" style="font-size:.7rem">当前通道</span>{% endif %}
|
||||
</span>
|
||||
<span class="settings-ctp-fold-chevron" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
<div class="settings-ctp-fold-body">
|
||||
<div class="settings-ctp-grid">
|
||||
<div class="field">
|
||||
<label>投资者代码</label>
|
||||
<input name="ctp_live_user" value="{{ ctp_cfg.ctp_live_user }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>交易密码</label>
|
||||
<input name="ctp_live_password" type="password" autocomplete="new-password"
|
||||
placeholder="{% if ctp_cfg.ctp_live_password_set %}已设置,留空不修改{% else %}实盘密码{% endif %}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>经纪商代码</label>
|
||||
<input name="ctp_live_broker_id" value="{{ ctp_cfg.ctp_live_broker_id }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>柜台环境</label>
|
||||
<select name="ctp_live_env">
|
||||
<option value="实盘" {% if ctp_cfg.ctp_live_env == '实盘' %}selected{% endif %}>实盘</option>
|
||||
<option value="测试" {% if ctp_cfg.ctp_live_env == '测试' %}selected{% endif %}>测试</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>AppID</label>
|
||||
<input name="ctp_live_app_id" value="{{ ctp_cfg.ctp_live_app_id }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>授权编码</label>
|
||||
<input name="ctp_live_auth_code" value="{{ ctp_cfg.ctp_live_auth_code }}">
|
||||
</div>
|
||||
<div class="field field-ctp-front-span">
|
||||
<label>行情前置</label>
|
||||
<input name="ctp_live_md_address" value="{{ ctp_cfg.ctp_live_md_address }}" placeholder="tcp://...">
|
||||
</div>
|
||||
<div class="field field-ctp-front-span">
|
||||
<label>交易前置</label>
|
||||
<input name="ctp_live_td_address" value="{{ ctp_cfg.ctp_live_td_address }}" placeholder="tcp://...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">保存 CTP 配置</button>
|
||||
<p class="settings-ctp-status">
|
||||
官方第一套:<code>180.168.146.187:10201/10211</code>;
|
||||
第二套(云服务器常用):<code>182.254.243.31:30001/30011</code>;
|
||||
7×24:<code>182.254.243.31:40001/40011</code>(部分账号在 40001 会报「不合法登录」,与快期前置保持一致)。
|
||||
详见 <code>docs/SIMNOW.md</code>。
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>行情说明</h2>
|
||||
<div class="card-inner">
|
||||
<p class="hint" style="font-size:.88rem;line-height:1.6;margin:0">
|
||||
当前行情源:<strong class="text-accent">{{ quote_label }}</strong><br>
|
||||
CTP 已连接时使用<strong>柜台行情</strong>;未连接时回退新浪接口。<br>
|
||||
合约代码按同花顺格式(如 ag2608、IF2606)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>企业微信推送</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="wechat">
|
||||
<div class="field" style="margin-bottom:.75rem">
|
||||
<label>Webhook 地址</label>
|
||||
<input name="wechat_webhook" type="url" placeholder="https://qyapi.weixin.qq.com/..." value="{{ webhook }}">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
<p class="hint" style="margin-top:.75rem;margin-bottom:0">在企业微信群添加机器人后,粘贴 Webhook 地址保存。</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>修改密码</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post" class="settings-password-form">
|
||||
<input type="hidden" name="action" value="password">
|
||||
<div class="field field-full">
|
||||
<label>当前账号</label>
|
||||
<input type="text" value="{{ username }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>原密码</label>
|
||||
<input name="old_password" type="password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>新密码</label>
|
||||
<input name="new_password" type="password" required minlength="6" placeholder="至少 6 位">
|
||||
</div>
|
||||
<div class="field field-full">
|
||||
<label>确认新密码</label>
|
||||
<input name="new_password2" type="password" required minlength="6">
|
||||
</div>
|
||||
<div class="field-full">
|
||||
<button type="submit" class="btn-primary">修改密码</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>使用提示</h2>
|
||||
<ul class="settings-tips">
|
||||
<li>下单监控:连接 CTP 后下单、看持仓与品种推荐</li>
|
||||
<li>策略交易:趋势回调自动补仓;顺势加仓需先开仓</li>
|
||||
<li>手续费:默认 CTP 柜台费率,连接后点同步</li>
|
||||
<li>手机端:浏览器菜单可「添加到主屏幕」安装 App</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var sel = document.getElementById('position-sizing-mode');
|
||||
var lotsField = document.getElementById('field-fixed-lots');
|
||||
var amountField = document.getElementById('field-fixed-amount');
|
||||
function syncSizingFields() {
|
||||
if (!sel) return;
|
||||
var isAmount = sel.value === 'amount';
|
||||
if (lotsField) lotsField.hidden = isAmount;
|
||||
if (amountField) amountField.hidden = !isAmount;
|
||||
}
|
||||
if (sel) sel.addEventListener('change', syncSizingFields);
|
||||
syncSizingFields();
|
||||
|
||||
var CTP_FOLD_KEY = 'qihuo_ctp_fold';
|
||||
function setCtpFold(el, collapsed) {
|
||||
if (!el) return;
|
||||
el.classList.toggle('is-collapsed', collapsed);
|
||||
var head = el.querySelector('.settings-ctp-fold-head');
|
||||
if (head) head.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
}
|
||||
function saveCtpFoldState() {
|
||||
var state = {};
|
||||
document.querySelectorAll('[data-ctp-fold]').forEach(function (el) {
|
||||
state[el.getAttribute('data-ctp-fold')] = el.classList.contains('is-collapsed');
|
||||
});
|
||||
try { localStorage.setItem(CTP_FOLD_KEY, JSON.stringify(state)); } catch (e) { /* ignore */ }
|
||||
}
|
||||
function loadCtpFoldState() {
|
||||
try {
|
||||
var raw = localStorage.getItem(CTP_FOLD_KEY);
|
||||
if (!raw) return;
|
||||
var state = JSON.parse(raw);
|
||||
document.querySelectorAll('[data-ctp-fold]').forEach(function (el) {
|
||||
var key = el.getAttribute('data-ctp-fold');
|
||||
if (Object.prototype.hasOwnProperty.call(state, key)) {
|
||||
setCtpFold(el, !!state[key]);
|
||||
}
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
document.querySelectorAll('.settings-ctp-fold-head').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var panel = btn.closest('[data-ctp-fold]');
|
||||
if (!panel) return;
|
||||
setCtpFold(panel, !panel.classList.contains('is-collapsed'));
|
||||
saveCtpFoldState();
|
||||
});
|
||||
});
|
||||
loadCtpFoldState();
|
||||
|
||||
var ctpForm = document.getElementById('ctp-settings-form');
|
||||
if (ctpForm) {
|
||||
ctpForm.addEventListener('submit', function (ev) {
|
||||
var simnowFold = document.querySelector('[data-ctp-fold="simnow"]');
|
||||
if (simnowFold) setCtpFold(simnowFold, false);
|
||||
var pwd = document.getElementById('simnow_password');
|
||||
var pwdVal = pwd && pwd.value ? pwd.value.trim() : '';
|
||||
var pwdWasSet = {{ 'true' if ctp_cfg.simnow_password_set else 'false' }};
|
||||
if (pwdWasSet && !pwdVal) {
|
||||
var ok = window.confirm(
|
||||
'SimNow 交易密码为空,保存后不会更新密码(仍用旧密码)。\n\n'
|
||||
+ '若快期已改密,请取消后在「交易密码」框手打新密码再保存。\n\n仍要保存其他项?'
|
||||
);
|
||||
if (!ok) ev.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}系统设置 - 国内期货监控系统{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.settings-page{display:flex;flex-direction:column;gap:1.25rem}
|
||||
.settings-page .split-grid{margin-bottom:0}
|
||||
.settings-page .split-grid .card{margin-bottom:0;min-height:100%;height:100%;display:flex;flex-direction:column}
|
||||
.settings-page .split-grid .card > form,
|
||||
.settings-page .split-grid .card > .card-inner{flex:1;display:flex;flex-direction:column}
|
||||
.settings-password-form{display:grid;grid-template-columns:1fr 1fr;gap:.65rem .75rem}
|
||||
.settings-password-form .field-full{grid-column:1/-1}
|
||||
.settings-password-form .field label{font-size:.78rem}
|
||||
.settings-password-form input{padding:.55rem .7rem;font-size:.85rem}
|
||||
.settings-tips{flex:1;display:flex;flex-direction:column;justify-content:center;gap:.5rem;margin:0;padding:0;list-style:none;font-size:.85rem;color:var(--text-muted);line-height:1.55}
|
||||
.settings-tips li{padding-left:1rem;position:relative}
|
||||
.settings-tips li::before{content:"";position:absolute;left:0;top:.55em;width:5px;height:5px;border-radius:50%;background:var(--accent)}
|
||||
.settings-ctp-grid{display:grid;grid-template-columns:1fr 1fr;gap:.65rem .75rem}
|
||||
.settings-ctp-grid .field-full{grid-column:1/-1}
|
||||
.settings-ctp-wrap .card-body{padding-top:0}
|
||||
.settings-ctp-cards-row{
|
||||
display:grid;grid-template-columns:1fr 1fr;gap:.75rem;
|
||||
align-items:start;margin-bottom:.75rem;
|
||||
}
|
||||
.settings-ctp-cards-row .settings-ctp-fold.card{margin-bottom:0;height:100%}
|
||||
.settings-ctp-cards-row .settings-ctp-grid{
|
||||
grid-template-columns:repeat(6,minmax(0,1fr));
|
||||
gap:.5rem .6rem;
|
||||
}
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field{grid-column:span 2}
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field-ctp-front-span{grid-column:span 3}
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field label{font-size:.75rem}
|
||||
.settings-ctp-cards-row .settings-ctp-grid input,
|
||||
.settings-ctp-cards-row .settings-ctp-grid select{
|
||||
padding:.45rem .55rem;font-size:.8rem;
|
||||
}
|
||||
.settings-ctp-fold.card{
|
||||
margin-bottom:.75rem;padding:0;overflow:hidden;
|
||||
border:1px solid var(--border);border-radius:8px;background:var(--card-inner);
|
||||
}
|
||||
.settings-ctp-fold.card:last-of-type{margin-bottom:0}
|
||||
.settings-ctp-fold-head{
|
||||
width:100%;display:flex;align-items:center;justify-content:space-between;gap:.75rem;
|
||||
padding:.7rem 1rem;margin:0;border:none;background:transparent;cursor:pointer;
|
||||
font-size:.92rem;font-weight:600;color:var(--text-title);text-align:left;
|
||||
}
|
||||
.settings-ctp-fold-head:hover{color:var(--accent)}
|
||||
.settings-ctp-fold-title{display:flex;align-items:center;gap:.5rem}
|
||||
.settings-ctp-fold-chevron{
|
||||
flex-shrink:0;font-size:.72rem;color:var(--text-muted);
|
||||
transition:transform .2s ease;
|
||||
}
|
||||
.settings-ctp-fold.is-collapsed .settings-ctp-fold-chevron{transform:rotate(-90deg)}
|
||||
.settings-ctp-fold-body{padding:0 1rem .85rem}
|
||||
.settings-ctp-fold.is-collapsed .settings-ctp-fold-body{display:none}
|
||||
.settings-ctp-status{font-size:.82rem;color:var(--text-muted);margin-top:.75rem;line-height:1.5}
|
||||
@media(max-width:900px){
|
||||
.settings-password-form{grid-template-columns:1fr}
|
||||
.settings-ctp-cards-row{grid-template-columns:1fr}
|
||||
.settings-ctp-grid{grid-template-columns:1fr}
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field,
|
||||
.settings-ctp-cards-row .settings-ctp-grid .field-ctp-front-span{grid-column:span 1}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>导航显示</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="nav">
|
||||
<p class="hint" style="margin-bottom:.75rem">关闭后顶栏隐藏对应入口,直接访问 URL 也会跳转回下单监控。</p>
|
||||
<div class="check-row">
|
||||
{% for key, label in nav_toggles.items() %}
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;white-space:nowrap">
|
||||
<input type="checkbox" name="nav_{{ key }}" {% if nav_items[key] %}checked{% endif %}>
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存导航</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>交易模式</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="trading">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>交易通道</label>
|
||||
<select name="trading_mode">
|
||||
<option value="simulation" {% if trading_mode == 'simulation' %}selected{% endif %}>SimNow(vnpy CTP)</option>
|
||||
<option value="live" {% if trading_mode == 'live' %}selected{% endif %}>期货公司 CTP(后期接入)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>计仓模式</label>
|
||||
<select name="position_sizing_mode" id="position-sizing-mode">
|
||||
<option value="fixed" {% if position_sizing_mode == 'fixed' %}selected{% endif %}>固定手数</option>
|
||||
<option value="amount" {% if position_sizing_mode in ('amount', 'risk') %}selected{% endif %}>固定金额</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="field-fixed-lots" {% if position_sizing_mode in ('amount', 'risk') %}hidden{% endif %}>
|
||||
<label>固定手数(手)</label>
|
||||
<input name="fixed_lots" type="number" step="1" min="1" value="{{ fixed_lots }}">
|
||||
</div>
|
||||
<div class="field" id="field-fixed-amount" {% if position_sizing_mode not in ('amount', 'risk') %}hidden{% endif %}>
|
||||
<label>固定金额(元)</label>
|
||||
<input name="fixed_amount" type="number" step="1" min="1" value="{{ fixed_amount }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>保证金占用上限(%)</label>
|
||||
<input name="max_margin_pct" type="number" step="1" min="1" max="100" value="{{ max_margin_pct }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>移动保本缓冲(最小变动价位倍数)</label>
|
||||
<input name="trailing_be_tick_buffer" type="number" step="1" min="1" max="20" value="{{ trailing_be_tick_buffer }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>开仓挂单超时(分钟)</label>
|
||||
<input name="pending_order_timeout_min" type="number" step="1" min="1" max="60" value="{{ pending_order_timeout_min }}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
|
||||
<p class="hint" style="margin-top:.75rem;margin-bottom:0">
|
||||
保证金上限用于开仓校验与品种最大手数估算(默认 30%)。<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。
|
||||
<strong>挂单超时</strong>:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。CTP 账号与前置在下方「CTP 连接」中配置。
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-ctp-wrap">
|
||||
<h2>CTP 连接</h2>
|
||||
<div class="card-body">
|
||||
<p class="hint" style="margin-bottom:.85rem">
|
||||
投资者代码、密码、前置地址在此维护(优先于 <code>.env</code>)。保存后将自动断开并用新地址重连 CTP。
|
||||
{% if ctp_status.connected %}
|
||||
<span class="badge profit" style="margin-left:.35rem">已连接</span>
|
||||
{% elif ctp_status.connecting %}
|
||||
<span class="badge planned" style="margin-left:.35rem">连接中</span>
|
||||
{% elif ctp_status.last_error %}
|
||||
<span class="text-loss" style="display:block;margin-top:.35rem">{{ ctp_status.last_error }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<form action="{{ url_for('settings') }}" method="post" id="ctp-settings-form">
|
||||
<input type="hidden" name="action" value="ctp">
|
||||
|
||||
<div class="settings-ctp-cards-row">
|
||||
<div class="settings-ctp-fold card{% if trading_mode != 'simulation' %} is-collapsed{% endif %}" data-ctp-fold="simnow">
|
||||
<button type="button" class="settings-ctp-fold-head" aria-expanded="{{ 'true' if trading_mode == 'simulation' else 'false' }}">
|
||||
<span class="settings-ctp-fold-title">
|
||||
SimNow 模拟盘
|
||||
{% if trading_mode == 'simulation' %}<span class="badge planned" style="font-size:.7rem">当前通道</span>{% endif %}
|
||||
</span>
|
||||
<span class="settings-ctp-fold-chevron" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
<div class="settings-ctp-fold-body">
|
||||
<div class="settings-ctp-grid">
|
||||
<div class="field">
|
||||
<label>投资者代码</label>
|
||||
<input name="simnow_user" value="{{ ctp_cfg.simnow_user }}" placeholder="非手机号">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>交易密码</label>
|
||||
<input id="simnow_password" name="simnow_password" type="password"
|
||||
autocomplete="off" spellcheck="false"
|
||||
placeholder="{% if ctp_cfg.simnow_password_set %}已设置:须重新输入才会更新{% else %}SimNow 交易密码(必填){% endif %}">
|
||||
<p class="hint" style="margin:.25rem 0 0;font-size:.75rem">
|
||||
与快期相同密码,保存前须在此<strong>手打</strong>;留空则不改。下方「修改密码」是网页登录密码,不是 SimNow。
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>经纪商代码</label>
|
||||
<input name="simnow_broker_id" value="{{ ctp_cfg.simnow_broker_id }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>柜台环境</label>
|
||||
<select name="simnow_env">
|
||||
<option value="实盘" {% if ctp_cfg.simnow_env == '实盘' %}selected{% endif %}>实盘(看穿式,推荐)</option>
|
||||
<option value="测试" {% if ctp_cfg.simnow_env == '测试' %}selected{% endif %}>测试</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>AppID</label>
|
||||
<input name="simnow_app_id" value="{{ ctp_cfg.simnow_app_id }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>授权编码</label>
|
||||
<input name="simnow_auth_code" value="{{ ctp_cfg.simnow_auth_code }}">
|
||||
</div>
|
||||
<div class="field field-ctp-front-span">
|
||||
<label>行情前置</label>
|
||||
<input name="simnow_md_address" value="{{ ctp_cfg.simnow_md_address }}" placeholder="tcp://180.168.146.187:10211">
|
||||
</div>
|
||||
<div class="field field-ctp-front-span">
|
||||
<label>交易前置</label>
|
||||
<input name="simnow_td_address" value="{{ ctp_cfg.simnow_td_address }}" placeholder="tcp://180.168.146.187:10201">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-ctp-fold card{% if trading_mode != 'live' %} is-collapsed{% endif %}" data-ctp-fold="live">
|
||||
<button type="button" class="settings-ctp-fold-head" aria-expanded="{{ 'true' if trading_mode == 'live' else 'false' }}">
|
||||
<span class="settings-ctp-fold-title">
|
||||
期货公司实盘
|
||||
{% if trading_mode == 'live' %}<span class="badge planned" style="font-size:.7rem">当前通道</span>{% endif %}
|
||||
</span>
|
||||
<span class="settings-ctp-fold-chevron" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
<div class="settings-ctp-fold-body">
|
||||
<div class="settings-ctp-grid">
|
||||
<div class="field">
|
||||
<label>投资者代码</label>
|
||||
<input name="ctp_live_user" value="{{ ctp_cfg.ctp_live_user }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>交易密码</label>
|
||||
<input name="ctp_live_password" type="password" autocomplete="new-password"
|
||||
placeholder="{% if ctp_cfg.ctp_live_password_set %}已设置,留空不修改{% else %}实盘密码{% endif %}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>经纪商代码</label>
|
||||
<input name="ctp_live_broker_id" value="{{ ctp_cfg.ctp_live_broker_id }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>柜台环境</label>
|
||||
<select name="ctp_live_env">
|
||||
<option value="实盘" {% if ctp_cfg.ctp_live_env == '实盘' %}selected{% endif %}>实盘</option>
|
||||
<option value="测试" {% if ctp_cfg.ctp_live_env == '测试' %}selected{% endif %}>测试</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>AppID</label>
|
||||
<input name="ctp_live_app_id" value="{{ ctp_cfg.ctp_live_app_id }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>授权编码</label>
|
||||
<input name="ctp_live_auth_code" value="{{ ctp_cfg.ctp_live_auth_code }}">
|
||||
</div>
|
||||
<div class="field field-ctp-front-span">
|
||||
<label>行情前置</label>
|
||||
<input name="ctp_live_md_address" value="{{ ctp_cfg.ctp_live_md_address }}" placeholder="tcp://...">
|
||||
</div>
|
||||
<div class="field field-ctp-front-span">
|
||||
<label>交易前置</label>
|
||||
<input name="ctp_live_td_address" value="{{ ctp_cfg.ctp_live_td_address }}" placeholder="tcp://...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">保存 CTP 配置</button>
|
||||
<p class="settings-ctp-status">
|
||||
官方第一套:<code>180.168.146.187:10201/10211</code>;
|
||||
第二套(云服务器常用):<code>182.254.243.31:30001/30011</code>;
|
||||
7×24:<code>182.254.243.31:40001/40011</code>(部分账号在 40001 会报「不合法登录」,与快期前置保持一致)。
|
||||
详见 <code>docs/SIMNOW.md</code>。
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>行情说明</h2>
|
||||
<div class="card-inner">
|
||||
<p class="hint" style="font-size:.88rem;line-height:1.6;margin:0">
|
||||
当前行情源:<strong class="text-accent">{{ quote_label }}</strong><br>
|
||||
CTP 已连接时使用<strong>柜台行情</strong>;未连接时回退新浪接口。<br>
|
||||
合约代码按同花顺格式(如 ag2608、IF2606)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>企业微信推送</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="wechat">
|
||||
<div class="field" style="margin-bottom:.75rem">
|
||||
<label>Webhook 地址</label>
|
||||
<input name="wechat_webhook" type="url" placeholder="https://qyapi.weixin.qq.com/..." value="{{ webhook }}">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
<p class="hint" style="margin-top:.75rem;margin-bottom:0">在企业微信群添加机器人后,粘贴 Webhook 地址保存。</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>修改密码</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post" class="settings-password-form">
|
||||
<input type="hidden" name="action" value="password">
|
||||
<div class="field field-full">
|
||||
<label>当前账号</label>
|
||||
<input type="text" value="{{ username }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>原密码</label>
|
||||
<input name="old_password" type="password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>新密码</label>
|
||||
<input name="new_password" type="password" required minlength="6" placeholder="至少 6 位">
|
||||
</div>
|
||||
<div class="field field-full">
|
||||
<label>确认新密码</label>
|
||||
<input name="new_password2" type="password" required minlength="6">
|
||||
</div>
|
||||
<div class="field-full">
|
||||
<button type="submit" class="btn-primary">修改密码</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>使用提示</h2>
|
||||
<ul class="settings-tips">
|
||||
<li>下单监控:连接 CTP 后下单、看持仓与可开仓品种</li>
|
||||
<li>策略交易:趋势回调自动补仓;顺势加仓需先开仓</li>
|
||||
<li>手续费:默认 CTP 柜台费率,连接后点同步</li>
|
||||
<li>手机端:浏览器菜单可「添加到主屏幕」安装 App</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var sel = document.getElementById('position-sizing-mode');
|
||||
var lotsField = document.getElementById('field-fixed-lots');
|
||||
var amountField = document.getElementById('field-fixed-amount');
|
||||
function syncSizingFields() {
|
||||
if (!sel) return;
|
||||
var isAmount = sel.value === 'amount';
|
||||
if (lotsField) lotsField.hidden = isAmount;
|
||||
if (amountField) amountField.hidden = !isAmount;
|
||||
}
|
||||
if (sel) sel.addEventListener('change', syncSizingFields);
|
||||
syncSizingFields();
|
||||
|
||||
var CTP_FOLD_KEY = 'qihuo_ctp_fold';
|
||||
function setCtpFold(el, collapsed) {
|
||||
if (!el) return;
|
||||
el.classList.toggle('is-collapsed', collapsed);
|
||||
var head = el.querySelector('.settings-ctp-fold-head');
|
||||
if (head) head.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
}
|
||||
function saveCtpFoldState() {
|
||||
var state = {};
|
||||
document.querySelectorAll('[data-ctp-fold]').forEach(function (el) {
|
||||
state[el.getAttribute('data-ctp-fold')] = el.classList.contains('is-collapsed');
|
||||
});
|
||||
try { localStorage.setItem(CTP_FOLD_KEY, JSON.stringify(state)); } catch (e) { /* ignore */ }
|
||||
}
|
||||
function loadCtpFoldState() {
|
||||
try {
|
||||
var raw = localStorage.getItem(CTP_FOLD_KEY);
|
||||
if (!raw) return;
|
||||
var state = JSON.parse(raw);
|
||||
document.querySelectorAll('[data-ctp-fold]').forEach(function (el) {
|
||||
var key = el.getAttribute('data-ctp-fold');
|
||||
if (Object.prototype.hasOwnProperty.call(state, key)) {
|
||||
setCtpFold(el, !!state[key]);
|
||||
}
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
document.querySelectorAll('.settings-ctp-fold-head').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var panel = btn.closest('[data-ctp-fold]');
|
||||
if (!panel) return;
|
||||
setCtpFold(panel, !panel.classList.contains('is-collapsed'));
|
||||
saveCtpFoldState();
|
||||
});
|
||||
});
|
||||
loadCtpFoldState();
|
||||
|
||||
var ctpForm = document.getElementById('ctp-settings-form');
|
||||
if (ctpForm) {
|
||||
ctpForm.addEventListener('submit', function (ev) {
|
||||
var simnowFold = document.querySelector('[data-ctp-fold="simnow"]');
|
||||
if (simnowFold) setCtpFold(simnowFold, false);
|
||||
var pwd = document.getElementById('simnow_password');
|
||||
var pwdVal = pwd && pwd.value ? pwd.value.trim() : '';
|
||||
var pwdWasSet = {{ 'true' if ctp_cfg.simnow_password_set else 'false' }};
|
||||
if (pwdWasSet && !pwdVal) {
|
||||
var ok = window.confirm(
|
||||
'SimNow 交易密码为空,保存后不会更新密码(仍用旧密码)。\n\n'
|
||||
+ '若快期已改密,请取消后在「交易密码」框手打新密码再保存。\n\n仍要保存其他项?'
|
||||
);
|
||||
if (!ok) ev.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+77
-76
@@ -1,76 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}统计分析 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card stats-summary-card">
|
||||
<div class="stats-toolbar">
|
||||
<span id="stats-updated" class="hint">正在加载统计…</span>
|
||||
</div>
|
||||
<div class="stat-grid stat-grid-summary" id="stats-summary">
|
||||
<div class="stat-item"><div class="label">总交易次数</div><div class="value" data-k="total_trades">-</div></div>
|
||||
<div class="stat-item"><div class="label">胜率</div><div class="value" data-k="win_rate">-</div></div>
|
||||
<div class="stat-item"><div class="label">平均盈利</div><div class="value text-profit" data-k="avg_profit">-</div></div>
|
||||
<div class="stat-item"><div class="label">平均亏损</div><div class="value text-loss" data-k="avg_loss">-</div></div>
|
||||
<div class="stat-item"><div class="label">盈亏比</div><div class="value" data-k="profit_loss_ratio">-</div></div>
|
||||
<div class="stat-item"><div class="label">连续亏损次数</div><div class="value" data-k="consecutive_losses">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大回撤</div><div class="value" data-k="max_drawdown">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大亏损金额</div><div class="value text-loss" data-k="max_loss_amount">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大亏损占比</div><div class="value text-loss" data-k="max_loss_pct">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大盈利金额</div><div class="value text-profit" data-k="max_profit_amount">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大盈利占比</div><div class="value text-profit" data-k="max_profit_pct">-</div></div>
|
||||
<div class="stat-item"><div class="label">累计手续费</div><div class="value text-loss" data-k="total_fee">-</div></div>
|
||||
<div class="stat-item"><div class="label">情绪单数量</div><div class="value" data-k="emotion_count">-</div></div>
|
||||
<div class="stat-item"><div class="label">情绪单占比</div><div class="value" data-k="emotion_ratio">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stats-card-head">
|
||||
<h2>分项统计</h2>
|
||||
<div class="field stats-view-field">
|
||||
<label for="stats-view-select">统计维度</label>
|
||||
<select id="stats-view-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-scroll">
|
||||
<table id="stats-breakdown-table">
|
||||
<thead><tr id="stats-breakdown-head"></tr></thead>
|
||||
<tbody id="stats-breakdown-body">
|
||||
<tr><td colspan="12" class="text-muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-summary-card{margin-bottom:1.25rem}
|
||||
.stats-toolbar{display:flex;align-items:center;justify-content:flex-start;gap:1rem;margin-bottom:.75rem;flex-wrap:wrap}
|
||||
.stat-grid-summary{
|
||||
display:flex;flex-wrap:nowrap;align-items:stretch;gap:0;
|
||||
margin-bottom:0;overflow-x:auto;-webkit-overflow-scrolling:touch;
|
||||
}
|
||||
.stat-grid-summary .stat-item{
|
||||
flex:1 1 0;min-width:4.5rem;background:transparent;border:none;border-radius:0;
|
||||
padding:.35rem .2rem;text-align:center;position:relative;overflow:visible;
|
||||
border-right:1px solid var(--table-border);
|
||||
}
|
||||
.stat-grid-summary .stat-item:last-child{border-right:none}
|
||||
.stat-grid-summary .stat-item::before{display:none}
|
||||
.stat-grid-summary .stat-item:hover{transform:none;box-shadow:none}
|
||||
.stat-grid-summary .stat-item .label{
|
||||
font-size:.62rem;line-height:1.25;color:var(--text-muted);white-space:nowrap;
|
||||
}
|
||||
.stat-grid-summary .stat-item .value{
|
||||
font-size:.78rem;font-weight:600;color:var(--text-title);margin-top:.12rem;
|
||||
font-variant-numeric:tabular-nums;white-space:nowrap;
|
||||
}
|
||||
.stats-card-head{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1rem}
|
||||
.stats-card-head h2{margin-bottom:0}
|
||||
.stats-view-field{width:auto;min-width:200px}
|
||||
.stats-view-field select{width:100%;min-width:180px}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/stats.js') }}"></script>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}统计分析 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card stats-summary-card">
|
||||
<div class="stats-toolbar">
|
||||
<span id="stats-updated" class="hint">正在加载统计…</span>
|
||||
</div>
|
||||
<div class="stat-grid stat-grid-summary" id="stats-summary">
|
||||
<div class="stat-item"><div class="label">总交易次数</div><div class="value" data-k="total_trades">-</div></div>
|
||||
<div class="stat-item"><div class="label">胜率</div><div class="value" data-k="win_rate">-</div></div>
|
||||
<div class="stat-item"><div class="label">平均盈利</div><div class="value text-profit" data-k="avg_profit">-</div></div>
|
||||
<div class="stat-item"><div class="label">平均亏损</div><div class="value text-loss" data-k="avg_loss">-</div></div>
|
||||
<div class="stat-item"><div class="label">盈亏比</div><div class="value" data-k="profit_loss_ratio">-</div></div>
|
||||
<div class="stat-item"><div class="label">连续亏损次数</div><div class="value" data-k="consecutive_losses">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大回撤</div><div class="value" data-k="max_drawdown">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大亏损金额</div><div class="value text-loss" data-k="max_loss_amount">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大亏损占比</div><div class="value text-loss" data-k="max_loss_pct">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大盈利金额</div><div class="value text-profit" data-k="max_profit_amount">-</div></div>
|
||||
<div class="stat-item"><div class="label">最大盈利占比</div><div class="value text-profit" data-k="max_profit_pct">-</div></div>
|
||||
<div class="stat-item"><div class="label">累计手续费</div><div class="value text-loss" data-k="total_fee">-</div></div>
|
||||
<div class="stat-item"><div class="label">情绪单数量</div><div class="value" data-k="emotion_count">-</div></div>
|
||||
<div class="stat-item"><div class="label">情绪单占比</div><div class="value" data-k="emotion_ratio">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stats-card-head">
|
||||
<h2>分项统计</h2>
|
||||
<div class="field stats-view-field">
|
||||
<label for="stats-view-select">统计维度</label>
|
||||
<select id="stats-view-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-scroll">
|
||||
<table id="stats-breakdown-table">
|
||||
<thead><tr id="stats-breakdown-head"></tr></thead>
|
||||
<tbody id="stats-breakdown-body">
|
||||
<tr><td colspan="12" class="text-muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-summary-card{margin-bottom:1.25rem}
|
||||
.stats-toolbar{display:flex;align-items:center;justify-content:flex-start;gap:1rem;margin-bottom:.75rem;flex-wrap:wrap}
|
||||
.stat-grid-summary{
|
||||
display:flex;flex-wrap:nowrap;align-items:stretch;gap:0;
|
||||
margin-bottom:0;overflow-x:auto;-webkit-overflow-scrolling:touch;
|
||||
}
|
||||
.stat-grid-summary .stat-item{
|
||||
flex:1 1 0;min-width:4.5rem;background:transparent;border:none;border-radius:0;
|
||||
padding:.35rem .2rem;text-align:center;position:relative;overflow:visible;
|
||||
border-right:1px solid var(--table-border);
|
||||
}
|
||||
.stat-grid-summary .stat-item:last-child{border-right:none}
|
||||
.stat-grid-summary .stat-item::before{display:none}
|
||||
.stat-grid-summary .stat-item:hover{transform:none;box-shadow:none}
|
||||
.stat-grid-summary .stat-item .label{
|
||||
font-size:.62rem;line-height:1.25;color:var(--text-muted);white-space:nowrap;
|
||||
}
|
||||
.stat-grid-summary .stat-item .value{
|
||||
font-size:.78rem;font-weight:600;color:var(--text-title);margin-top:.12rem;
|
||||
font-variant-numeric:tabular-nums;white-space:nowrap;
|
||||
}
|
||||
.stats-card-head{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1rem}
|
||||
.stats-card-head h2{margin-bottom:0}
|
||||
.stats-view-field{width:auto;min-width:200px}
|
||||
.stats-view-field select{width:100%;min-width:180px}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/stats.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
+102
-101
@@ -1,101 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}策略交易 - 国内期货监控系统{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.strategy-page .split-grid .card{min-height:420px;display:flex;flex-direction:column}
|
||||
.strategy-page .split-grid .card-body{flex:1}
|
||||
.strategy-preview{background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;margin-top:.75rem;white-space:pre-wrap;max-height:200px;overflow:auto}
|
||||
.strategy-steps{margin:.75rem 0 0;padding-left:1.1rem;font-size:.82rem;color:var(--text-muted);line-height:1.6}
|
||||
.strategy-steps a{color:var(--accent)}
|
||||
.strategy-active-roll{margin-top:.65rem;padding:.55rem .75rem;background:var(--card-inner);border-radius:8px;font-size:.8rem;border:1px solid var(--card-border)}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="strategy-page">
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>趋势回调</h2>
|
||||
<div class="card-body">
|
||||
{% if active_trend %}
|
||||
<p class="hint">运行中 #{{ active_trend.id }} · {{ active_trend.symbol }} · {{ '做多' if active_trend.direction == 'long' else '做空' }}</p>
|
||||
<p class="hint">已开 <strong>{{ active_trend.lots_open or 0 }}</strong> / {{ active_trend.target_lots }} 手 · 止损 {{ active_trend.stop_loss }} · 止盈 {{ active_trend.take_profit }}</p>
|
||||
<form id="trend-stop-form" class="form-row" style="margin-top:.75rem">
|
||||
<input type="hidden" name="plan_id" value="{{ active_trend.id }}">
|
||||
<button type="button" class="btn-primary" id="btn-trend-stop">结束计划</button>
|
||||
</form>
|
||||
<p class="hint" style="margin-top:.75rem;font-size:.75rem">后台按档位自动补仓,触及止盈或手动结束。</p>
|
||||
{% else %}
|
||||
<p class="hint" style="margin-bottom:.65rem">设置止损/补仓边界/止盈 → 预览 → 确认执行首仓;后续自动分档加仓。</p>
|
||||
<form id="trend-form" class="form-compact">
|
||||
<div class="form-line line-2">
|
||||
<div class="symbol-wrap symbol-mains">
|
||||
<input class="symbol-input" name="symbol" placeholder="品种,输入中文或代码" autocomplete="off" required>
|
||||
<div class="symbol-dropdown"></div>
|
||||
</div>
|
||||
<select name="direction"><option value="long">做多</option><option value="short">做空</option></select>
|
||||
</div>
|
||||
<div class="form-line line-3">
|
||||
<input name="stop_loss" type="number" step="any" placeholder="止损" required>
|
||||
<input name="add_upper" type="number" step="any" placeholder="补仓边界" required>
|
||||
<input name="take_profit" type="number" step="any" placeholder="止盈" required>
|
||||
</div>
|
||||
<div class="form-line line-2">
|
||||
<input name="risk_percent" type="number" step="0.1" value="{{ risk_percent }}" placeholder="单笔风险 %" title="单笔风险%">
|
||||
<button type="button" class="btn-primary" id="btn-trend-preview">预览计划</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="trend-preview" class="strategy-preview" hidden></div>
|
||||
<button type="button" class="btn-primary" id="btn-trend-exec" hidden style="margin-top:.65rem;width:100%">确认执行首仓</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>顺势加仓(滚仓)</h2>
|
||||
<div class="card-body">
|
||||
<p class="hint" style="margin-bottom:.65rem">在已有持仓上扩大仓位,统一抬高止损;最多 3 腿,止盈锁定首仓。</p>
|
||||
{% if roll_groups %}
|
||||
{% for g in roll_groups %}
|
||||
<div class="strategy-active-roll">
|
||||
运行中 · 监控 #{{ g.order_monitor_id }} · {{ g.leg_count or 1 }} 腿 · 止损 {{ g.current_stop_loss }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if monitors %}
|
||||
<form id="roll-form" class="form-compact">
|
||||
<div class="field" style="margin-bottom:.5rem">
|
||||
<label class="text-label" style="font-size:.72rem">选择下单监控(开仓后生成)</label>
|
||||
<select name="monitor_id" required>
|
||||
{% for m in monitors %}
|
||||
<option value="{{ m.id }}">{{ m.symbol_name or m.symbol }} {{ m.symbol }} · {{ '多' if m.direction == 'long' else '空' }} {{ m.lots }}手 · SL {{ m.stop_loss or '—' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-line line-2">
|
||||
<input name="new_stop_loss" type="number" step="any" placeholder="新统一止损" required>
|
||||
<input name="risk_percent" type="number" step="0.1" value="2" placeholder="总风险 %" title="总风险%">
|
||||
</div>
|
||||
<div class="form-line line-2">
|
||||
<input name="add_price" type="number" step="any" placeholder="加仓参考价(可选)">
|
||||
<button type="button" class="btn-primary" id="btn-roll-preview">预览滚仓</button>
|
||||
</div>
|
||||
<div id="roll-preview" class="strategy-preview" hidden></div>
|
||||
<button type="button" class="btn-primary" id="btn-roll-exec" hidden style="margin-top:.65rem;width:100%">执行滚仓</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="empty-hint">暂无可用持仓监控</p>
|
||||
<ol class="strategy-steps">
|
||||
<li>打开 <a href="{{ url_for('positions') }}">持仓监控</a>,连接 CTP</li>
|
||||
<li>在「期货下单」填写品种、止损/止盈并<strong>开仓</strong></li>
|
||||
<li>开仓成功后会生成本页可选的监控记录,即可滚仓</li>
|
||||
</ol>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:1rem"><a href="{{ url_for('strategy_records_page') }}">策略交易记录 →</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/strategy.js') }}"></script>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}策略交易 - 国内期货监控系统{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.strategy-page .split-grid .card{min-height:420px;display:flex;flex-direction:column}
|
||||
.strategy-page .split-grid .card-body{flex:1}
|
||||
.strategy-preview{background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;margin-top:.75rem;white-space:pre-wrap;max-height:200px;overflow:auto}
|
||||
.strategy-steps{margin:.75rem 0 0;padding-left:1.1rem;font-size:.82rem;color:var(--text-muted);line-height:1.6}
|
||||
.strategy-steps a{color:var(--accent)}
|
||||
.strategy-active-roll{margin-top:.65rem;padding:.55rem .75rem;background:var(--card-inner);border-radius:8px;font-size:.8rem;border:1px solid var(--card-border)}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="strategy-page">
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>趋势回调</h2>
|
||||
<div class="card-body">
|
||||
{% if active_trend %}
|
||||
<p class="hint">运行中 #{{ active_trend.id }} · {{ active_trend.symbol }} · {{ '做多' if active_trend.direction == 'long' else '做空' }}</p>
|
||||
<p class="hint">已开 <strong>{{ active_trend.lots_open or 0 }}</strong> / {{ active_trend.target_lots }} 手 · 止损 {{ active_trend.stop_loss }} · 止盈 {{ active_trend.take_profit }}</p>
|
||||
<form id="trend-stop-form" class="form-row" style="margin-top:.75rem">
|
||||
<input type="hidden" name="plan_id" value="{{ active_trend.id }}">
|
||||
<button type="button" class="btn-primary" id="btn-trend-stop">结束计划</button>
|
||||
</form>
|
||||
<p class="hint" style="margin-top:.75rem;font-size:.75rem">后台按档位自动补仓,触及止盈或手动结束。</p>
|
||||
{% else %}
|
||||
<p class="hint" style="margin-bottom:.65rem">设置止损/补仓边界/止盈 → 预览 → 确认执行首仓;后续自动分档加仓。</p>
|
||||
<form id="trend-form" class="form-compact">
|
||||
<div class="form-line line-2">
|
||||
<div class="symbol-wrap symbol-mains">
|
||||
<input class="symbol-input" name="symbol" placeholder="品种,输入中文或代码" autocomplete="off" required>
|
||||
<div class="symbol-dropdown"></div>
|
||||
</div>
|
||||
<select name="direction"><option value="long">做多</option><option value="short">做空</option></select>
|
||||
</div>
|
||||
<div class="form-line line-3">
|
||||
<input name="stop_loss" type="number" step="any" placeholder="止损" required>
|
||||
<input name="add_upper" type="number" step="any" placeholder="补仓边界" required>
|
||||
<input name="take_profit" type="number" step="any" placeholder="止盈" required>
|
||||
</div>
|
||||
<div class="form-line line-2">
|
||||
<input name="risk_percent" type="number" step="0.1" value="{{ risk_percent }}" placeholder="单笔风险 %" title="单笔风险%">
|
||||
<button type="button" class="btn-primary" id="btn-trend-preview">预览计划</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="trend-preview" class="strategy-preview" hidden></div>
|
||||
<button type="button" class="btn-primary" id="btn-trend-exec" hidden style="margin-top:.65rem;width:100%">确认执行首仓</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>顺势加仓(滚仓)</h2>
|
||||
<div class="card-body">
|
||||
<p class="hint" style="margin-bottom:.65rem">在已有持仓上扩大仓位,统一抬高止损;最多 3 腿,止盈锁定首仓。</p>
|
||||
{% if roll_groups %}
|
||||
{% for g in roll_groups %}
|
||||
<div class="strategy-active-roll">
|
||||
运行中 · 监控 #{{ g.order_monitor_id }} · {{ g.leg_count or 1 }} 腿 · 止损 {{ g.current_stop_loss }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if monitors %}
|
||||
<form id="roll-form" class="form-compact">
|
||||
<div class="field" style="margin-bottom:.5rem">
|
||||
<label class="text-label" style="font-size:.72rem">选择下单监控(开仓后生成)</label>
|
||||
<select name="monitor_id" required>
|
||||
{% for m in monitors %}
|
||||
<option value="{{ m.id }}">{{ m.symbol_name or m.symbol }} {{ m.symbol }} · {{ '多' if m.direction == 'long' else '空' }} {{ m.lots }}手 · SL {{ m.stop_loss or '—' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-line line-2">
|
||||
<input name="new_stop_loss" type="number" step="any" placeholder="新统一止损" required>
|
||||
<input name="risk_percent" type="number" step="0.1" value="2" placeholder="总风险 %" title="总风险%">
|
||||
</div>
|
||||
<div class="form-line line-2">
|
||||
<input name="add_price" type="number" step="any" placeholder="加仓参考价(可选)">
|
||||
<button type="button" class="btn-primary" id="btn-roll-preview">预览滚仓</button>
|
||||
</div>
|
||||
<div id="roll-preview" class="strategy-preview" hidden></div>
|
||||
<button type="button" class="btn-primary" id="btn-roll-exec" hidden style="margin-top:.65rem;width:100%">执行滚仓</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="empty-hint">暂无可用持仓监控</p>
|
||||
<ol class="strategy-steps">
|
||||
<li>打开 <a href="{{ url_for('positions') }}">持仓监控</a>,连接 CTP</li>
|
||||
<li>在「期货下单」填写品种、止损/止盈并<strong>开仓</strong></li>
|
||||
<li>开仓成功后会生成本页可选的监控记录,即可滚仓</li>
|
||||
</ol>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:1rem"><a href="{{ url_for('strategy_records_page') }}">策略交易记录 →</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/strategy.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}策略记录 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="split-grid">
|
||||
<div class="card card-scroll">
|
||||
<h2>趋势回调</h2>
|
||||
{% if trend_rows %}
|
||||
<ul class="list">{% for r in trend_rows %}
|
||||
<li class="list-item"><span>{{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}</span></li>
|
||||
{% endfor %}</ul>
|
||||
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
|
||||
</div>
|
||||
<div class="card card-scroll">
|
||||
<h2>顺势加仓</h2>
|
||||
{% if roll_rows %}
|
||||
<ul class="list">{% for r in roll_rows %}
|
||||
<li class="list-item"><span>{{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}</span></li>
|
||||
{% endfor %}</ul>
|
||||
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}策略记录 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="split-grid">
|
||||
<div class="card card-scroll">
|
||||
<h2>趋势回调</h2>
|
||||
{% if trend_rows %}
|
||||
<ul class="list">{% for r in trend_rows %}
|
||||
<li class="list-item"><span>{{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}</span></li>
|
||||
{% endfor %}</ul>
|
||||
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
|
||||
</div>
|
||||
<div class="card card-scroll">
|
||||
<h2>顺势加仓</h2>
|
||||
{% if roll_rows %}
|
||||
<ul class="list">{% for r in roll_rows %}
|
||||
<li class="list-item"><span>{{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}</span></li>
|
||||
{% endfor %}</ul>
|
||||
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+211
-210
@@ -1,210 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}下单监控 - 国内期货监控系统{% endblock %}
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/trade.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="trade-page">
|
||||
<div class="trade-top-bar">
|
||||
<div class="trade-top-bar-main">
|
||||
<span class="badge dir">{{ trading_mode_label }}</span>
|
||||
<span class="badge {% if ctp_status.connected %}profit{% else %}planned{% endif %}" id="ctp-badge">
|
||||
{% if ctp_status.connected %}CTP 已连接{% else %}CTP 未连接{% endif %}
|
||||
</span>
|
||||
<span class="badge {% if risk_status.can_trade %}profit{% else %}loss{% endif %}" id="risk-badge">{{ risk_status.status_label }}</span>
|
||||
<span class="text-muted">权益 <strong id="cap-display">{{ '%.2f'|format(capital) }}</strong> 元</span>
|
||||
{% if ctp_account.available is defined and ctp_status.connected %}
|
||||
<span class="text-muted">可用 <strong id="avail-display">{{ '%.2f'|format(ctp_account.available) }}</strong> 元</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="trade-top-bar-actions">
|
||||
<button type="button" class="btn-primary btn-ctp-sm" id="btn-ctp-connect">{% if ctp_status.connected %}重连 CTP{% else %}连接 CTP{% endif %}</button>
|
||||
<span class="text-muted trade-top-hint">断线自动重连 · 开盘前 30 分钟自动连接</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-grid trade-split">
|
||||
<div class="card trade-card" id="order">
|
||||
<h2>期货下单</h2>
|
||||
<div class="card-body">
|
||||
<div class="trade-order-status trade-order-status-compact">
|
||||
<div class="status-row">
|
||||
<span class="text-muted">计仓</span>
|
||||
<strong id="sizing-label">{{ sizing_mode_label }}</strong>
|
||||
{% if sizing_mode == 'fixed' %}
|
||||
<span class="text-muted">· {{ fixed_lots }} 手</span>
|
||||
{% elif sizing_mode in ('amount', 'risk') %}
|
||||
<span class="text-muted">· {{ '%.0f'|format(fixed_amount) }} 元</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trade-form-rows">
|
||||
<div class="trade-form-line line-3">
|
||||
<div class="symbol-wrap trade-field symbol-mains">
|
||||
<label class="text-label">品种</label>
|
||||
<input type="text" id="trade-symbol" class="symbol-input" placeholder="输入中文或代码,选择主力合约" autocomplete="off">
|
||||
<input type="hidden" id="trade-symbol-code" class="symbol-ths-code">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected" id="sym-selected"></div>
|
||||
</div>
|
||||
<div class="trade-field">
|
||||
<label class="text-label">方向</label>
|
||||
<select id="trade-direction">
|
||||
<option value="long">做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trade-field" id="field-lots">
|
||||
<label class="text-label">手数</label>
|
||||
<input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="—">
|
||||
<input type="hidden" id="trade-lots" value="{{ fixed_lots if sizing_mode == 'fixed' else '1' }}">
|
||||
<p class="hint lots-warn text-loss" id="lots-warn" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trade-form-line line-3">
|
||||
<div class="trade-field">
|
||||
<label class="text-label">入场价</label>
|
||||
<div class="price-type-tabs">
|
||||
<button type="button" class="price-tab active" data-type="limit">限价</button>
|
||||
<button type="button" class="price-tab" data-type="market">市价</button>
|
||||
</div>
|
||||
<input type="number" id="trade-price" step="any" placeholder="限价">
|
||||
<p class="hint market-hint" id="market-hint" hidden>市价以 FAK 即时成交报单(非限价挂单)</p>
|
||||
</div>
|
||||
<div class="trade-field">
|
||||
<label class="text-label">止盈</label>
|
||||
<input type="number" id="trade-tp" step="any">
|
||||
</div>
|
||||
<div class="trade-field">
|
||||
<label class="text-label">止损</label>
|
||||
<input type="number" id="trade-sl" step="any">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trade-action-row">
|
||||
<label class="trailing-be-toggle">
|
||||
<input type="checkbox" id="trailing-be" checked>
|
||||
<span>移动保本</span>
|
||||
</label>
|
||||
<span class="hint trade-rr-hint" id="trade-rr-hint" hidden></span>
|
||||
<button type="button" class="btn-primary btn-open" id="btn-open">开仓</button>
|
||||
<p class="hint session-hint text-muted" id="session-hint" hidden>不在交易时间段</p>
|
||||
<p class="trade-order-msg" id="order-msg" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="trade-footer" id="trade-footer">
|
||||
<p class="hint" id="trade-metrics-hint">填写品种后显示精度与每跳价值;策略自动化请用 <a href="{{ url_for('strategy_page') }}">策略交易</a>。</p>
|
||||
{% if ctp_status.last_error %}
|
||||
<p class="text-loss ctp-install-hint" style="font-size:.78rem;margin-top:.35rem">{{ ctp_status.last_error }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card trade-card" id="positions">
|
||||
<h2>当前持仓</h2>
|
||||
<p class="hint pos-hint">开仓委托先显示「挂单中」,柜台成交后写入监控;超过 <strong>{{ pending_order_timeout_min }}</strong> 分钟未成交自动撤单,可手动撤单。</p>
|
||||
<div class="card-body card-scroll" id="position-live-list">
|
||||
<div class="empty-hint" id="position-placeholder">加载本地持仓…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card trade-card trade-card-full" id="recommend">
|
||||
<h2>品种推荐</h2>
|
||||
<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 %}
|
||||
保证金优先读取 CTP 柜台合约信息。
|
||||
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
|
||||
</p>
|
||||
<p class="trend-hint">走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。成交量为昨日成交手数,成交额=成交量×昨收×合约乘数。支持按走势/跳空/成交量/振幅排序,可按行业筛选。</p>
|
||||
<div class="rec-stats" id="rec-stats"></div>
|
||||
<div class="rec-sort-bar">
|
||||
<label for="rec-industry-filter">行业</label>
|
||||
<select id="rec-industry-filter">
|
||||
<option value="" selected>全部</option>
|
||||
{% for cat in product_categories %}
|
||||
<option value="{{ cat }}">{{ cat }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="rec-sort-key">排序</label>
|
||||
<select id="rec-sort-key">
|
||||
<option value="trend" selected>走势</option>
|
||||
<option value="gap">跳空</option>
|
||||
<option value="volume">成交量</option>
|
||||
<option value="amplitude">振幅</option>
|
||||
</select>
|
||||
<button type="button" class="rec-sort-dir-btn" id="rec-sort-dir" title="切换升序/降序">↓</button>
|
||||
</div>
|
||||
<div class="trade-table-wrap">
|
||||
<table class="trade-table" id="recommend-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品种</th><th>交易所</th><th>行业</th><th>走势</th><th>是否跳空</th>
|
||||
<th>参考价</th><th>昨日收盘</th><th>今日开盘</th>
|
||||
<th>昨日涨跌</th><th>昨日振幅</th><th>成交量(手)</th><th>成交额</th>
|
||||
<th>1手保证金</th><th>1手手续费</th><th>最大手数</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recommend-list">
|
||||
{% if recommend_rows %}
|
||||
{% for r in recommend_rows %}
|
||||
<tr class="rec-{{ r.status }}{% if r.trend_transition %} rec-trend-break{% endif %}">
|
||||
<td><strong class="{% if r.trend_transition %}trend-name{% endif %}">{{ r.name }}</strong> <span class="text-accent">{{ r.main_code or r.ths }}</span></td>
|
||||
<td>{{ r.exchange }}</td>
|
||||
<td>{{ r.category or '—' }}</td>
|
||||
<td>
|
||||
{% if r.trend_label and r.trend_label != '—' %}
|
||||
<span class="badge trend-badge {% if r.trend in ('break_long', 'break_short') %}break{% elif r.trend == 'long' %}profit{% elif r.trend == 'short' %}loss{% else %}planned{% endif %}" title="{% if r.trend_overlap_pct is not none %}近3日重叠 {{ r.trend_overlap_pct }}%{% endif %}">
|
||||
{% if r.trend_transition %}★ {% endif %}{{ r.trend_label }}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.gap_label and r.gap_label != '—' %}
|
||||
<span class="badge gap-badge {% if r.gap == 'up' %}profit{% elif r.gap == 'down' %}loss{% else %}planned{% endif %}"{% if r.gap_pct %} title="跳空 {{ '%+.2f'|format(r.gap_pct) }}%"{% endif %}>{{ r.gap_label }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.prev_close is not none %}{{ r.prev_close }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.today_open is not none %}{{ r.today_open }}{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
{% if r.yesterday_change is not none %}
|
||||
<span class="{% if r.yesterday_change > 0 %}rec-change-up{% elif r.yesterday_change < 0 %}rec-change-down{% endif %}">
|
||||
{{ '%+.4f'|format(r.yesterday_change) }}{% if r.yesterday_change_pct is not none %} ({{ '%+.2f'|format(r.yesterday_change_pct) }}%){% endif %}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{% if r.yesterday_amplitude_pct is not none %}{{ '%.2f'|format(r.yesterday_amplitude_pct) }}%{% else %}—{% endif %}</td>
|
||||
<td>{% if r.volume is not none %}{{ r.volume }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.turnover is not none %}{{ '%.0f'|format(r.turnover) }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% if r.margin_source == 'ctp' %} <span class="text-muted">(柜台)</span>{% endif %}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %}</td>
|
||||
<td><span class="badge {% if r.status=='ok' %}profit{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="16" class="empty-hint">等待今日后台刷新推荐…</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }};
|
||||
window.TRADE_FIXED_LOTS = {{ fixed_lots|tojson }};
|
||||
window.TRADE_FIXED_AMOUNT = {{ fixed_amount|tojson }};
|
||||
window.PRODUCT_CATEGORIES = {{ product_categories | default([]) | tojson }};
|
||||
window.__RECOMMEND_ROWS__ = {{ recommend_rows | default([]) | tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/trade.js') }}?v={{ asset_v }}"></script>
|
||||
{% endblock %}
|
||||
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}下单监控 - 国内期货监控系统{% endblock %}
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/trade.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="trade-page">
|
||||
<div class="trade-top-bar">
|
||||
<div class="trade-top-bar-main">
|
||||
<span class="badge dir">{{ trading_mode_label }}</span>
|
||||
<span class="badge {% if ctp_status.connected %}profit{% else %}planned{% endif %}" id="ctp-badge">
|
||||
{% if ctp_status.connected %}CTP 已连接{% else %}CTP 未连接{% endif %}
|
||||
</span>
|
||||
<span class="badge {% if risk_status.can_trade %}profit{% else %}loss{% endif %}" id="risk-badge">{{ risk_status.status_label }}</span>
|
||||
<span class="text-muted">权益 <strong id="cap-display">{{ '%.2f'|format(capital) }}</strong> 元</span>
|
||||
{% if ctp_account.available is defined and ctp_status.connected %}
|
||||
<span class="text-muted">可用 <strong id="avail-display">{{ '%.2f'|format(ctp_account.available) }}</strong> 元</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="trade-top-bar-actions">
|
||||
<button type="button" class="btn-primary btn-ctp-sm" id="btn-ctp-connect">{% if ctp_status.connected %}重连 CTP{% else %}连接 CTP{% endif %}</button>
|
||||
<span class="text-muted trade-top-hint">断线自动重连 · 开盘前 30 分钟自动连接</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-grid trade-split">
|
||||
<div class="card trade-card" id="order">
|
||||
<h2>期货下单</h2>
|
||||
<div class="card-body">
|
||||
<div class="trade-order-status trade-order-status-compact">
|
||||
<div class="status-row">
|
||||
<span class="text-muted">计仓</span>
|
||||
<strong id="sizing-label">{{ sizing_mode_label }}</strong>
|
||||
{% if sizing_mode == 'fixed' %}
|
||||
<span class="text-muted">· {{ fixed_lots }} 手</span>
|
||||
{% elif sizing_mode in ('amount', 'risk') %}
|
||||
<span class="text-muted">· {{ '%.0f'|format(fixed_amount) }} 元</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trade-form-rows">
|
||||
<div class="trade-form-line line-3">
|
||||
<div class="symbol-wrap trade-field symbol-mains">
|
||||
<label class="text-label">品种</label>
|
||||
<input type="text" id="trade-symbol" class="symbol-input" placeholder="输入中文或代码,选择主力合约" autocomplete="off">
|
||||
<input type="hidden" id="trade-symbol-code" class="symbol-ths-code">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected" id="sym-selected"></div>
|
||||
</div>
|
||||
<div class="trade-field">
|
||||
<label class="text-label">方向</label>
|
||||
<select id="trade-direction">
|
||||
<option value="long">做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trade-field" id="field-lots">
|
||||
<label class="text-label">手数</label>
|
||||
<input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="—">
|
||||
<input type="hidden" id="trade-lots" value="{{ fixed_lots if sizing_mode == 'fixed' else '1' }}">
|
||||
<p class="hint lots-warn text-loss" id="lots-warn" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trade-form-line line-3">
|
||||
<div class="trade-field">
|
||||
<label class="text-label">入场价</label>
|
||||
<div class="price-type-tabs">
|
||||
<button type="button" class="price-tab active" data-type="limit">限价</button>
|
||||
<button type="button" class="price-tab" data-type="market">市价</button>
|
||||
</div>
|
||||
<input type="number" id="trade-price" step="any" placeholder="限价">
|
||||
<p class="hint market-hint" id="market-hint" hidden>市价以 FAK 即时成交报单(非限价挂单)</p>
|
||||
</div>
|
||||
<div class="trade-field">
|
||||
<label class="text-label">止盈</label>
|
||||
<input type="number" id="trade-tp" step="any">
|
||||
</div>
|
||||
<div class="trade-field">
|
||||
<label class="text-label">止损</label>
|
||||
<input type="number" id="trade-sl" step="any">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trade-action-row">
|
||||
<label class="trailing-be-toggle">
|
||||
<input type="checkbox" id="trailing-be" checked>
|
||||
<span>移动保本</span>
|
||||
</label>
|
||||
<span class="hint trade-rr-hint" id="trade-rr-hint" hidden></span>
|
||||
<button type="button" class="btn-primary btn-open" id="btn-open">开仓</button>
|
||||
<p class="hint session-hint text-muted" id="session-hint" hidden>不在交易时间段</p>
|
||||
<p class="trade-order-msg" id="order-msg" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="trade-footer" id="trade-footer">
|
||||
<p class="hint" id="trade-metrics-hint">填写品种后显示精度与每跳价值;策略自动化请用 <a href="{{ url_for('strategy_page') }}">策略交易</a>。</p>
|
||||
{% if ctp_status.last_error %}
|
||||
<p class="text-loss ctp-install-hint" style="font-size:.78rem;margin-top:.35rem">{{ ctp_status.last_error }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card trade-card" id="positions">
|
||||
<h2>当前持仓</h2>
|
||||
<p class="hint pos-hint">开仓委托先显示「挂单中」,柜台成交后写入监控;超过 <strong>{{ pending_order_timeout_min }}</strong> 分钟未成交自动撤单,可手动撤单。</p>
|
||||
<div class="card-body card-scroll" id="position-live-list">
|
||||
<div class="empty-hint" id="position-placeholder">加载本地持仓…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card trade-card trade-card-full" id="recommend">
|
||||
<h2>可开仓品种</h2>
|
||||
<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 %}
|
||||
保证金优先读取 CTP 柜台合约信息。
|
||||
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
|
||||
</p>
|
||||
<p class="trend-hint">走势:近一周日线,近3日重叠≥70%为震荡;跳空=今日开盘 vs 昨日收盘。成交量为昨日成交手数,成交额=成交量×昨收×合约乘数。支持按走势/跳空/成交量/振幅排序,可按行业筛选。</p>
|
||||
<div class="rec-stats" id="rec-stats"></div>
|
||||
<div class="rec-sort-bar">
|
||||
<label for="rec-industry-filter">行业</label>
|
||||
<select id="rec-industry-filter">
|
||||
<option value="" selected>全部</option>
|
||||
{% for cat in product_categories %}
|
||||
<option value="{{ cat }}">{{ cat }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="rec-sort-key">排序</label>
|
||||
<select id="rec-sort-key">
|
||||
<option value="trend" selected>走势</option>
|
||||
<option value="gap">跳空</option>
|
||||
<option value="volume">成交量</option>
|
||||
<option value="amplitude">振幅</option>
|
||||
</select>
|
||||
<button type="button" class="rec-sort-dir-btn" id="rec-sort-dir" title="切换升序/降序">↓</button>
|
||||
</div>
|
||||
<div class="trade-table-wrap">
|
||||
<table class="trade-table" id="recommend-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品种</th><th>交易所</th><th>行业</th><th>走势</th><th>是否跳空</th>
|
||||
<th>参考价</th><th>昨日收盘</th><th>今日开盘</th>
|
||||
<th>昨日涨跌</th><th>昨日振幅</th><th>成交量(手)</th><th>成交额</th>
|
||||
<th>1手保证金</th><th>1手手续费</th><th>最大手数</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recommend-list">
|
||||
{% if recommend_rows %}
|
||||
{% for r in recommend_rows %}
|
||||
<tr class="rec-{{ r.status }}{% if r.trend_transition %} rec-trend-break{% endif %}">
|
||||
<td><strong class="{% if r.trend_transition %}trend-name{% endif %}">{{ r.name }}</strong> <span class="text-accent">{{ r.main_code or r.ths }}</span></td>
|
||||
<td>{{ r.exchange }}</td>
|
||||
<td>{{ r.category or '—' }}</td>
|
||||
<td>
|
||||
{% if r.trend_label and r.trend_label != '—' %}
|
||||
<span class="badge trend-badge {% if r.trend in ('break_long', 'break_short') %}break{% elif r.trend == 'long' %}profit{% elif r.trend == 'short' %}loss{% else %}planned{% endif %}" title="{% if r.trend_overlap_pct is not none %}近3日重叠 {{ r.trend_overlap_pct }}%{% endif %}">
|
||||
{% if r.trend_transition %}★ {% endif %}{{ r.trend_label }}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.gap_label and r.gap_label != '—' %}
|
||||
<span class="badge gap-badge {% if r.gap == 'up' %}profit{% elif r.gap == 'down' %}loss{% else %}planned{% endif %}"{% if r.gap_pct %} title="跳空 {{ '%+.2f'|format(r.gap_pct) }}%"{% endif %}>{{ r.gap_label }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.prev_close is not none %}{{ r.prev_close }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.today_open is not none %}{{ r.today_open }}{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
{% if r.yesterday_change is not none %}
|
||||
<span class="{% if r.yesterday_change > 0 %}rec-change-up{% elif r.yesterday_change < 0 %}rec-change-down{% endif %}">
|
||||
{{ '%+.4f'|format(r.yesterday_change) }}{% if r.yesterday_change_pct is not none %} ({{ '%+.2f'|format(r.yesterday_change_pct) }}%){% endif %}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{% if r.yesterday_amplitude_pct is not none %}{{ '%.2f'|format(r.yesterday_amplitude_pct) }}%{% else %}—{% endif %}</td>
|
||||
<td>{% if r.volume is not none %}{{ r.volume }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.turnover is not none %}{{ '%.0f'|format(r.turnover) }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% if r.margin_source == 'ctp' %} <span class="text-muted">(柜台)</span>{% endif %}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %}</td>
|
||||
<td><span class="badge {% if r.status=='ok' %}profit{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="16" class="empty-hint">等待今日后台刷新推荐…</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }};
|
||||
window.TRADE_FIXED_LOTS = {{ fixed_lots|tojson }};
|
||||
window.TRADE_FIXED_AMOUNT = {{ fixed_amount|tojson }};
|
||||
window.PRODUCT_CATEGORIES = {{ product_categories | default([]) | tojson }};
|
||||
window.__RECOMMEND_ROWS__ = {{ recommend_rows | default([]) | tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/trade.js') }}?v={{ asset_v }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
+74
-69
@@ -1,69 +1,74 @@
|
||||
"""交易记录:字段补全、资金曲线数据。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
TRADE_LOG_EXTRA_COLUMNS = (
|
||||
"ALTER TABLE trade_logs ADD COLUMN margin_pct REAL",
|
||||
"ALTER TABLE trade_logs ADD COLUMN equity_after REAL",
|
||||
"ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'",
|
||||
"ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT",
|
||||
)
|
||||
|
||||
|
||||
def ensure_trade_log_columns(conn) -> None:
|
||||
for sql in TRADE_LOG_EXTRA_COLUMNS:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def calc_equity_after(capital: float, pnl_net: float) -> float | None:
|
||||
cap = float(capital or 0)
|
||||
if cap <= 0:
|
||||
return None
|
||||
return round(cap + float(pnl_net or 0), 2)
|
||||
|
||||
|
||||
def enrich_trades_for_records(
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
initial_capital: float = 0.0,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""表格仍按 id 降序;资金曲线按平仓时间升序用最新资金绘制。"""
|
||||
rows = [dict(t) for t in trades]
|
||||
chrono = sorted(
|
||||
rows,
|
||||
key=lambda t: ((t.get("close_time") or ""), int(t.get("id") or 0)),
|
||||
)
|
||||
running = float(initial_capital or 0)
|
||||
curve: list[dict[str, Any]] = []
|
||||
|
||||
for t in chrono:
|
||||
pnl_net = float(t.get("pnl_net") or 0)
|
||||
eq = t.get("equity_after")
|
||||
if eq is None:
|
||||
if running > 0:
|
||||
eq = round(running + pnl_net, 2)
|
||||
else:
|
||||
eq = None
|
||||
t["equity_after"] = eq
|
||||
if eq is not None:
|
||||
running = float(eq)
|
||||
|
||||
if t.get("margin_pct") is None:
|
||||
margin = float(t.get("margin") or 0)
|
||||
cap_before = float(eq or 0) - pnl_net if eq is not None else 0.0
|
||||
if margin > 0 and cap_before > 0:
|
||||
t["margin_pct"] = round(margin / cap_before * 100, 2)
|
||||
|
||||
if eq is not None:
|
||||
curve.append({
|
||||
"time": (t.get("close_time") or "")[:19],
|
||||
"value": float(eq),
|
||||
"id": int(t.get("id") or 0),
|
||||
})
|
||||
|
||||
return rows, curve
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""交易记录:字段补全、资金曲线数据。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
TRADE_LOG_EXTRA_COLUMNS = (
|
||||
"ALTER TABLE trade_logs ADD COLUMN margin_pct REAL",
|
||||
"ALTER TABLE trade_logs ADD COLUMN equity_after REAL",
|
||||
"ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'",
|
||||
"ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT",
|
||||
)
|
||||
|
||||
|
||||
def ensure_trade_log_columns(conn) -> None:
|
||||
for sql in TRADE_LOG_EXTRA_COLUMNS:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def calc_equity_after(capital: float, pnl_net: float) -> float | None:
|
||||
cap = float(capital or 0)
|
||||
if cap <= 0:
|
||||
return None
|
||||
return round(cap + float(pnl_net or 0), 2)
|
||||
|
||||
|
||||
def enrich_trades_for_records(
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
initial_capital: float = 0.0,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""表格仍按 id 降序;资金曲线按平仓时间升序用最新资金绘制。"""
|
||||
rows = [dict(t) for t in trades]
|
||||
chrono = sorted(
|
||||
rows,
|
||||
key=lambda t: ((t.get("close_time") or ""), int(t.get("id") or 0)),
|
||||
)
|
||||
running = float(initial_capital or 0)
|
||||
curve: list[dict[str, Any]] = []
|
||||
|
||||
for t in chrono:
|
||||
pnl_net = float(t.get("pnl_net") or 0)
|
||||
eq = t.get("equity_after")
|
||||
if eq is None:
|
||||
if running > 0:
|
||||
eq = round(running + pnl_net, 2)
|
||||
else:
|
||||
eq = None
|
||||
t["equity_after"] = eq
|
||||
if eq is not None:
|
||||
running = float(eq)
|
||||
|
||||
if t.get("margin_pct") is None:
|
||||
margin = float(t.get("margin") or 0)
|
||||
cap_before = float(eq or 0) - pnl_net if eq is not None else 0.0
|
||||
if margin > 0 and cap_before > 0:
|
||||
t["margin_pct"] = round(margin / cap_before * 100, 2)
|
||||
|
||||
if eq is not None:
|
||||
curve.append({
|
||||
"time": (t.get("close_time") or "")[:19],
|
||||
"value": float(eq),
|
||||
"id": int(t.get("id") or 0),
|
||||
})
|
||||
|
||||
return rows, curve
|
||||
|
||||
+95
-90
@@ -1,90 +1,95 @@
|
||||
"""交易上下文:设置读取、资金、模式。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
TRADING_MODE_SIM = "simulation" # SimNow CTP
|
||||
TRADING_MODE_LIVE = "live" # 期货公司 CTP
|
||||
|
||||
|
||||
def get_trading_mode(get_setting: Callable[[str, str], str]) -> str:
|
||||
m = (get_setting("trading_mode", TRADING_MODE_SIM) or TRADING_MODE_SIM).strip().lower()
|
||||
return m if m in (TRADING_MODE_SIM, TRADING_MODE_LIVE) else TRADING_MODE_SIM
|
||||
|
||||
|
||||
def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str:
|
||||
from position_sizing import normalize_sizing_mode
|
||||
return normalize_sizing_mode(get_setting("position_sizing_mode", "fixed"))
|
||||
|
||||
|
||||
def get_fixed_lots(get_setting: Callable[[str, str], str]) -> int:
|
||||
try:
|
||||
return max(1, int(float(get_setting("fixed_lots", "1") or 1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def get_fixed_amount(get_setting: Callable[[str, str], str]) -> float:
|
||||
try:
|
||||
return max(1.0, float(get_setting("fixed_amount", "5000") or 5000))
|
||||
except (TypeError, ValueError):
|
||||
return 5000.0
|
||||
|
||||
|
||||
def get_risk_percent(get_setting: Callable[[str, str], str]) -> float:
|
||||
try:
|
||||
return max(0.1, float(get_setting("risk_percent", "1") or 1))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
|
||||
"""单笔/总仓位保证金占权益上限(%),默认 30。"""
|
||||
try:
|
||||
return max(1.0, min(100.0, float(get_setting("max_margin_pct", "30") or 30)))
|
||||
except (TypeError, ValueError):
|
||||
return 30.0
|
||||
|
||||
|
||||
def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int:
|
||||
"""移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。"""
|
||||
try:
|
||||
return max(1, min(20, int(float(get_setting("trailing_be_tick_buffer", "2") or 2))))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
|
||||
def get_pending_order_timeout_min(get_setting: Callable[[str, str], str]) -> int:
|
||||
"""开仓限价委托未成交自动撤单时间(分钟),默认 5。"""
|
||||
try:
|
||||
return max(1, min(60, int(float(get_setting("pending_order_timeout_min", "5") or 5))))
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
|
||||
|
||||
def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int:
|
||||
return get_pending_order_timeout_min(get_setting) * 60
|
||||
|
||||
|
||||
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
|
||||
"""优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。"""
|
||||
del conn
|
||||
mode = get_trading_mode(get_setting)
|
||||
try:
|
||||
from vnpy_bridge import ctp_status, get_ctp_balance
|
||||
|
||||
st = ctp_status(mode)
|
||||
if st.get("connected"):
|
||||
bal = get_ctp_balance(mode)
|
||||
if bal and bal > 0:
|
||||
return float(bal)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return float(get_setting("live_capital", "0") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def trading_mode_label(get_setting: Callable[[str, str], str]) -> str:
|
||||
return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘"
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""交易上下文:设置读取、资金、模式。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
TRADING_MODE_SIM = "simulation" # SimNow CTP
|
||||
TRADING_MODE_LIVE = "live" # 期货公司 CTP
|
||||
|
||||
|
||||
def get_trading_mode(get_setting: Callable[[str, str], str]) -> str:
|
||||
m = (get_setting("trading_mode", TRADING_MODE_SIM) or TRADING_MODE_SIM).strip().lower()
|
||||
return m if m in (TRADING_MODE_SIM, TRADING_MODE_LIVE) else TRADING_MODE_SIM
|
||||
|
||||
|
||||
def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str:
|
||||
from position_sizing import normalize_sizing_mode
|
||||
return normalize_sizing_mode(get_setting("position_sizing_mode", "fixed"))
|
||||
|
||||
|
||||
def get_fixed_lots(get_setting: Callable[[str, str], str]) -> int:
|
||||
try:
|
||||
return max(1, int(float(get_setting("fixed_lots", "1") or 1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def get_fixed_amount(get_setting: Callable[[str, str], str]) -> float:
|
||||
try:
|
||||
return max(1.0, float(get_setting("fixed_amount", "5000") or 5000))
|
||||
except (TypeError, ValueError):
|
||||
return 5000.0
|
||||
|
||||
|
||||
def get_risk_percent(get_setting: Callable[[str, str], str]) -> float:
|
||||
try:
|
||||
return max(0.1, float(get_setting("risk_percent", "1") or 1))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
|
||||
"""单笔/总仓位保证金占权益上限(%),默认 30。"""
|
||||
try:
|
||||
return max(1.0, min(100.0, float(get_setting("max_margin_pct", "30") or 30)))
|
||||
except (TypeError, ValueError):
|
||||
return 30.0
|
||||
|
||||
|
||||
def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int:
|
||||
"""移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。"""
|
||||
try:
|
||||
return max(1, min(20, int(float(get_setting("trailing_be_tick_buffer", "2") or 2))))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
|
||||
def get_pending_order_timeout_min(get_setting: Callable[[str, str], str]) -> int:
|
||||
"""开仓限价委托未成交自动撤单时间(分钟),默认 5。"""
|
||||
try:
|
||||
return max(1, min(60, int(float(get_setting("pending_order_timeout_min", "5") or 5))))
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
|
||||
|
||||
def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int:
|
||||
return get_pending_order_timeout_min(get_setting) * 60
|
||||
|
||||
|
||||
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
|
||||
"""优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。"""
|
||||
del conn
|
||||
mode = get_trading_mode(get_setting)
|
||||
try:
|
||||
from vnpy_bridge import ctp_status, get_ctp_balance
|
||||
|
||||
st = ctp_status(mode)
|
||||
if st.get("connected"):
|
||||
bal = get_ctp_balance(mode)
|
||||
if bal and bal > 0:
|
||||
return float(bal)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return float(get_setting("live_capital", "0") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def trading_mode_label(get_setting: Callable[[str, str], str]) -> str:
|
||||
return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘"
|
||||
|
||||
+1471
-1466
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user