Add personal license agreement and rename product section to tradable symbols.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 02:52:45 +08:00
parent 7b60f0dce5
commit ab9987e4c7
85 changed files with 18772 additions and 18235 deletions
+42
View File
@@ -0,0 +1,42 @@
国内期货交易监控复盘系统 — 软件使用许可与版权声明
著作权人:马建军
Copyright (c) 2025-2026 马建军. All rights reserved.
【权利声明】
本软件(含源代码、文档、界面、脚本及后续更新版本)之著作权及相关知识产权,
均归马建军所有。除本许可明确允许的范围外,保留一切权利。
【授权范围 — 个人版】
经著作权人书面或付费交付同意的自然人购买者,仅可在本人名下单一服务器或
个人设备上部署并使用本软件,用于个人期货交易纪律管理、记录与复盘,且须
遵守中华人民共和国相关法律法规及期货监管规定。
【严禁用途】
未经著作权人事先书面许可,严禁将本软件用于包括但不限于以下用途:
(1)带单、代客理财、代客下单、跟单室、信号群、付费喊单、向他人推荐具体
期货买卖方向或具体合约;
(2)向他人推荐、介绍、引导参与特定期货品种或交易机会(若构成投资咨询或
其他需许可之业务,使用者依法另行承担法律责任);
(3)融资、配资、分仓、分润、对赌、非法吸收资金等与期货相关的资金融通
或变相配资业务;
(4)复制、传播、转售、出租、出借源代码或编译产物,或授权第三方使用;
(5)搭建共享交易室、多租户 SaaS、白标系统对外经营(须另行签订机构版协议);
(6)删除、篡改或隐藏本版权及许可声明。
【免责声明】
本软件为交易纪律与记录辅助工具,不构成任何投资建议、咨询或收益承诺。
期货交易具有高风险,使用者须独立决策并自行承担全部盈亏及法律责任。
因使用者违反法律法规、监管规定或本许可导致的后果,由使用者自行承担。
【更新与维护】
源代码更新、部署服务及共享交易室等机构授权,以双方另行书面约定为准。
未经约定,不视为自动授予新版本或扩展用途之权利。
【联系】
著作权人:马建军
手机:18364911125
微信:dekun03
详细购买条款见 docs/软件购买与使用协议.md。
本许可之解释与适用以中华人民共和国法律为准(法律强制性规定除外)。
+12 -5
View File
@@ -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
+1856 -1851
View File
File diff suppressed because it is too large Load Diff
+280 -275
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, SHFESR609 → 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, SHFESR609 → 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -156,7 +156,7 @@ pm2 restart qihuo
4. 点击 **连接 CTP**
5. 顶栏显示 **CTP 已连接**,权益变为 SimNow 账户资金即成功
连接成功后:下单、持仓、浮盈均来自 SimNow 柜台;**系统设置里的「参考资金」不再用于交易**,仅 CTP 未连接时用于品种推荐与以损定仓估算。
连接成功后:下单、持仓、浮盈均来自 SimNow 柜台;**系统设置里的「参考资金」不再用于交易**,仅 CTP 未连接时用于可开仓品种筛选与以损定仓估算。
---
+8 -7
View File
@@ -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 故障排查。
+185
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+562 -557
View File
File diff suppressed because it is too large Load Diff
+175 -170
View File
@@ -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
View File
@@ -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
View File
@@ -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(
"未找到可用 localevnpy_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(
"未找到可用 localevnpy_ctp 会在 CTP 登录后崩溃。"
"请执行: apt install -y locales && locale-gen zh_CN.GB18030 en_US.UTF-8"
) from last_err
+248 -243
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ''}")
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
+307 -302
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+549 -548
View File
File diff suppressed because it is too large Load Diff
+205 -204
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+57 -53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
})();
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+46 -42
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
+23 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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
+75 -70
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 %}报价 CTPK 线历史新浪补齐、最新 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 %}报价 CTPK 线历史新浪补齐、最新 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 %}>SimNowvnpy 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 %}>SimNowvnpy 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
View File
@@ -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
View File
@@ -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 %}
+23 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff