接入 SimNow 模拟盘与期货下单、策略及品种推荐功能。
新增 vnpy CTP 桥接、以损定仓/固定张数、趋势回调与滚仓策略、按资金推荐品种及交易风控;模拟盘走 SimNow,实盘预留期货公司配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+33
-6
@@ -3,19 +3,46 @@ HOST=0.0.0.0
|
||||
PORT=6600
|
||||
DEBUG=false
|
||||
|
||||
# Flask Session 密钥(部署时务必改为随机字符串,deploy.sh 首次会自动生成)
|
||||
SECRET_KEY=change-this-to-a-random-secret-key
|
||||
|
||||
# 初始管理员(首次建库自动写入;已建库后修改需设 ADMIN_SYNC_FROM_ENV=true 并重启)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-on-first-login
|
||||
ADMIN_SYNC_FROM_ENV=false
|
||||
|
||||
# 企业微信 Webhook(也可在系统设置页面修改)
|
||||
WECHAT_WEBHOOK=
|
||||
|
||||
# 行情数据源: sina(默认,免费)| auto(有机构 token 时优先同花顺)| ths
|
||||
QUOTE_SOURCE=sina
|
||||
|
||||
# 同花顺 iFinD refresh_token(仅机构用户,普通用户留空即可)
|
||||
THS_REFRESH_TOKEN=
|
||||
|
||||
# 交易模式:simulation=SimNow,live=期货公司(系统设置页可改)
|
||||
TRADING_MODE=simulation
|
||||
POSITION_SIZING_MODE=risk
|
||||
RISK_PERCENT=1
|
||||
|
||||
# —— SimNow 模拟盘(在 simnow.com 注册后填写)——
|
||||
SIMNOW_USER=
|
||||
SIMNOW_PASSWORD=
|
||||
SIMNOW_BROKER_ID=9999
|
||||
# 7×24 环境示例(以 SimNow 官网最新为准)
|
||||
SIMNOW_TD_ADDRESS=tcp://180.168.146.187:10201
|
||||
SIMNOW_MD_ADDRESS=tcp://180.168.146.187:10211
|
||||
SIMNOW_APP_ID=simnow_client_test
|
||||
SIMNOW_AUTH_CODE=0000000000000000
|
||||
SIMNOW_PRODUCT_INFO=simnow_client_test
|
||||
|
||||
# —— 期货公司实盘(后期接入)——
|
||||
CTP_LIVE_USER=
|
||||
CTP_LIVE_PASSWORD=
|
||||
CTP_LIVE_BROKER_ID=
|
||||
CTP_LIVE_TD_ADDRESS=
|
||||
CTP_LIVE_MD_ADDRESS=
|
||||
CTP_LIVE_APP_ID=
|
||||
CTP_LIVE_AUTH_CODE=
|
||||
CTP_LIVE_PRODUCT_INFO=
|
||||
|
||||
# 账户冷静期
|
||||
RISK_CONTROL_ENABLED=true
|
||||
RISK_COOLING_HOURS_MANUAL=4
|
||||
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||
MAX_ACTIVE_POSITIONS=1
|
||||
|
||||
@@ -34,6 +34,9 @@ from kline_store import ensure_kline_tables
|
||||
from kline_stream import kline_hub, sse_format
|
||||
from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS
|
||||
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
|
||||
from strategy.strategy_db import init_strategy_tables
|
||||
from install_trading import install_trading
|
||||
from vnpy_bridge import try_init_vnpy
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
|
||||
@@ -306,6 +309,7 @@ def init_db():
|
||||
data_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL)''')
|
||||
ensure_kline_tables(conn)
|
||||
init_strategy_tables(conn)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -322,6 +326,12 @@ def init_db():
|
||||
|
||||
if not get_setting("fee_multiplier"):
|
||||
set_setting("fee_multiplier", "2")
|
||||
if not get_setting("trading_mode"):
|
||||
set_setting("trading_mode", "simulation")
|
||||
if not get_setting("position_sizing_mode"):
|
||||
set_setting("position_sizing_mode", "risk")
|
||||
if not get_setting("risk_percent"):
|
||||
set_setting("risk_percent", "1")
|
||||
conn = get_db()
|
||||
fee_cnt = conn.execute("SELECT COUNT(*) FROM fee_rates").fetchone()[0]
|
||||
conn.close()
|
||||
@@ -569,6 +579,9 @@ def background_task():
|
||||
expire_old_plans()
|
||||
check_key_monitors()
|
||||
check_order_plans()
|
||||
fn = getattr(app, "_check_trend_plans", None)
|
||||
if fn:
|
||||
fn(app)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3)
|
||||
@@ -583,8 +596,6 @@ def start_background_threads():
|
||||
threading.Thread(target=refresh_main_index, daemon=True).start()
|
||||
|
||||
|
||||
start_background_threads()
|
||||
|
||||
# —————————————— 登录 ——————————————
|
||||
|
||||
def login_required(f):
|
||||
@@ -1278,6 +1289,14 @@ def add_review():
|
||||
d.get("notes", "").strip(),
|
||||
),
|
||||
)
|
||||
hook = getattr(app, "_risk_review_hook", None)
|
||||
if hook:
|
||||
hook(
|
||||
conn,
|
||||
",".join(tags),
|
||||
exit_trigger,
|
||||
d.get("exit_supplement", "").strip(),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
touch_stats_cache()
|
||||
@@ -1535,9 +1554,25 @@ def settings():
|
||||
flash("实盘资金不能为负数")
|
||||
else:
|
||||
set_setting("live_capital", str(val))
|
||||
flash("实盘资金已保存")
|
||||
flash("参考资金已保存(CTP 已连接时以 SimNow/柜台权益为准)")
|
||||
except ValueError:
|
||||
flash("请输入有效的实盘资金金额")
|
||||
elif action == "trading":
|
||||
mode = request.form.get("trading_mode", "simulation").strip()
|
||||
if mode not in ("simulation", "live"):
|
||||
mode = "simulation"
|
||||
sizing = request.form.get("position_sizing_mode", "risk").strip()
|
||||
if sizing not in ("fixed", "risk"):
|
||||
sizing = "risk"
|
||||
set_setting("trading_mode", mode)
|
||||
set_setting("position_sizing_mode", sizing)
|
||||
try:
|
||||
rp = float(request.form.get("risk_percent", "1") or 1)
|
||||
set_setting("risk_percent", str(max(0.1, min(100.0, rp))))
|
||||
except ValueError:
|
||||
flash("风险比例无效")
|
||||
return redirect(url_for("settings"))
|
||||
flash("交易模式已保存")
|
||||
elif action == "password":
|
||||
old_p = request.form.get("old_password", "")
|
||||
new_p = request.form.get("new_password", "")
|
||||
@@ -1563,8 +1598,26 @@ def settings():
|
||||
username=username,
|
||||
live_capital=live_capital,
|
||||
quote_label=get_quote_source_label(),
|
||||
trading_mode=get_setting("trading_mode", "simulation"),
|
||||
position_sizing_mode=get_setting("position_sizing_mode", "risk"),
|
||||
risk_percent=get_setting("risk_percent", "1"),
|
||||
)
|
||||
|
||||
|
||||
install_trading(
|
||||
app,
|
||||
login_required=login_required,
|
||||
get_db=get_db,
|
||||
get_setting=get_setting,
|
||||
set_setting=set_setting,
|
||||
fetch_price=fetch_price,
|
||||
send_wechat_msg=send_wechat_msg,
|
||||
)
|
||||
|
||||
try_init_vnpy({})
|
||||
|
||||
start_background_threads()
|
||||
|
||||
# —————————————— 启动 ——————————————
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+17
-10
@@ -2,13 +2,13 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10}
|
||||
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},
|
||||
"au": {"mult": 1000, "margin_rate": 0.10},
|
||||
"cu": {"mult": 5, "margin_rate": 0.10},
|
||||
"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},
|
||||
@@ -52,10 +52,14 @@ _SPEC_BY_THS: dict[str, dict] = {
|
||||
"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},
|
||||
"IH": {"mult": 300, "margin_rate": 0.12},
|
||||
"IC": {"mult": 200, "margin_rate": 0.12},
|
||||
"IM": {"mult": 200, "margin_rate": 0.12},
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +71,10 @@ def get_contract_spec(ths_code: str) -> dict:
|
||||
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:
|
||||
return {"mult": spec["mult"], "margin_rate": spec["margin_rate"]}
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""同花顺合约代码 → vnpy Symbol + Exchange。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from symbols import ths_to_codes
|
||||
|
||||
try:
|
||||
from vnpy.trader.constant import Exchange
|
||||
except ImportError:
|
||||
Exchange = None # type: ignore
|
||||
|
||||
_EX_MAP = {
|
||||
"SHFE": "SHFE",
|
||||
"DCE": "DCE",
|
||||
"CZCE": "CZCE",
|
||||
"CFFEX": "CFFEX",
|
||||
"INE": "INE",
|
||||
}
|
||||
|
||||
|
||||
def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]:
|
||||
"""
|
||||
返回 (symbol, exchange_enum_name)。
|
||||
例:rb2610 → rb2610, SHFE;SR609 → SR609, CZCE
|
||||
"""
|
||||
code = (ths_code or "").strip()
|
||||
codes = ths_to_codes(code)
|
||||
ex = (codes.get("ex") if codes else None) or "SHFE"
|
||||
ex = _EX_MAP.get(ex, "SHFE")
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
|
||||
if not m:
|
||||
return code, ex
|
||||
letters, digits = m.group(1), m.group(2)
|
||||
if ex == "CZCE":
|
||||
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
|
||||
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
|
||||
else:
|
||||
sym = letters.lower() + digits
|
||||
return sym, ex
|
||||
|
||||
|
||||
def to_vnpy_exchange(ex_name: str):
|
||||
if Exchange is None:
|
||||
raise ImportError("vnpy 未安装")
|
||||
mapping = {
|
||||
"SHFE": Exchange.SHFE,
|
||||
"DCE": Exchange.DCE,
|
||||
"CZCE": Exchange.CZCE,
|
||||
"CFFEX": Exchange.CFFEX,
|
||||
"INE": Exchange.INE,
|
||||
}
|
||||
ex = mapping.get((ex_name or "").upper())
|
||||
if ex is None:
|
||||
raise ValueError(f"未知交易所: {ex_name}")
|
||||
return ex
|
||||
@@ -0,0 +1,40 @@
|
||||
# 期货下单与策略交易
|
||||
|
||||
## 两种交易通道
|
||||
|
||||
| 设置 | 实际连接 | 资金 |
|
||||
|------|----------|------|
|
||||
| **模拟盘** | **SimNow**(vnpy → CTP 仿真前置) | SimNow 账户权益 |
|
||||
| **实盘** | **期货公司 CTP**(后期配置 `CTP_LIVE_*`) | 柜台权益 |
|
||||
|
||||
已移除「本地 SQLite 假撮合」;模拟盘与实盘均走 **vnpy_ctp**,仅 `.env` 前置与账号不同。
|
||||
|
||||
## 首次使用 SimNow
|
||||
|
||||
1. 在 [SimNow](https://www.simnow.com.cn/) 注册仿真账号
|
||||
2. 复制 `.env.example` → `.env`,填写 `SIMNOW_USER`、`SIMNOW_PASSWORD`
|
||||
3. 核对 SimNow 官网最新的 **7×24 或交易时段** 前置地址
|
||||
4. `pip install vnpy vnpy_ctp`
|
||||
5. 启动程序 → **期货下单** → 点击 **连接 CTP**
|
||||
6. 连接成功后,权益、持仓、下单均来自 SimNow
|
||||
|
||||
## 参考资金
|
||||
|
||||
系统设置中的「参考资金」仅在 **CTP 未连接** 时用于品种推荐与以损定仓估算;连接 SimNow 后自动改用柜台权益。
|
||||
|
||||
## 导航
|
||||
|
||||
| 页面 | 路径 |
|
||||
|------|------|
|
||||
| 品种推荐 | `/recommend` |
|
||||
| 期货下单 | `/trade` |
|
||||
| 策略交易 | `/strategy` |
|
||||
| 策略记录 | `/strategy/records` |
|
||||
|
||||
## API
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `POST /api/ctp/connect` | 按当前模式连接 SimNow 或实盘 CTP |
|
||||
| `GET /api/ctp/status` | 连接状态与缺失配置项 |
|
||||
| `POST /api/trade/order` | 限价报单(需已连接 CTP) |
|
||||
@@ -0,0 +1,690 @@
|
||||
"""期货下单、品种推荐、策略交易路由注册。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import flash, jsonify, redirect, render_template, request, url_for
|
||||
|
||||
from contract_specs import calc_position_metrics, get_contract_spec
|
||||
from position_sizing import (
|
||||
MODE_FIXED,
|
||||
MODE_RISK,
|
||||
calc_lots_by_risk,
|
||||
calc_order_tick_metrics,
|
||||
normalize_sizing_mode,
|
||||
)
|
||||
from product_recommend import list_product_recommendations
|
||||
from risk.account_risk_lib import (
|
||||
assert_can_open,
|
||||
get_risk_status,
|
||||
on_mood_journal_freeze,
|
||||
on_user_initiated_close,
|
||||
parse_mood_issues,
|
||||
reduce_cooloff_after_journal,
|
||||
trading_day_label,
|
||||
)
|
||||
from strategy.strategy_db import init_strategy_tables
|
||||
from strategy.strategy_roll_lib import preview_roll
|
||||
from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot
|
||||
from strategy.strategy_trend_lib import compute_trend_plan_futures, trend_dca_level_reached
|
||||
from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND
|
||||
from symbols import ths_to_codes, resolve_main_contract, PRODUCTS
|
||||
from trading_context import (
|
||||
TRADING_MODE_LIVE,
|
||||
TRADING_MODE_SIM,
|
||||
get_account_capital,
|
||||
get_risk_percent,
|
||||
get_sizing_mode,
|
||||
get_trading_mode,
|
||||
trading_mode_label,
|
||||
)
|
||||
from ctp_symbol import ths_to_vnpy_symbol
|
||||
from vnpy_bridge import (
|
||||
ctp_connect,
|
||||
ctp_get_account,
|
||||
ctp_list_positions,
|
||||
ctp_status,
|
||||
execute_order,
|
||||
)
|
||||
|
||||
|
||||
def install_trading(app, *, login_required, get_db, get_setting, set_setting, fetch_price, send_wechat_msg):
|
||||
"""注册交易相关路由。"""
|
||||
|
||||
def _settings_dict() -> dict:
|
||||
return {
|
||||
"trading_mode": get_trading_mode(get_setting),
|
||||
"position_sizing_mode": get_sizing_mode(get_setting),
|
||||
"risk_percent": str(get_risk_percent(get_setting)),
|
||||
}
|
||||
|
||||
def _capital(conn) -> float:
|
||||
return get_account_capital(conn, get_setting)
|
||||
|
||||
def _main_price(product_ths: str):
|
||||
for p in PRODUCTS:
|
||||
if p["ths"] == product_ths:
|
||||
main = resolve_main_contract(p)
|
||||
if not main:
|
||||
return None
|
||||
sym = main.get("ths_code") or ""
|
||||
codes = ths_to_codes(sym)
|
||||
if codes:
|
||||
return fetch_price(sym, codes.get("market_code", ""), codes.get("sina_code", ""))
|
||||
return None
|
||||
|
||||
def _ctp_account(mode: str) -> dict:
|
||||
try:
|
||||
return ctp_get_account(mode)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _ctp_positions(mode: str) -> list:
|
||||
try:
|
||||
return ctp_list_positions(mode)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _match_ctp_symbol(ctp_sym: str, ths: str) -> bool:
|
||||
a = (ctp_sym or "").lower()
|
||||
b = (ths or "").lower()
|
||||
if a == b:
|
||||
return True
|
||||
try:
|
||||
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
|
||||
return a == vnpy_sym.lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@app.route("/trade")
|
||||
@login_required
|
||||
def trade_page():
|
||||
conn = get_db()
|
||||
init_strategy_tables(conn)
|
||||
mode = get_trading_mode(get_setting)
|
||||
ctp_st = ctp_status(mode)
|
||||
capital = _capital(conn)
|
||||
sizing = get_sizing_mode(get_setting)
|
||||
risk = get_risk_status(conn)
|
||||
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
|
||||
positions = _ctp_positions(mode) if ctp_st.get("connected") else []
|
||||
conn.close()
|
||||
return render_template(
|
||||
"trade.html",
|
||||
trading_mode=mode,
|
||||
trading_mode_label=trading_mode_label(get_setting),
|
||||
sizing_mode=sizing,
|
||||
risk_percent=get_risk_percent(get_setting),
|
||||
capital=capital,
|
||||
risk_status=risk,
|
||||
ctp_status=ctp_st,
|
||||
ctp_account=ctp_acc,
|
||||
ctp_positions=positions,
|
||||
)
|
||||
|
||||
@app.route("/recommend")
|
||||
@login_required
|
||||
def recommend_page():
|
||||
conn = get_db()
|
||||
capital = _capital(conn)
|
||||
conn.close()
|
||||
rows = list_product_recommendations(capital, _main_price)
|
||||
return render_template("recommend.html", capital=capital, rows=rows, trading_mode_label=trading_mode_label(get_setting))
|
||||
|
||||
@app.route("/strategy")
|
||||
@login_required
|
||||
def strategy_page():
|
||||
conn = get_db()
|
||||
init_strategy_tables(conn)
|
||||
capital = _capital(conn)
|
||||
active_trend = conn.execute(
|
||||
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
monitors = conn.execute(
|
||||
"SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
roll_groups = conn.execute(
|
||||
"SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return render_template(
|
||||
"strategy.html",
|
||||
capital=capital,
|
||||
risk_percent=get_risk_percent(get_setting),
|
||||
sizing_mode=get_sizing_mode(get_setting),
|
||||
active_trend=dict(active_trend) if active_trend else None,
|
||||
monitors=[dict(m) for m in monitors],
|
||||
roll_groups=[dict(g) for g in roll_groups],
|
||||
)
|
||||
|
||||
@app.route("/strategy/records")
|
||||
@login_required
|
||||
def strategy_records_page():
|
||||
conn = get_db()
|
||||
init_strategy_tables(conn)
|
||||
trend, roll = list_snapshots(conn)
|
||||
conn.close()
|
||||
return render_template("strategy_records.html", trend_rows=trend, roll_rows=roll)
|
||||
|
||||
@app.route("/api/trade/quote")
|
||||
@login_required
|
||||
def api_trade_quote():
|
||||
sym = (request.args.get("symbol") or "").strip()
|
||||
lots = request.args.get("lots") or "1"
|
||||
if not sym:
|
||||
return jsonify({"ok": False, "error": "缺少品种"}), 400
|
||||
codes = ths_to_codes(sym)
|
||||
price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "")
|
||||
try:
|
||||
lots_f = max(1, int(float(lots)))
|
||||
except (TypeError, ValueError):
|
||||
lots_f = 1
|
||||
metrics = calc_order_tick_metrics(sym, lots_f, price)
|
||||
spec = get_contract_spec(sym)
|
||||
name = codes.get("name", sym) if codes else sym
|
||||
pos_long = pos_short = 0
|
||||
mode = get_trading_mode(get_setting)
|
||||
ctp_st = ctp_status(mode)
|
||||
if ctp_st.get("connected"):
|
||||
for p in _ctp_positions(mode):
|
||||
if not _match_ctp_symbol(p.get("symbol", ""), sym):
|
||||
continue
|
||||
if p["direction"] == "long":
|
||||
pos_long = int(p["lots"])
|
||||
else:
|
||||
pos_short = int(p["lots"])
|
||||
max_open = int(_capital(get_db()) / (metrics["margin_per_lot"] or 1)) if metrics.get("margin_per_lot") else 0
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"symbol": sym,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"lots": lots_f,
|
||||
"metrics": metrics,
|
||||
"exchange": codes.get("exchange", "") if codes else "",
|
||||
"pos_long": pos_long,
|
||||
"pos_short": pos_short,
|
||||
"max_open_long": max_open,
|
||||
"max_open_short": max_open,
|
||||
"footer_text": (
|
||||
f"*{name} 每手{spec['mult']}吨/点 最小变动{metrics['tick_size']} "
|
||||
f"每跳{metrics['tick_value_per_lot']}元/手×{lots_f}={metrics['tick_value_total']}元 "
|
||||
f"精度{metrics['price_precision']}位小数"
|
||||
),
|
||||
})
|
||||
|
||||
@app.route("/api/trade/preview", methods=["POST"])
|
||||
@login_required
|
||||
def api_trade_preview():
|
||||
d = request.get_json(silent=True) or {}
|
||||
sym = (d.get("symbol") or "").strip()
|
||||
direction = (d.get("direction") or "long").strip().lower()
|
||||
try:
|
||||
entry = float(d.get("entry") or d.get("price") or 0)
|
||||
sl = float(d.get("stop_loss") or 0)
|
||||
tp = float(d.get("take_profit") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "error": "价格参数无效"}), 400
|
||||
conn = get_db()
|
||||
capital = _capital(conn)
|
||||
conn.close()
|
||||
sizing = get_sizing_mode(get_setting)
|
||||
if sizing == MODE_RISK:
|
||||
lots, err = calc_lots_by_risk(entry, sl, direction, capital, get_risk_percent(get_setting), sym)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
else:
|
||||
try:
|
||||
lots = max(1, int(d.get("lots") or 1))
|
||||
except (TypeError, ValueError):
|
||||
lots = 1
|
||||
metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym)
|
||||
tick = calc_order_tick_metrics(sym, lots, entry)
|
||||
return jsonify({"ok": True, "lots": lots, "sizing_mode": sizing, "metrics": metrics, "tick": tick, "capital": capital})
|
||||
|
||||
@app.route("/api/trade/order", methods=["POST"])
|
||||
@login_required
|
||||
def api_trade_order():
|
||||
d = request.get_json(silent=True) or {}
|
||||
sym = (d.get("symbol") or "").strip()
|
||||
offset = (d.get("offset") or "open").strip().lower()
|
||||
direction = (d.get("direction") or "long").strip().lower()
|
||||
try:
|
||||
lots = max(1, int(d.get("lots") or 1))
|
||||
price = float(d.get("price") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "error": "手数或价格无效"}), 400
|
||||
if not sym or price <= 0:
|
||||
return jsonify({"ok": False, "error": "品种或价格无效"}), 400
|
||||
conn = get_db()
|
||||
init_strategy_tables(conn)
|
||||
if offset.startswith("open"):
|
||||
err = assert_can_open(conn)
|
||||
if err:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": err}), 403
|
||||
mode = get_trading_mode(get_setting)
|
||||
sizing = get_sizing_mode(get_setting)
|
||||
if offset.startswith("open") and sizing == MODE_RISK:
|
||||
sl = float(d.get("stop_loss") or 0)
|
||||
if sl <= 0:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": "以损定仓模式须填写止损价"}), 400
|
||||
lots_calc, err = calc_lots_by_risk(price, sl, direction, _capital(conn), get_risk_percent(get_setting), sym)
|
||||
if err:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
lots = lots_calc or lots
|
||||
try:
|
||||
result = execute_order(
|
||||
conn,
|
||||
mode=mode,
|
||||
offset=offset,
|
||||
symbol=sym,
|
||||
direction=direction,
|
||||
lots=lots,
|
||||
price=price,
|
||||
settings=_settings_dict(),
|
||||
)
|
||||
if offset.startswith("open"):
|
||||
sl = d.get("stop_loss")
|
||||
tp = d.get("take_profit")
|
||||
codes = ths_to_codes(sym)
|
||||
conn.execute(
|
||||
"""INSERT INTO trade_order_monitors (
|
||||
symbol, symbol_name, market_code, direction, lots, entry_price,
|
||||
stop_loss, take_profit, open_time, monitor_type, status
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""",
|
||||
(
|
||||
sym,
|
||||
codes.get("name", sym) if codes else sym,
|
||||
codes.get("market_code", "") if codes else "",
|
||||
direction,
|
||||
lots,
|
||||
price,
|
||||
float(sl) if sl else None,
|
||||
float(tp) if tp else None,
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"manual",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}")
|
||||
conn.close()
|
||||
return jsonify({"ok": True, "result": result, "lots": lots})
|
||||
except ValueError as exc:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": str(exc)}), 500
|
||||
|
||||
@app.route("/api/ctp/connect", methods=["POST"])
|
||||
@login_required
|
||||
def api_ctp_connect():
|
||||
mode = get_trading_mode(get_setting)
|
||||
force = bool((request.get_json(silent=True) or {}).get("force"))
|
||||
try:
|
||||
st = ctp_connect(mode, force=force)
|
||||
acc = _ctp_account(mode)
|
||||
return jsonify({"ok": True, "status": st, "account": acc})
|
||||
except Exception as exc:
|
||||
st = ctp_status(mode)
|
||||
return jsonify({"ok": False, "error": str(exc), "status": st}), 400
|
||||
|
||||
@app.route("/api/ctp/status")
|
||||
@login_required
|
||||
def api_ctp_status():
|
||||
mode = get_trading_mode(get_setting)
|
||||
st = ctp_status(mode)
|
||||
acc = _ctp_account(mode) if st.get("connected") else {}
|
||||
return jsonify({"ok": True, "status": st, "account": acc})
|
||||
|
||||
@app.route("/api/account_snapshot")
|
||||
@login_required
|
||||
def api_account_snapshot():
|
||||
conn = get_db()
|
||||
init_strategy_tables(conn)
|
||||
mode = get_trading_mode(get_setting)
|
||||
ctp_st = ctp_status(mode)
|
||||
capital = _capital(conn)
|
||||
risk = get_risk_status(conn)
|
||||
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
|
||||
positions = _ctp_positions(mode) if ctp_st.get("connected") else []
|
||||
conn.close()
|
||||
return jsonify({
|
||||
"capital": capital,
|
||||
"trading_mode": mode,
|
||||
"trading_mode_label": trading_mode_label(get_setting),
|
||||
"sizing_mode": get_sizing_mode(get_setting),
|
||||
"risk_status": risk,
|
||||
"ctp_status": ctp_st,
|
||||
"ctp_account": ctp_acc,
|
||||
"positions": positions,
|
||||
})
|
||||
|
||||
@app.route("/api/recommend/list")
|
||||
@login_required
|
||||
def api_recommend_list():
|
||||
conn = get_db()
|
||||
capital = _capital(conn)
|
||||
conn.close()
|
||||
return jsonify({"ok": True, "capital": capital, "rows": list_product_recommendations(capital, _main_price)})
|
||||
|
||||
@app.route("/api/strategy/trend/preview", methods=["POST"])
|
||||
@login_required
|
||||
def api_trend_preview():
|
||||
d = request.get_json(silent=True) or {}
|
||||
sym = (d.get("symbol") or "").strip()
|
||||
conn = get_db()
|
||||
if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone():
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": "已有运行中趋势计划"}), 400
|
||||
capital = _capital(conn)
|
||||
codes = ths_to_codes(sym)
|
||||
price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "")
|
||||
conn.close()
|
||||
if not price:
|
||||
return jsonify({"ok": False, "error": "无法获取现价"}), 400
|
||||
plan, err = compute_trend_plan_futures(
|
||||
direction=d.get("direction") or "long",
|
||||
stop_loss=float(d.get("stop_loss") or 0),
|
||||
add_upper=float(d.get("add_upper") or 0),
|
||||
take_profit=float(d.get("take_profit") or 0),
|
||||
risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)),
|
||||
capital=capital,
|
||||
live_price=price,
|
||||
ths_code=sym,
|
||||
dca_legs=int(d.get("dca_legs") or 5),
|
||||
)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
return jsonify({"ok": True, "plan": plan})
|
||||
|
||||
@app.route("/api/strategy/trend/execute", methods=["POST"])
|
||||
@login_required
|
||||
def api_trend_execute():
|
||||
d = request.get_json(silent=True) or {}
|
||||
sym = (d.get("symbol") or "").strip()
|
||||
conn = get_db()
|
||||
init_strategy_tables(conn)
|
||||
err = assert_can_open(conn)
|
||||
if err:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": err}), 403
|
||||
capital = _capital(conn)
|
||||
codes = ths_to_codes(sym)
|
||||
price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "")
|
||||
plan, perr = compute_trend_plan_futures(
|
||||
direction=d.get("direction") or "long",
|
||||
stop_loss=float(d.get("stop_loss") or 0),
|
||||
add_upper=float(d.get("add_upper") or 0),
|
||||
take_profit=float(d.get("take_profit") or 0),
|
||||
risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)),
|
||||
capital=capital,
|
||||
live_price=price or float(d.get("live_price") or 0),
|
||||
ths_code=sym,
|
||||
)
|
||||
if perr:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": perr}), 400
|
||||
mode = get_trading_mode(get_setting)
|
||||
try:
|
||||
execute_order(
|
||||
conn, mode=mode, offset="open", symbol=sym,
|
||||
direction=plan["direction"], lots=plan["first_lots"], price=price, settings=_settings_dict(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO trend_pullback_plans (
|
||||
status, symbol, symbol_name, direction, stop_loss, add_upper, take_profit,
|
||||
risk_percent, capital_snapshot, plan_margin, target_lots, first_lots, remainder_lots,
|
||||
dca_legs, leg_amounts_json, grid_prices_json, first_order_done, avg_entry_price,
|
||||
lots_open, opened_at
|
||||
) VALUES ('active',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)""",
|
||||
(
|
||||
sym, codes.get("name", sym) if codes else sym, plan["direction"],
|
||||
plan["stop_loss"], plan["add_upper"], plan["take_profit"],
|
||||
plan["risk_percent"], plan["capital_snapshot"], plan["plan_margin"],
|
||||
plan["target_lots"], plan["first_lots"], plan["remainder_lots"],
|
||||
plan["dca_legs"], plan["leg_amounts_json"], plan["grid_prices_json"],
|
||||
price, plan["first_lots"], now,
|
||||
),
|
||||
)
|
||||
plan_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}手")
|
||||
return jsonify({"ok": True, "plan_id": plan_id, "plan": plan})
|
||||
|
||||
@app.route("/api/strategy/roll/preview", methods=["POST"])
|
||||
@login_required
|
||||
def api_roll_preview():
|
||||
d = request.get_json(silent=True) or {}
|
||||
conn = get_db()
|
||||
mon_id = int(d.get("monitor_id") or 0)
|
||||
mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone()
|
||||
conn.close()
|
||||
if not mon:
|
||||
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
|
||||
sym = mon["symbol"]
|
||||
spec = get_contract_spec(sym)
|
||||
capital = _capital(get_db())
|
||||
preview, err = preview_roll(
|
||||
direction=mon["direction"],
|
||||
symbol=sym,
|
||||
qty_existing=float(mon["lots"]),
|
||||
entry_existing=float(mon["entry_price"]),
|
||||
initial_take_profit=float(mon["take_profit"] or 0),
|
||||
add_mode=d.get("add_mode") or "market",
|
||||
new_stop_loss=float(d.get("new_stop_loss") or 0),
|
||||
risk_percent=float(d.get("risk_percent") or 2),
|
||||
capital_base=capital,
|
||||
mult=spec["mult"],
|
||||
add_price=float(d.get("add_price") or mon["entry_price"]),
|
||||
fib_upper=d.get("fib_upper"),
|
||||
fib_lower=d.get("fib_lower"),
|
||||
legs_done=int(d.get("legs_done") or 0),
|
||||
)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
return jsonify({"ok": True, "preview": preview})
|
||||
|
||||
@app.route("/api/strategy/roll/execute", methods=["POST"])
|
||||
@login_required
|
||||
def api_roll_execute():
|
||||
d = request.get_json(silent=True) or {}
|
||||
conn = get_db()
|
||||
init_strategy_tables(conn)
|
||||
mon_id = int(d.get("monitor_id") or 0)
|
||||
mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone()
|
||||
if not mon:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
|
||||
if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone():
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": "趋势回调运行中,不可滚仓"}), 400
|
||||
sym = mon["symbol"]
|
||||
spec = get_contract_spec(sym)
|
||||
capital = _capital(conn)
|
||||
prev, err = preview_roll(
|
||||
direction=mon["direction"],
|
||||
symbol=sym,
|
||||
qty_existing=float(mon["lots"]),
|
||||
entry_existing=float(mon["entry_price"]),
|
||||
initial_take_profit=float(mon["take_profit"] or 0),
|
||||
add_mode=d.get("add_mode") or "market",
|
||||
new_stop_loss=float(d.get("new_stop_loss") or 0),
|
||||
risk_percent=float(d.get("risk_percent") or 2),
|
||||
capital_base=capital,
|
||||
mult=spec["mult"],
|
||||
add_price=float(d.get("add_price") or mon["entry_price"]),
|
||||
)
|
||||
if err:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
price = float(prev["add_price"])
|
||||
mode = get_trading_mode(get_setting)
|
||||
try:
|
||||
execute_order(
|
||||
conn, mode=mode, offset="open", symbol=sym,
|
||||
direction=mon["direction"], lots=int(prev["add_lots"]), price=price, settings=_settings_dict(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
new_lots = int(mon["lots"]) + int(prev["add_lots"])
|
||||
new_avg = prev["avg_entry_after"]
|
||||
new_sl = prev["new_stop_loss"]
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?",
|
||||
(new_lots, new_avg, new_sl, mon_id),
|
||||
)
|
||||
grp = conn.execute(
|
||||
"SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'",
|
||||
(mon_id,),
|
||||
).fetchone()
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if grp:
|
||||
gid = grp["id"]
|
||||
leg_n = int(grp["leg_count"] or 0) + 1
|
||||
conn.execute(
|
||||
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
|
||||
(leg_n, new_sl, now, gid),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO roll_groups (
|
||||
order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss,
|
||||
current_stop_loss, risk_percent, leg_count, status, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,1,'active',?,?)""",
|
||||
(mon_id, sym, mon["direction"], mon["take_profit"], mon["stop_loss"], new_sl,
|
||||
float(d.get("risk_percent") or 2), now, now),
|
||||
)
|
||||
gid = cur.lastrowid
|
||||
leg_n = 1
|
||||
conn.execute(
|
||||
"""INSERT INTO roll_legs (roll_group_id, leg_index, add_mode, fill_price, lots, new_stop_loss, status, created_at)
|
||||
VALUES (?,?,?,?,?,?, 'filled', ?)""",
|
||||
(gid, leg_n, d.get("add_mode") or "market", price, int(prev["add_lots"]), new_sl, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True, "preview": prev})
|
||||
|
||||
@app.route("/api/strategy/trend/stop", methods=["POST"])
|
||||
@login_required
|
||||
def api_trend_stop():
|
||||
d = request.get_json(silent=True) or {}
|
||||
plan_id = int(d.get("plan_id") or 0)
|
||||
conn = get_db()
|
||||
plan = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (plan_id,)).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
return jsonify({"ok": False, "error": "计划不存在"}), 404
|
||||
mode = get_trading_mode(get_setting)
|
||||
price = fetch_price(plan["symbol"]) or float(plan["avg_entry_price"] or 0)
|
||||
try:
|
||||
if int(plan["lots_open"] or 0) > 0:
|
||||
execute_order(
|
||||
conn, mode=mode, offset="close", symbol=plan["symbol"],
|
||||
direction=plan["direction"], lots=int(plan["lots_open"]), price=price, settings=_settings_dict(),
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"UPDATE trend_pullback_plans SET status='stopped_manual', message=?, opened_at=opened_at WHERE id=?",
|
||||
("手动结束", plan_id),
|
||||
)
|
||||
save_snapshot(
|
||||
conn, strategy_type=STRATEGY_TREND, source_id=plan_id,
|
||||
symbol=plan["symbol"], direction=plan["direction"], result_label="手动结束",
|
||||
payload=dict(plan), opened_at=plan["opened_at"] or "",
|
||||
)
|
||||
on_user_initiated_close(conn, trading_day=trading_day_label())
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
def check_trend_plans(app_ref):
|
||||
"""后台:趋势补仓与止盈。"""
|
||||
conn = get_db()
|
||||
init_strategy_tables(conn)
|
||||
rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall()
|
||||
mode = get_trading_mode(get_setting)
|
||||
for plan in rows:
|
||||
sym = plan["symbol"]
|
||||
price = fetch_price(sym)
|
||||
if not price:
|
||||
continue
|
||||
direction = plan["direction"]
|
||||
tp = float(plan["take_profit"] or 0)
|
||||
if tp > 0:
|
||||
hit_tp = (direction == "long" and price >= tp) or (direction == "short" and price <= tp)
|
||||
if hit_tp:
|
||||
try:
|
||||
execute_order(
|
||||
conn, mode=mode, offset="close", symbol=sym, direction=direction,
|
||||
lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(),
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
conn.execute(
|
||||
"UPDATE trend_pullback_plans SET status='stopped_tp', message=? WHERE id=?",
|
||||
("程序止盈", plan["id"]),
|
||||
)
|
||||
save_snapshot(
|
||||
conn, strategy_type=STRATEGY_TREND, source_id=plan["id"],
|
||||
symbol=sym, direction=direction, result_label="止盈",
|
||||
payload=dict(plan), opened_at=plan["opened_at"] or "",
|
||||
)
|
||||
send_wechat_msg(f"趋势回调止盈 {sym}")
|
||||
continue
|
||||
try:
|
||||
grid = json.loads(plan["grid_prices_json"] or "[]")
|
||||
legs = json.loads(plan["leg_amounts_json"] or "[]")
|
||||
except Exception:
|
||||
grid, legs = [], []
|
||||
done = int(plan["legs_done"] or 0)
|
||||
if done < len(grid) and done < len(legs):
|
||||
level = float(grid[done])
|
||||
if trend_dca_level_reached(direction, price, level):
|
||||
add_lots = int(legs[done])
|
||||
try:
|
||||
execute_order(
|
||||
conn, mode=mode, offset="open", symbol=sym, direction=direction,
|
||||
lots=add_lots, price=price, settings=_settings_dict(),
|
||||
)
|
||||
new_open = int(plan["lots_open"] or 0) + add_lots
|
||||
old_avg = float(plan["avg_entry_price"] or price)
|
||||
new_avg = (old_avg * int(plan["lots_open"] or 0) + price * add_lots) / new_open if new_open else price
|
||||
conn.execute(
|
||||
"""UPDATE trend_pullback_plans SET legs_done=?, lots_open=?, avg_entry_price=? WHERE id=?""",
|
||||
(done + 1, new_open, new_avg, plan["id"]),
|
||||
)
|
||||
send_wechat_msg(f"趋势回调补仓 {sym} +{add_lots}手 @档位{done+1}")
|
||||
except ValueError:
|
||||
pass
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
app._check_trend_plans = check_trend_plans
|
||||
|
||||
@app.route("/settings/trading", methods=["POST"])
|
||||
@login_required
|
||||
def settings_trading_post():
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str):
|
||||
if parse_mood_issues(behavior_tags):
|
||||
on_mood_journal_freeze(conn, trading_day=trading_day_label())
|
||||
if (exit_trigger or "").strip() == "手动平仓" and (exit_supplement or "").strip():
|
||||
reduce_cooloff_after_journal(conn, trading_day=trading_day_label())
|
||||
|
||||
app._risk_review_hook = hook_review_mood
|
||||
@@ -0,0 +1,94 @@
|
||||
"""期货计仓:固定张数 / 以损定仓(不含币圈全仓杠杆模式)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
|
||||
MODE_FIXED = "fixed"
|
||||
MODE_RISK = "risk"
|
||||
|
||||
|
||||
def normalize_sizing_mode(raw: str) -> str:
|
||||
m = (raw or MODE_RISK).strip().lower()
|
||||
return m if m in (MODE_FIXED, MODE_RISK) else MODE_RISK
|
||||
|
||||
|
||||
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 calc_lots_by_risk(
|
||||
entry: float,
|
||||
stop_loss: float,
|
||||
direction: str,
|
||||
capital: float,
|
||||
risk_percent: float,
|
||||
ths_code: str,
|
||||
*,
|
||||
max_lots: Optional[int] = None,
|
||||
) -> tuple[Optional[int], Optional[str]]:
|
||||
"""以损定仓:返回 (手数, 错误信息)。"""
|
||||
try:
|
||||
entry_f = float(entry)
|
||||
sl_f = float(stop_loss)
|
||||
cap = float(capital)
|
||||
rp = float(risk_percent)
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if entry_f <= 0 or cap <= 0 or rp <= 0:
|
||||
return None, "入场价、资金或风险比例无效"
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec["mult"]
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
per_lot_risk = (sl_f - entry_f) * mult
|
||||
else:
|
||||
per_lot_risk = (entry_f - sl_f) * mult
|
||||
if per_lot_risk <= 0:
|
||||
return None, "止损方向与入场价不匹配"
|
||||
budget = cap * rp / 100.0
|
||||
lots = int(math.floor(budget / per_lot_risk))
|
||||
if lots < 1:
|
||||
return None, f"按 {rp}% 风险预算,当前止损距离下不足 1 手"
|
||||
margin_rate = spec["margin_rate"]
|
||||
margin_per_lot = entry_f * mult * margin_rate
|
||||
max_by_margin = int(math.floor(cap * 0.85 / margin_per_lot)) if margin_per_lot > 0 else lots
|
||||
if max_by_margin < 1:
|
||||
return None, "可用资金不足以覆盖 1 手保证金"
|
||||
lots = min(lots, max_by_margin)
|
||||
if max_lots is not None:
|
||||
lots = min(lots, int(max_lots))
|
||||
return lots, None
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""按账户资金推荐可交易品种(期货核心筛选)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from contract_specs import get_contract_spec
|
||||
from symbols import PRODUCTS
|
||||
|
||||
|
||||
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_position_pct: float = 50.0,
|
||||
default_stop_ticks: int = 20,
|
||||
) -> dict:
|
||||
"""评估单品种在当前资金下是否可交易。"""
|
||||
ths = product.get("ths") or ""
|
||||
name = product.get("name") or ths
|
||||
exchange = product.get("exchange") or ""
|
||||
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)
|
||||
|
||||
if p <= 0:
|
||||
return {
|
||||
"ths": ths,
|
||||
"name": name,
|
||||
"exchange": exchange,
|
||||
"status": "no_price",
|
||||
"status_label": "暂无行情",
|
||||
"min_capital_one_lot": None,
|
||||
"margin_one_lot": None,
|
||||
"risk_one_lot_1pct": None,
|
||||
}
|
||||
|
||||
margin_one = p * mult * margin_rate
|
||||
min_capital = margin_one / (max_position_pct / 100.0) if max_position_pct > 0 else margin_one
|
||||
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
|
||||
|
||||
can_margin = cap >= min_capital
|
||||
can_risk = cap > 0 and risk_one_lot <= cap * 0.01
|
||||
|
||||
if can_margin and can_risk:
|
||||
status, label = "ok", "推荐"
|
||||
elif can_margin:
|
||||
status, label = "margin_ok", "可开1手·止损偏宽"
|
||||
else:
|
||||
status, label = "blocked", "资金不足"
|
||||
|
||||
return {
|
||||
"ths": ths,
|
||||
"name": name,
|
||||
"exchange": exchange,
|
||||
"price": round(p, 4),
|
||||
"mult": mult,
|
||||
"tick_size": tick,
|
||||
"margin_one_lot": round(margin_one, 2),
|
||||
"min_capital_one_lot": round(min_capital, 2),
|
||||
"risk_one_lot_1pct": round(risk_one_lot, 2),
|
||||
"risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2),
|
||||
"status": status,
|
||||
"status_label": label,
|
||||
}
|
||||
|
||||
|
||||
def list_product_recommendations(
|
||||
capital: float,
|
||||
price_fn: Callable[[str], Optional[float]],
|
||||
*,
|
||||
max_position_pct: float = 50.0,
|
||||
) -> list[dict]:
|
||||
"""扫描全部品种并排序:推荐 > 可开 > 不足。"""
|
||||
rows = []
|
||||
for p in PRODUCTS:
|
||||
ths = p["ths"]
|
||||
main_code = price_fn(ths)
|
||||
row = assess_product_for_capital(
|
||||
p, capital, main_code, max_position_pct=max_position_pct
|
||||
)
|
||||
rows.append(row)
|
||||
order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3}
|
||||
rows.sort(key=lambda r: (order.get(r["status"], 9), r.get("min_capital_one_lot") or 1e18))
|
||||
return rows
|
||||
@@ -4,3 +4,5 @@ python-dotenv==1.0.1
|
||||
Werkzeug==3.0.3
|
||||
matplotlib==3.9.2
|
||||
akshare==1.18.64
|
||||
# 实盘 / 模拟 CTP(SimNow + 期货公司)
|
||||
# pip install vnpy vnpy_ctp
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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
|
||||
|
||||
|
||||
def ensure_account_risk_schema(conn) -> None:
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
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) -> 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),
|
||||
)
|
||||
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)
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def assert_can_open(conn) -> Optional[str]:
|
||||
rs = get_risk_status(conn)
|
||||
if not rs.get("can_trade"):
|
||||
return rs.get("reason") or "当前不可开仓"
|
||||
return None
|
||||
@@ -0,0 +1,20 @@
|
||||
.trade-page{max-width:720px;margin:0 auto}
|
||||
.trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1rem}
|
||||
.trade-order-card{padding:1.25rem}
|
||||
.trade-tabs{display:flex;gap:1rem;margin-bottom:1rem;font-size:.88rem}
|
||||
.trade-tabs span.active{color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent);padding-bottom:.25rem}
|
||||
.trade-tabs a{color:var(--text-muted);text-decoration:none}
|
||||
.trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem}
|
||||
.trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)}
|
||||
.trade-btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin:1rem 0}
|
||||
.trade-btn{border:none;border-radius:8px;padding:.75rem .35rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.15rem;color:#fff;font-weight:600}
|
||||
.trade-btn .btn-price{font-size:1.1rem}
|
||||
.trade-btn .btn-label{font-size:.85rem}
|
||||
.trade-btn .btn-sub{font-size:.68rem;opacity:.85;font-weight:400}
|
||||
.trade-btn.long{background:linear-gradient(180deg,#e74c3c,#c0392b)}
|
||||
.trade-btn.lock{background:linear-gradient(180deg,#27ae60,#1e8449)}
|
||||
.trade-btn.close{background:linear-gradient(180deg,#3498db,#2980b9)}
|
||||
.trade-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border)}
|
||||
.trade-footer strong{color:var(--accent)}
|
||||
.rec-blocked td{opacity:.55}
|
||||
.rec-ok td:first-child{font-weight:600}
|
||||
+101
-37
@@ -242,41 +242,59 @@
|
||||
|
||||
function getDataZoom(c, preserve) {
|
||||
var defStart = getDefaultZoomStart();
|
||||
var zoom = [
|
||||
{
|
||||
type: 'inside',
|
||||
xAxisIndex: [0, 1],
|
||||
start: defStart,
|
||||
end: 100,
|
||||
zoomOnMouseWheel: true,
|
||||
moveOnMouseMove: true,
|
||||
moveOnMouseWheel: false,
|
||||
var xZoom = {
|
||||
type: 'inside',
|
||||
id: 'dzInsideX',
|
||||
xAxisIndex: [0, 1],
|
||||
start: defStart,
|
||||
end: 100,
|
||||
filterMode: 'none',
|
||||
zoomOnMouseWheel: true,
|
||||
moveOnMouseMove: true,
|
||||
moveOnMouseWheel: false,
|
||||
preventDefaultMouseMove: true,
|
||||
minSpan: 2,
|
||||
};
|
||||
var yZoom = {
|
||||
type: 'inside',
|
||||
id: 'dzInsideY',
|
||||
yAxisIndex: [0],
|
||||
orient: 'vertical',
|
||||
filterMode: 'none',
|
||||
zoomOnMouseWheel: true,
|
||||
moveOnMouseMove: true,
|
||||
preventDefaultMouseMove: true,
|
||||
};
|
||||
var slider = {
|
||||
type: 'slider',
|
||||
id: 'dzSlider',
|
||||
xAxisIndex: [0, 1],
|
||||
start: defStart,
|
||||
end: 100,
|
||||
height: 22,
|
||||
bottom: 4,
|
||||
borderColor: c.grid,
|
||||
backgroundColor: c.bg,
|
||||
fillerColor: c.area,
|
||||
handleStyle: { color: c.sliderFill },
|
||||
dataBackground: {
|
||||
lineStyle: { color: c.grid, opacity: 0.35 },
|
||||
areaStyle: { color: c.area },
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
xAxisIndex: [0, 1],
|
||||
start: defStart,
|
||||
end: 100,
|
||||
height: 22,
|
||||
bottom: 4,
|
||||
borderColor: c.grid,
|
||||
backgroundColor: c.bg,
|
||||
fillerColor: c.area,
|
||||
handleStyle: { color: c.sliderFill },
|
||||
dataBackground: {
|
||||
lineStyle: { color: c.grid },
|
||||
areaStyle: { color: c.area },
|
||||
},
|
||||
textStyle: { color: c.text, fontSize: 10 },
|
||||
},
|
||||
];
|
||||
textStyle: { color: c.text, fontSize: 10 },
|
||||
filterMode: 'none',
|
||||
brushSelect: false,
|
||||
};
|
||||
var zoom = [xZoom, yZoom, slider];
|
||||
if (preserve && chart) {
|
||||
var opt = chart.getOption();
|
||||
if (opt && opt.dataZoom) {
|
||||
opt.dataZoom.forEach(function (z, i) {
|
||||
if (zoom[i] && z.start != null && z.end != null) {
|
||||
zoom[i].start = z.start;
|
||||
zoom[i].end = z.end;
|
||||
opt.dataZoom.forEach(function (z) {
|
||||
if (!z.id) return;
|
||||
var target = zoom.find(function (t) { return t.id === z.id; });
|
||||
if (target && z.start != null && z.end != null) {
|
||||
target.start = z.start;
|
||||
target.end = z.end;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -284,6 +302,11 @@
|
||||
return zoom;
|
||||
}
|
||||
|
||||
function isFollowingLatest() {
|
||||
var z = getZoomRange();
|
||||
return z.end >= 98;
|
||||
}
|
||||
|
||||
function mapSeriesData(bars, values, gapDay) {
|
||||
if (!gapDay) return values;
|
||||
return bars.map(function (b, i) {
|
||||
@@ -303,6 +326,7 @@
|
||||
var times = bars.map(function (b) { return b.time; });
|
||||
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
||||
var gapDay = chartOpts.gapDay;
|
||||
var followLatest = preserveZoom && isFollowingLatest();
|
||||
var dataZoom = getDataZoom(c, preserveZoom);
|
||||
var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end };
|
||||
var vIdx = visibleIndices(bars, zoom);
|
||||
@@ -326,6 +350,7 @@
|
||||
boundaryGap: gapDay ? false : true,
|
||||
axisLabel: { color: c.text, fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: c.grid } },
|
||||
splitLine: { show: false },
|
||||
};
|
||||
var xAxis1 = {
|
||||
type: xAxisType,
|
||||
@@ -333,6 +358,7 @@
|
||||
boundaryGap: gapDay ? false : true,
|
||||
axisLabel: { show: false },
|
||||
axisLine: { lineStyle: { color: c.grid } },
|
||||
splitLine: { show: false },
|
||||
};
|
||||
if (!gapDay) {
|
||||
xAxis0.data = times;
|
||||
@@ -344,14 +370,27 @@
|
||||
animation: false,
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||
dataZoom: dataZoom,
|
||||
grid: grids,
|
||||
xAxis: [xAxis0, xAxis1],
|
||||
yAxis: [
|
||||
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } },
|
||||
{ scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 },
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 0,
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: c.text },
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 1,
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: c.text, fontSize: 10 },
|
||||
splitNumber: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (!preserveZoom) {
|
||||
base.dataZoom = dataZoom;
|
||||
}
|
||||
|
||||
var series = [];
|
||||
var mainMark = {
|
||||
@@ -465,7 +504,12 @@
|
||||
};
|
||||
}
|
||||
|
||||
chart.setOption(Object.assign(base, { series: series }), true);
|
||||
if (preserveZoom) {
|
||||
chart.setOption(Object.assign(base, { series: series }), false);
|
||||
} else {
|
||||
chart.setOption(Object.assign(base, { series: series }), true);
|
||||
dataZoomBound = false;
|
||||
}
|
||||
|
||||
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
|
||||
chart.setOption({
|
||||
@@ -478,6 +522,22 @@
|
||||
} : { show: false },
|
||||
});
|
||||
|
||||
if (followLatest) {
|
||||
var span = zoom.end - zoom.start;
|
||||
chart.dispatchAction({
|
||||
type: 'dataZoom',
|
||||
dataZoomIndex: 0,
|
||||
start: Math.max(0, 100 - span),
|
||||
end: 100,
|
||||
});
|
||||
chart.dispatchAction({
|
||||
type: 'dataZoom',
|
||||
dataZoomIndex: 2,
|
||||
start: Math.max(0, 100 - span),
|
||||
end: 100,
|
||||
});
|
||||
}
|
||||
|
||||
bindDataZoomHL();
|
||||
}
|
||||
|
||||
@@ -656,12 +716,16 @@
|
||||
if (start === 0) end = newSpan;
|
||||
else start = end - newSpan;
|
||||
}
|
||||
chart.dispatchAction({ type: 'dataZoom', start: start, end: end });
|
||||
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: end });
|
||||
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: end });
|
||||
}
|
||||
|
||||
function resetDataZoom() {
|
||||
if (!chart) return;
|
||||
chart.dispatchAction({ type: 'dataZoom', start: getDefaultZoomStart(), end: 100 });
|
||||
var start = getDefaultZoomStart();
|
||||
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: 100 });
|
||||
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: 100 });
|
||||
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 });
|
||||
}
|
||||
|
||||
function bindPeriodTabs() {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
(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;
|
||||
}
|
||||
|
||||
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 () {
|
||||
jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) {
|
||||
if (!d.ok) { previewEl.textContent = d.error || '预览失败'; btnExec.hidden = true; return; }
|
||||
trendPayload = formData(trendForm);
|
||||
previewEl.textContent = JSON.stringify(d.plan, null, 2);
|
||||
btnExec.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (btnExec) {
|
||||
btnExec.addEventListener('click', function () {
|
||||
if (!trendPayload) return;
|
||||
jsonPost('/api/strategy/trend/execute', trendPayload).then(function (d) {
|
||||
if (!d.ok) { alert(d.error); return; }
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 () {
|
||||
jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) {
|
||||
if (!d.ok) { rollPrev.textContent = d.error; btnRollE.hidden = true; return; }
|
||||
rollPrev.textContent = JSON.stringify(d.preview, null, 2);
|
||||
btnRollE.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (btnRollE && rollForm) {
|
||||
btnRollE.addEventListener('click', function () {
|
||||
jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) {
|
||||
if (!d.ok) { alert(d.error); return; }
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,127 @@
|
||||
(function () {
|
||||
var symInput = document.getElementById('trade-symbol');
|
||||
var lotsInput = document.getElementById('trade-lots');
|
||||
var priceInput = document.getElementById('trade-price');
|
||||
var footer = document.getElementById('trade-footer');
|
||||
var slInput = document.getElementById('trade-sl');
|
||||
var tpInput = document.getElementById('trade-tp');
|
||||
var debounceTimer;
|
||||
|
||||
function selectedSymbol() {
|
||||
return (symInput && symInput.value || '').trim();
|
||||
}
|
||||
|
||||
function refreshQuote() {
|
||||
var sym = selectedSymbol();
|
||||
var lots = lotsInput ? lotsInput.value : '1';
|
||||
if (!sym) return;
|
||||
fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) return;
|
||||
if (priceInput && !priceInput.dataset.manual && data.price) {
|
||||
priceInput.value = data.price;
|
||||
}
|
||||
var px = data.price != null ? data.price : '—';
|
||||
['px-long', 'px-short'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = px;
|
||||
});
|
||||
var ml = document.getElementById('max-long');
|
||||
var ms = document.getElementById('max-short');
|
||||
if (ml) ml.textContent = '≤' + (data.max_open_long || '—');
|
||||
if (ms) ms.textContent = '≤' + (data.max_open_short || '—');
|
||||
document.getElementById('pos-long').textContent = '≤' + (data.pos_long || 0);
|
||||
document.getElementById('pos-short').textContent = '≤' + (data.pos_short || 0);
|
||||
if (footer && data.metrics) {
|
||||
var m = data.metrics;
|
||||
footer.innerHTML =
|
||||
'<p><strong>' + (data.name || sym) + '</strong> ' + (data.footer_text || '') + '</p>' +
|
||||
'<p>价格精度 <strong>' + m.price_precision + '</strong> 位 · ' +
|
||||
'最小变动 <strong>' + m.tick_size + '</strong> · ' +
|
||||
'每跳 <strong>' + m.tick_value_per_lot + '</strong> 元/手 · ' +
|
||||
'当前 <strong>' + lots + '</strong> 手每跳合计 <strong class="text-accent">' + m.tick_value_total + '</strong> 元</p>' +
|
||||
(m.margin_total ? '<p class="text-muted">预估保证金约 ' + m.margin_total + ' 元</p>' : '');
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(refreshQuote, 400);
|
||||
}
|
||||
|
||||
if (symInput) symInput.addEventListener('input', scheduleRefresh);
|
||||
if (lotsInput) lotsInput.addEventListener('input', scheduleRefresh);
|
||||
if (priceInput) {
|
||||
priceInput.addEventListener('input', function () {
|
||||
priceInput.dataset.manual = '1';
|
||||
});
|
||||
}
|
||||
|
||||
function postOrder(offset, direction) {
|
||||
var sym = selectedSymbol();
|
||||
if (!sym) { alert('请选择品种'); return; }
|
||||
var body = {
|
||||
symbol: sym,
|
||||
offset: offset,
|
||||
direction: direction,
|
||||
lots: parseInt(lotsInput.value, 10) || 1,
|
||||
price: parseFloat(priceInput.value) || 0,
|
||||
stop_loss: slInput ? parseFloat(slInput.value) : null,
|
||||
take_profit: tpInput ? parseFloat(tpInput.value) : null
|
||||
};
|
||||
fetch('/api/trade/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) { return r.json(); }).then(function (data) {
|
||||
if (!data.ok) { alert(data.error || '下单失败'); return; }
|
||||
alert('已提交 ' + (data.lots || '') + ' 手');
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
var btnLong = document.getElementById('btn-open-long');
|
||||
var btnShort = document.getElementById('btn-open-short');
|
||||
var btnCloseL = document.getElementById('btn-close-long');
|
||||
var btnCloseS = document.getElementById('btn-close-short');
|
||||
if (btnLong) btnLong.addEventListener('click', function () { postOrder('open', 'long'); });
|
||||
if (btnShort) btnShort.addEventListener('click', function () { postOrder('open', 'short'); });
|
||||
if (btnCloseL) btnCloseL.addEventListener('click', function () { postOrder('close', 'long'); });
|
||||
if (btnCloseS) btnCloseS.addEventListener('click', function () { postOrder('close', 'short'); });
|
||||
|
||||
var btnConnect = document.getElementById('btn-ctp-connect');
|
||||
if (btnConnect) {
|
||||
btnConnect.addEventListener('click', function () {
|
||||
btnConnect.disabled = true;
|
||||
btnConnect.textContent = '连接中…';
|
||||
fetch('/api/ctp/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.ok) { alert(d.error || '连接失败'); return; }
|
||||
location.reload();
|
||||
})
|
||||
.finally(function () {
|
||||
btnConnect.disabled = false;
|
||||
btnConnect.textContent = '连接 CTP';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
fetch('/api/account_snapshot').then(function (r) { return r.json(); }).then(function (d) {
|
||||
var cap = document.getElementById('cap-display');
|
||||
if (cap && d.capital != null) cap.textContent = Number(d.capital).toFixed(2);
|
||||
var badge = document.getElementById('risk-badge');
|
||||
if (badge && d.risk_status) badge.textContent = d.risk_status.status_label;
|
||||
var ctpBadge = document.getElementById('ctp-badge');
|
||||
if (ctpBadge && d.ctp_status) {
|
||||
ctpBadge.textContent = d.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接';
|
||||
ctpBadge.className = 'badge ' + (d.ctp_status.connected ? 'profit' : 'planned');
|
||||
}
|
||||
}).catch(function () {});
|
||||
}, 5000);
|
||||
|
||||
scheduleRefresh();
|
||||
})();
|
||||
@@ -0,0 +1,18 @@
|
||||
"""斐波计算(自 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
|
||||
@@ -0,0 +1,131 @@
|
||||
"""策略相关表结构。"""
|
||||
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)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def init_strategy_tables(conn) -> None:
|
||||
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)")
|
||||
@@ -0,0 +1,159 @@
|
||||
"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。"""
|
||||
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
|
||||
@@ -0,0 +1,70 @@
|
||||
"""策略结束快照。"""
|
||||
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
|
||||
@@ -0,0 +1,108 @@
|
||||
"""趋势回调:纯计算(期货整数手)。"""
|
||||
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
|
||||
@@ -483,6 +483,9 @@
|
||||
<h1 class="site-title">国内期货 · 交易监控 + 复盘<span class="site-title-sub">FUTURES MONITOR SYSTEM</span></h1>
|
||||
<button type="button" class="nav-backdrop" id="nav-backdrop" aria-label="关闭菜单" hidden></button>
|
||||
<nav class="site-nav" id="site-nav">
|
||||
<a href="{{ url_for('trade_page') }}" class="{% if request.endpoint == 'trade_page' %}active{% endif %}">期货下单</a>
|
||||
<a href="{{ url_for('recommend_page') }}" class="{% if request.endpoint == 'recommend_page' %}active{% endif %}">品种推荐</a>
|
||||
<a href="{{ url_for('strategy_page') }}" class="{% if request.endpoint in ('strategy_page', 'strategy_records_page') %}active{% endif %}">策略交易</a>
|
||||
<a href="{{ url_for('plans') }}" class="{% if request.endpoint == 'plans' %}active{% endif %}">开单计划</a>
|
||||
<a href="{{ url_for('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
|
||||
<a href="{{ url_for('positions') }}" class="{% if request.endpoint == 'positions' %}active{% endif %}">持仓监控</a>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
|
||||
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
|
||||
</div>
|
||||
<p class="hint">数据来源:新浪财经。K 线由后台自动刷新并经 SSE 推送到前端;支持滚轮/拖拽缩放。可视区内自动标注最高/最低价。</p>
|
||||
<p class="hint">数据来源:新浪财经。拖拽左右平移、滚轮缩放;按住图表上下拖动可平移价格轴。可视区内自动标注最高/最低价。</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{% 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 %}
|
||||
+38
-3
@@ -3,13 +3,48 @@
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
<h2>实盘资金</h2>
|
||||
<h2>交易模式</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="trading">
|
||||
<div class="form-grid" style="max-width:640px">
|
||||
<div class="field">
|
||||
<label>交易通道</label>
|
||||
<select name="trading_mode">
|
||||
<option value="simulation" {% if trading_mode == 'simulation' %}selected{% endif %}>模拟盘 · SimNow(vnpy CTP)</option>
|
||||
<option value="live" {% if trading_mode == 'live' %}selected{% endif %}>实盘 · 期货公司 CTP(后期接入)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>计仓模式</label>
|
||||
<select name="position_sizing_mode">
|
||||
<option value="risk" {% if position_sizing_mode == 'risk' %}selected{% endif %}>以损定仓</option>
|
||||
<option value="fixed" {% if position_sizing_mode == 'fixed' %}selected{% endif %}>固定张数</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>单笔风险比例(以损定仓,%)</label>
|
||||
<input name="risk_percent" type="number" step="0.1" min="0.1" max="100" value="{{ risk_percent }}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
|
||||
</form>
|
||||
<p class="hint" style="margin-top:.75rem">
|
||||
<strong>模拟盘</strong>连接上期 SimNow 仿真柜台(非本地假资金)。在 <code>.env</code> 配置
|
||||
<code>SIMNOW_USER</code>、<code>SIMNOW_PASSWORD</code> 等,在「期货下单」页点击连接 CTP。<br>
|
||||
<strong>实盘</strong>后期配置 <code>CTP_LIVE_*</code> 对接你的期货公司。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>参考资金</h2>
|
||||
<form action="{{ url_for('settings') }}" method="post" class="form-row">
|
||||
<input type="hidden" name="action" value="capital">
|
||||
<input name="live_capital" type="number" step="0.01" min="0" placeholder="实盘资金(元)" value="{{ live_capital }}" style="flex:1;min-width:200px;max-width:320px">
|
||||
<input name="live_capital" type="number" step="0.01" min="0" placeholder="参考权益(元)" value="{{ live_capital }}" style="flex:1;min-width:200px;max-width:320px">
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</form>
|
||||
<p class="hint" style="margin-top:.75rem">用于持仓监控的风险比例、仓位占比计算,保存在数据库中。</p>
|
||||
<p class="hint" style="margin-top:.75rem">
|
||||
CTP 未连接时用于<strong>品种推荐</strong>与以损定仓估算;SimNow/实盘登录成功后自动改用<strong>柜台权益</strong>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}策略交易 - 国内期货监控系统{% endblock %}
|
||||
{% block content %}
|
||||
<div class="split-grid">
|
||||
<div class="card">
|
||||
<h2>趋势回调</h2>
|
||||
{% if active_trend %}
|
||||
<p class="hint">运行中 #{{ active_trend.id }} {{ active_trend.symbol }} {{ active_trend.direction }}
|
||||
已开 {{ active_trend.lots_open or 0 }}/{{ active_trend.target_lots }} 手</p>
|
||||
<form id="trend-stop-form" class="form-row">
|
||||
<input type="hidden" name="plan_id" value="{{ active_trend.id }}">
|
||||
<button type="button" class="btn-primary" id="btn-trend-stop">结束计划</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form id="trend-form" class="form-compact">
|
||||
<div class="form-line line-2">
|
||||
<div class="symbol-wrap"><input class="symbol-input" name="symbol" placeholder="合约" 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="风险%">
|
||||
<button type="button" class="btn-primary" id="btn-trend-preview">预览</button>
|
||||
</div>
|
||||
</form>
|
||||
<pre id="trend-preview" class="hint" style="white-space:pre-wrap;margin-top:.75rem"></pre>
|
||||
<button type="button" class="btn-primary" id="btn-trend-exec" hidden>确认执行首仓</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>顺势加仓(滚仓)</h2>
|
||||
<p class="hint">须先有「下单监控」持仓;最多 3 腿;止盈锁首仓。</p>
|
||||
{% if monitors %}
|
||||
<form id="roll-form" class="form-compact">
|
||||
<select name="monitor_id" required>
|
||||
{% for m in monitors %}
|
||||
<option value="{{ m.id }}">{{ m.symbol }} {{ m.direction }} {{ m.lots }}手</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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="总风险%">
|
||||
</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>
|
||||
<pre id="roll-preview" class="hint" style="white-space:pre-wrap"></pre>
|
||||
<button type="button" class="btn-primary" id="btn-roll-exec" hidden>执行滚仓</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="empty-hint">请先在「期货下单」开仓并建立监控。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint"><a href="{{ url_for('strategy_records_page') }}">策略交易记录 →</a></p>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/strategy.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,96 @@
|
||||
{% 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">
|
||||
<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>
|
||||
<button type="button" class="btn-primary" id="btn-ctp-connect" style="padding:.4rem .9rem;font-size:.8rem">连接 CTP</button>
|
||||
</div>
|
||||
|
||||
<div class="card trade-order-card">
|
||||
<div class="trade-tabs">
|
||||
<span class="active">期货下单</span>
|
||||
<a href="{{ url_for('recommend_page') }}">品种推荐</a>
|
||||
<a href="{{ url_for('strategy_page') }}">策略交易</a>
|
||||
</div>
|
||||
|
||||
<div class="trade-input-row">
|
||||
<div class="symbol-wrap trade-field">
|
||||
<label class="text-label">品种</label>
|
||||
<input type="text" id="trade-symbol" class="symbol-input" placeholder="主力合约 rb2610" autocomplete="off" value="">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected" id="sym-selected"></div>
|
||||
</div>
|
||||
<div class="trade-field">
|
||||
<label class="text-label">手数</label>
|
||||
<input type="number" id="trade-lots" min="1" step="1" value="1">
|
||||
</div>
|
||||
<div class="trade-field">
|
||||
<label class="text-label">价格</label>
|
||||
<input type="number" id="trade-price" step="any" placeholder="限价">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="risk-fields" class="trade-risk-row" {% if sizing_mode != 'risk' %}hidden{% endif %}>
|
||||
<div class="trade-field"><label class="text-label">止损</label><input type="number" id="trade-sl" step="any"></div>
|
||||
<div class="trade-field"><label class="text-label">止盈</label><input type="number" id="trade-tp" step="any"></div>
|
||||
</div>
|
||||
|
||||
<div class="trade-btn-row">
|
||||
<button type="button" class="trade-btn long" id="btn-open-long">
|
||||
<span class="btn-price" id="px-long">—</span>
|
||||
<span class="btn-label">加多</span>
|
||||
<span class="btn-sub" id="max-long">≤—</span>
|
||||
</button>
|
||||
<button type="button" class="trade-btn lock" id="btn-open-short">
|
||||
<span class="btn-price" id="px-short">—</span>
|
||||
<span class="btn-label">加空</span>
|
||||
<span class="btn-sub" id="max-short">≤—</span>
|
||||
</button>
|
||||
<button type="button" class="trade-btn close" id="btn-close-long">
|
||||
<span class="btn-sub">平多</span>
|
||||
<span class="btn-label">平多</span>
|
||||
<span class="btn-sub" id="pos-long">≤0</span>
|
||||
</button>
|
||||
<button type="button" class="trade-btn close" id="btn-close-short">
|
||||
<span class="btn-sub">平空</span>
|
||||
<span class="btn-label">平空</span>
|
||||
<span class="btn-sub" id="pos-short">≤0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="trade-footer" id="trade-footer">
|
||||
<p class="hint">SimNow 模拟盘:请先连接 CTP。输入品种与手数后显示跳动价值与价格精度。</p>
|
||||
{% if ctp_status.last_error %}<p class="text-loss" style="font-size:.78rem">{{ ctp_status.last_error }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if ctp_positions %}
|
||||
<div class="card">
|
||||
<h2>CTP 持仓(SimNow / 柜台)</h2>
|
||||
<ul class="list">
|
||||
{% for p in ctp_positions %}
|
||||
<li class="list-item">
|
||||
<span>{{ p.symbol }} {{ '多' if p.direction=='long' else '空' }} {{ p.lots }}手 @ {{ p.avg_price }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }};
|
||||
window.TRADE_RISK_PERCENT = {{ risk_percent }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/trade.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""交易上下文:设置读取、资金、模式。"""
|
||||
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", "risk"))
|
||||
|
||||
|
||||
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_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 "期货公司实盘"
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
"""CTP 执行层:模拟盘 → SimNow;实盘 → 期货公司(vnpy_ctp)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GATEWAY_NAME = "CTP"
|
||||
|
||||
_bridge: Optional["CtpBridge"] = None
|
||||
_bridge_lock = threading.Lock()
|
||||
|
||||
|
||||
def _env(key: str, default: str = "") -> str:
|
||||
return (os.getenv(key) or default).strip()
|
||||
|
||||
|
||||
def _simnow_setting() -> dict[str, str]:
|
||||
"""SimNow 7×24 仿真默认前置(可在 .env 覆盖)。"""
|
||||
return {
|
||||
"用户名": _env("SIMNOW_USER"),
|
||||
"密码": _env("SIMNOW_PASSWORD"),
|
||||
"经纪商代码": _env("SIMNOW_BROKER_ID", "9999"),
|
||||
"交易服务器": _env("SIMNOW_TD_ADDRESS", "tcp://180.168.146.187:10201"),
|
||||
"行情服务器": _env("SIMNOW_MD_ADDRESS", "tcp://180.168.146.187:10211"),
|
||||
"产品名称": _env("SIMNOW_APP_ID", "simnow_client_test"),
|
||||
"授权编码": _env("SIMNOW_AUTH_CODE", "0000000000000000"),
|
||||
"产品信息": _env("SIMNOW_PRODUCT_INFO", "simnow_client_test"),
|
||||
}
|
||||
|
||||
|
||||
def _live_setting() -> dict[str, str]:
|
||||
return {
|
||||
"用户名": _env("CTP_LIVE_USER"),
|
||||
"密码": _env("CTP_LIVE_PASSWORD"),
|
||||
"经纪商代码": _env("CTP_LIVE_BROKER_ID"),
|
||||
"交易服务器": _env("CTP_LIVE_TD_ADDRESS"),
|
||||
"行情服务器": _env("CTP_LIVE_MD_ADDRESS"),
|
||||
"产品名称": _env("CTP_LIVE_APP_ID"),
|
||||
"授权编码": _env("CTP_LIVE_AUTH_CODE"),
|
||||
"产品信息": _env("CTP_LIVE_PRODUCT_INFO"),
|
||||
}
|
||||
|
||||
|
||||
def _setting_for_mode(mode: str) -> dict[str, str]:
|
||||
return _simnow_setting() if mode == "simulation" else _live_setting()
|
||||
|
||||
|
||||
def _mode_label(mode: str) -> str:
|
||||
return "SimNow 模拟" if mode == "simulation" else "期货公司实盘"
|
||||
|
||||
|
||||
class CtpBridge:
|
||||
def __init__(self) -> None:
|
||||
self._engine = None
|
||||
self._ee = None
|
||||
self._connected_mode: Optional[str] = None
|
||||
self._last_error: str = ""
|
||||
self._connect_lock = threading.Lock()
|
||||
self._init_engine()
|
||||
|
||||
def _init_engine(self) -> None:
|
||||
try:
|
||||
from vnpy.event import EventEngine
|
||||
from vnpy.trader.engine import MainEngine
|
||||
from vnpy_ctp import CtpGateway
|
||||
|
||||
self._ee = EventEngine()
|
||||
self._engine = MainEngine(self._ee)
|
||||
self._engine.add_gateway(CtpGateway)
|
||||
except ImportError:
|
||||
self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp"
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
|
||||
def available(self) -> bool:
|
||||
return self._engine is not None
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
return self._last_error
|
||||
|
||||
@property
|
||||
def connected_mode(self) -> Optional[str]:
|
||||
return self._connected_mode
|
||||
|
||||
def status(self, mode: str) -> dict[str, Any]:
|
||||
st = _setting_for_mode(mode)
|
||||
missing = [k for k in ("用户名", "密码", "交易服务器") if not st.get(k)]
|
||||
return {
|
||||
"vnpy_installed": self.available(),
|
||||
"connected": self._connected_mode == mode,
|
||||
"connected_mode": self._connected_mode,
|
||||
"mode_label": _mode_label(mode),
|
||||
"missing_config": missing,
|
||||
"last_error": self._last_error,
|
||||
"broker_id": st.get("经纪商代码", ""),
|
||||
"td_address": st.get("交易服务器", ""),
|
||||
}
|
||||
|
||||
def connect(self, mode: str, *, force: bool = False) -> None:
|
||||
if not self._engine:
|
||||
raise RuntimeError(self._last_error or "vnpy 引擎未初始化")
|
||||
if self._connected_mode == mode and not force:
|
||||
return
|
||||
setting = _setting_for_mode(mode)
|
||||
if not setting.get("用户名") or not setting.get("密码"):
|
||||
raise ValueError(
|
||||
f"{_mode_label(mode)}:请在 .env 配置 "
|
||||
f"{'SIMNOW_USER / SIMNOW_PASSWORD' if mode == 'simulation' else 'CTP_LIVE_USER / CTP_LIVE_PASSWORD'}"
|
||||
)
|
||||
if not setting.get("交易服务器"):
|
||||
raise ValueError(f"{_mode_label(mode)}:未配置交易服务器地址")
|
||||
|
||||
with self._connect_lock:
|
||||
if self._connected_mode and self._connected_mode != mode:
|
||||
try:
|
||||
self._engine.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._connected_mode = None
|
||||
time.sleep(1)
|
||||
|
||||
self._engine.connect(setting, GATEWAY_NAME)
|
||||
# 等待登录与结算信息
|
||||
for _ in range(30):
|
||||
accounts = self._engine.get_all_accounts()
|
||||
if accounts:
|
||||
self._connected_mode = mode
|
||||
self._last_error = ""
|
||||
logger.info("CTP 已连接 [%s] account=%s", mode, len(accounts))
|
||||
return
|
||||
time.sleep(0.5)
|
||||
self._last_error = "CTP 连接超时,请检查 SimNow 账号、前置地址与交易时段"
|
||||
raise RuntimeError(self._last_error)
|
||||
|
||||
def ensure_connected(self, mode: str) -> None:
|
||||
if self._connected_mode != mode:
|
||||
self.connect(mode)
|
||||
|
||||
def get_account(self) -> dict[str, Any]:
|
||||
if not self._engine:
|
||||
return {}
|
||||
accounts = self._engine.get_all_accounts()
|
||||
if not accounts:
|
||||
return {}
|
||||
acc = accounts[0]
|
||||
return {
|
||||
"balance": float(getattr(acc, "balance", 0) or 0),
|
||||
"available": float(getattr(acc, "available", 0) or 0),
|
||||
"frozen": float(getattr(acc, "frozen", 0) or 0),
|
||||
"accountid": getattr(acc, "accountid", ""),
|
||||
}
|
||||
|
||||
def list_positions(self) -> list[dict[str, Any]]:
|
||||
if not self._engine:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for pos in self._engine.get_all_positions():
|
||||
vol = int(getattr(pos, "volume", 0) or 0)
|
||||
if vol <= 0:
|
||||
continue
|
||||
direction = getattr(pos, "direction", None)
|
||||
d = "long"
|
||||
if direction is not None and str(direction).endswith("SHORT"):
|
||||
d = "short"
|
||||
elif direction is not None and "空" in str(direction):
|
||||
d = "short"
|
||||
sym = getattr(pos, "symbol", "") or ""
|
||||
out.append({
|
||||
"symbol": sym,
|
||||
"direction": d,
|
||||
"lots": vol,
|
||||
"avg_price": float(getattr(pos, "price", 0) or 0),
|
||||
"pnl": float(getattr(pos, "pnl", 0) or 0),
|
||||
})
|
||||
return out
|
||||
|
||||
def send_order(
|
||||
self,
|
||||
*,
|
||||
ths_code: str,
|
||||
offset: str,
|
||||
direction: str,
|
||||
lots: int,
|
||||
price: float,
|
||||
) -> str:
|
||||
from vnpy.trader.constant import Direction, Offset, OrderType
|
||||
from vnpy.trader.object import OrderRequest
|
||||
|
||||
if not self._engine:
|
||||
raise RuntimeError("CTP 未初始化")
|
||||
sym, ex_name = ths_to_vnpy_symbol(ths_code)
|
||||
exchange = to_vnpy_exchange(ex_name)
|
||||
lots = max(1, int(lots))
|
||||
price = float(price)
|
||||
|
||||
offset = (offset or "open").lower()
|
||||
direction = (direction or "long").lower()
|
||||
|
||||
if offset in ("open", "open_long", "open_short"):
|
||||
d = Direction.LONG if direction == "long" or offset == "open_long" else Direction.SHORT
|
||||
off = Offset.OPEN
|
||||
elif offset in ("close", "close_long", "close_short"):
|
||||
# 平多 = 卖;平空 = 买
|
||||
if direction == "long" or offset == "close_long":
|
||||
d = Direction.SHORT
|
||||
else:
|
||||
d = Direction.LONG
|
||||
off = Offset.CLOSE
|
||||
else:
|
||||
raise ValueError(f"未知开平: {offset}")
|
||||
|
||||
req = OrderRequest(
|
||||
symbol=sym,
|
||||
exchange=exchange,
|
||||
direction=d,
|
||||
type=OrderType.LIMIT,
|
||||
volume=lots,
|
||||
price=price,
|
||||
offset=off,
|
||||
)
|
||||
vt_orderid = self._engine.send_order(req, GATEWAY_NAME)
|
||||
if not vt_orderid:
|
||||
raise RuntimeError("CTP 拒单或未返回委托号")
|
||||
return str(vt_orderid)
|
||||
|
||||
|
||||
def get_bridge() -> CtpBridge:
|
||||
global _bridge
|
||||
with _bridge_lock:
|
||||
if _bridge is None:
|
||||
_bridge = CtpBridge()
|
||||
return _bridge
|
||||
|
||||
|
||||
def try_init_vnpy(_settings: dict | None = None) -> bool:
|
||||
return get_bridge().available()
|
||||
|
||||
|
||||
def vnpy_available() -> bool:
|
||||
return get_bridge().available()
|
||||
|
||||
|
||||
def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]:
|
||||
b = get_bridge()
|
||||
b.connect(mode, force=force)
|
||||
return b.status(mode)
|
||||
|
||||
|
||||
def ctp_status(mode: str) -> dict[str, Any]:
|
||||
return get_bridge().status(mode)
|
||||
|
||||
|
||||
def ctp_get_account(mode: str) -> dict[str, Any]:
|
||||
b = get_bridge()
|
||||
b.ensure_connected(mode)
|
||||
return b.get_account()
|
||||
|
||||
|
||||
def ctp_list_positions(mode: str) -> list[dict[str, Any]]:
|
||||
b = get_bridge()
|
||||
b.ensure_connected(mode)
|
||||
return b.list_positions()
|
||||
|
||||
|
||||
def get_ctp_balance(mode: str) -> Optional[float]:
|
||||
try:
|
||||
acc = ctp_get_account(mode)
|
||||
bal = acc.get("balance")
|
||||
return float(bal) if bal else None
|
||||
except Exception as exc:
|
||||
logger.debug("get_ctp_balance: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def execute_order(
|
||||
conn,
|
||||
*,
|
||||
mode: str,
|
||||
offset: str,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
lots: int,
|
||||
price: float,
|
||||
settings: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""统一下单:simulation=SimNow,live=期货公司 CTP。"""
|
||||
del conn, settings
|
||||
if mode not in ("simulation", "live"):
|
||||
raise ValueError("未知交易模式")
|
||||
if not vnpy_available():
|
||||
raise ValueError(
|
||||
"请先安装 vnpy 与 vnpy_ctp:pip install vnpy vnpy_ctp\n"
|
||||
f"模拟盘需配置 .env 中 SIMNOW_USER / SIMNOW_PASSWORD 等"
|
||||
)
|
||||
b = get_bridge()
|
||||
b.ensure_connected(mode)
|
||||
order_id = b.send_order(
|
||||
ths_code=symbol,
|
||||
offset=offset,
|
||||
direction=direction,
|
||||
lots=lots,
|
||||
price=price,
|
||||
)
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"mode": mode,
|
||||
"mode_label": _mode_label(mode),
|
||||
"symbol": symbol,
|
||||
"lots": lots,
|
||||
"price": price,
|
||||
}
|
||||
Reference in New Issue
Block a user