接入 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
|
PORT=6600
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
|
||||||
# Flask Session 密钥(部署时务必改为随机字符串,deploy.sh 首次会自动生成)
|
|
||||||
SECRET_KEY=change-this-to-a-random-secret-key
|
SECRET_KEY=change-this-to-a-random-secret-key
|
||||||
|
|
||||||
# 初始管理员(首次建库自动写入;已建库后修改需设 ADMIN_SYNC_FROM_ENV=true 并重启)
|
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=change-me-on-first-login
|
ADMIN_PASSWORD=change-me-on-first-login
|
||||||
ADMIN_SYNC_FROM_ENV=false
|
ADMIN_SYNC_FROM_ENV=false
|
||||||
|
|
||||||
# 企业微信 Webhook(也可在系统设置页面修改)
|
|
||||||
WECHAT_WEBHOOK=
|
WECHAT_WEBHOOK=
|
||||||
|
|
||||||
# 行情数据源: sina(默认,免费)| auto(有机构 token 时优先同花顺)| ths
|
|
||||||
QUOTE_SOURCE=sina
|
QUOTE_SOURCE=sina
|
||||||
|
|
||||||
# 同花顺 iFinD refresh_token(仅机构用户,普通用户留空即可)
|
|
||||||
THS_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_stream import kline_hub, sse_format
|
||||||
from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS
|
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 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"))
|
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,
|
data_json TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL)''')
|
updated_at TEXT NOT NULL)''')
|
||||||
ensure_kline_tables(conn)
|
ensure_kline_tables(conn)
|
||||||
|
init_strategy_tables(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -322,6 +326,12 @@ def init_db():
|
|||||||
|
|
||||||
if not get_setting("fee_multiplier"):
|
if not get_setting("fee_multiplier"):
|
||||||
set_setting("fee_multiplier", "2")
|
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()
|
conn = get_db()
|
||||||
fee_cnt = conn.execute("SELECT COUNT(*) FROM fee_rates").fetchone()[0]
|
fee_cnt = conn.execute("SELECT COUNT(*) FROM fee_rates").fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -569,6 +579,9 @@ def background_task():
|
|||||||
expire_old_plans()
|
expire_old_plans()
|
||||||
check_key_monitors()
|
check_key_monitors()
|
||||||
check_order_plans()
|
check_order_plans()
|
||||||
|
fn = getattr(app, "_check_trend_plans", None)
|
||||||
|
if fn:
|
||||||
|
fn(app)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
@@ -583,8 +596,6 @@ def start_background_threads():
|
|||||||
threading.Thread(target=refresh_main_index, daemon=True).start()
|
threading.Thread(target=refresh_main_index, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
start_background_threads()
|
|
||||||
|
|
||||||
# —————————————— 登录 ——————————————
|
# —————————————— 登录 ——————————————
|
||||||
|
|
||||||
def login_required(f):
|
def login_required(f):
|
||||||
@@ -1278,6 +1289,14 @@ def add_review():
|
|||||||
d.get("notes", "").strip(),
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
touch_stats_cache()
|
touch_stats_cache()
|
||||||
@@ -1535,9 +1554,25 @@ def settings():
|
|||||||
flash("实盘资金不能为负数")
|
flash("实盘资金不能为负数")
|
||||||
else:
|
else:
|
||||||
set_setting("live_capital", str(val))
|
set_setting("live_capital", str(val))
|
||||||
flash("实盘资金已保存")
|
flash("参考资金已保存(CTP 已连接时以 SimNow/柜台权益为准)")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash("请输入有效的实盘资金金额")
|
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":
|
elif action == "password":
|
||||||
old_p = request.form.get("old_password", "")
|
old_p = request.form.get("old_password", "")
|
||||||
new_p = request.form.get("new_password", "")
|
new_p = request.form.get("new_password", "")
|
||||||
@@ -1563,8 +1598,26 @@ def settings():
|
|||||||
username=username,
|
username=username,
|
||||||
live_capital=live_capital,
|
live_capital=live_capital,
|
||||||
quote_label=get_quote_source_label(),
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+17
-10
@@ -2,13 +2,13 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Optional
|
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] = {
|
_SPEC_BY_THS: dict[str, dict] = {
|
||||||
"ag": {"mult": 15, "margin_rate": 0.14},
|
"ag": {"mult": 15, "margin_rate": 0.14, "tick_size": 1.0},
|
||||||
"au": {"mult": 1000, "margin_rate": 0.10},
|
"au": {"mult": 1000, "margin_rate": 0.10, "tick_size": 0.02},
|
||||||
"cu": {"mult": 5, "margin_rate": 0.10},
|
"cu": {"mult": 5, "margin_rate": 0.10, "tick_size": 10.0},
|
||||||
"al": {"mult": 5, "margin_rate": 0.10},
|
"al": {"mult": 5, "margin_rate": 0.10},
|
||||||
"zn": {"mult": 5, "margin_rate": 0.10},
|
"zn": {"mult": 5, "margin_rate": 0.10},
|
||||||
"pb": {"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},
|
"AP": {"mult": 10, "margin_rate": 0.10},
|
||||||
"CJ": {"mult": 5, "margin_rate": 0.10},
|
"CJ": {"mult": 5, "margin_rate": 0.10},
|
||||||
"PK": {"mult": 5, "margin_rate": 0.10},
|
"PK": {"mult": 5, "margin_rate": 0.10},
|
||||||
"IF": {"mult": 300, "margin_rate": 0.12},
|
"IF": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
|
||||||
"IH": {"mult": 300, "margin_rate": 0.12},
|
"IH": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
|
||||||
"IC": {"mult": 200, "margin_rate": 0.12},
|
"IC": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
|
||||||
"IM": {"mult": 200, "margin_rate": 0.12},
|
"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)
|
letters = m.group(1)
|
||||||
spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower())
|
spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower())
|
||||||
if spec:
|
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)
|
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
|
Werkzeug==3.0.3
|
||||||
matplotlib==3.9.2
|
matplotlib==3.9.2
|
||||||
akshare==1.18.64
|
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}
|
||||||
+80
-16
@@ -242,18 +242,32 @@
|
|||||||
|
|
||||||
function getDataZoom(c, preserve) {
|
function getDataZoom(c, preserve) {
|
||||||
var defStart = getDefaultZoomStart();
|
var defStart = getDefaultZoomStart();
|
||||||
var zoom = [
|
var xZoom = {
|
||||||
{
|
|
||||||
type: 'inside',
|
type: 'inside',
|
||||||
|
id: 'dzInsideX',
|
||||||
xAxisIndex: [0, 1],
|
xAxisIndex: [0, 1],
|
||||||
start: defStart,
|
start: defStart,
|
||||||
end: 100,
|
end: 100,
|
||||||
|
filterMode: 'none',
|
||||||
zoomOnMouseWheel: true,
|
zoomOnMouseWheel: true,
|
||||||
moveOnMouseMove: true,
|
moveOnMouseMove: true,
|
||||||
moveOnMouseWheel: false,
|
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',
|
type: 'slider',
|
||||||
|
id: 'dzSlider',
|
||||||
xAxisIndex: [0, 1],
|
xAxisIndex: [0, 1],
|
||||||
start: defStart,
|
start: defStart,
|
||||||
end: 100,
|
end: 100,
|
||||||
@@ -264,19 +278,23 @@
|
|||||||
fillerColor: c.area,
|
fillerColor: c.area,
|
||||||
handleStyle: { color: c.sliderFill },
|
handleStyle: { color: c.sliderFill },
|
||||||
dataBackground: {
|
dataBackground: {
|
||||||
lineStyle: { color: c.grid },
|
lineStyle: { color: c.grid, opacity: 0.35 },
|
||||||
areaStyle: { color: c.area },
|
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) {
|
if (preserve && chart) {
|
||||||
var opt = chart.getOption();
|
var opt = chart.getOption();
|
||||||
if (opt && opt.dataZoom) {
|
if (opt && opt.dataZoom) {
|
||||||
opt.dataZoom.forEach(function (z, i) {
|
opt.dataZoom.forEach(function (z) {
|
||||||
if (zoom[i] && z.start != null && z.end != null) {
|
if (!z.id) return;
|
||||||
zoom[i].start = z.start;
|
var target = zoom.find(function (t) { return t.id === z.id; });
|
||||||
zoom[i].end = z.end;
|
if (target && z.start != null && z.end != null) {
|
||||||
|
target.start = z.start;
|
||||||
|
target.end = z.end;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -284,6 +302,11 @@
|
|||||||
return zoom;
|
return zoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFollowingLatest() {
|
||||||
|
var z = getZoomRange();
|
||||||
|
return z.end >= 98;
|
||||||
|
}
|
||||||
|
|
||||||
function mapSeriesData(bars, values, gapDay) {
|
function mapSeriesData(bars, values, gapDay) {
|
||||||
if (!gapDay) return values;
|
if (!gapDay) return values;
|
||||||
return bars.map(function (b, i) {
|
return bars.map(function (b, i) {
|
||||||
@@ -303,6 +326,7 @@
|
|||||||
var times = bars.map(function (b) { return b.time; });
|
var times = bars.map(function (b) { return b.time; });
|
||||||
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
||||||
var gapDay = chartOpts.gapDay;
|
var gapDay = chartOpts.gapDay;
|
||||||
|
var followLatest = preserveZoom && isFollowingLatest();
|
||||||
var dataZoom = getDataZoom(c, preserveZoom);
|
var dataZoom = getDataZoom(c, preserveZoom);
|
||||||
var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end };
|
var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end };
|
||||||
var vIdx = visibleIndices(bars, zoom);
|
var vIdx = visibleIndices(bars, zoom);
|
||||||
@@ -326,6 +350,7 @@
|
|||||||
boundaryGap: gapDay ? false : true,
|
boundaryGap: gapDay ? false : true,
|
||||||
axisLabel: { color: c.text, fontSize: 10 },
|
axisLabel: { color: c.text, fontSize: 10 },
|
||||||
axisLine: { lineStyle: { color: c.grid } },
|
axisLine: { lineStyle: { color: c.grid } },
|
||||||
|
splitLine: { show: false },
|
||||||
};
|
};
|
||||||
var xAxis1 = {
|
var xAxis1 = {
|
||||||
type: xAxisType,
|
type: xAxisType,
|
||||||
@@ -333,6 +358,7 @@
|
|||||||
boundaryGap: gapDay ? false : true,
|
boundaryGap: gapDay ? false : true,
|
||||||
axisLabel: { show: false },
|
axisLabel: { show: false },
|
||||||
axisLine: { lineStyle: { color: c.grid } },
|
axisLine: { lineStyle: { color: c.grid } },
|
||||||
|
splitLine: { show: false },
|
||||||
};
|
};
|
||||||
if (!gapDay) {
|
if (!gapDay) {
|
||||||
xAxis0.data = times;
|
xAxis0.data = times;
|
||||||
@@ -344,14 +370,27 @@
|
|||||||
animation: false,
|
animation: false,
|
||||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||||
dataZoom: dataZoom,
|
|
||||||
grid: grids,
|
grid: grids,
|
||||||
xAxis: [xAxis0, xAxis1],
|
xAxis: [xAxis0, xAxis1],
|
||||||
yAxis: [
|
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 series = [];
|
||||||
var mainMark = {
|
var mainMark = {
|
||||||
@@ -465,7 +504,12 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preserveZoom) {
|
||||||
|
chart.setOption(Object.assign(base, { series: series }), false);
|
||||||
|
} else {
|
||||||
chart.setOption(Object.assign(base, { series: series }), true);
|
chart.setOption(Object.assign(base, { series: series }), true);
|
||||||
|
dataZoomBound = false;
|
||||||
|
}
|
||||||
|
|
||||||
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
|
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
@@ -478,6 +522,22 @@
|
|||||||
} : { show: false },
|
} : { 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();
|
bindDataZoomHL();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,12 +716,16 @@
|
|||||||
if (start === 0) end = newSpan;
|
if (start === 0) end = newSpan;
|
||||||
else start = 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() {
|
function resetDataZoom() {
|
||||||
if (!chart) return;
|
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() {
|
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>
|
<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>
|
<button type="button" class="nav-backdrop" id="nav-backdrop" aria-label="关闭菜单" hidden></button>
|
||||||
<nav class="site-nav" id="site-nav">
|
<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('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('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
|
||||||
<a href="{{ url_for('positions') }}" class="{% if request.endpoint == 'positions' %}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-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
|
||||||
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
|
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">数据来源:新浪财经。K 线由后台自动刷新并经 SSE 推送到前端;支持滚轮/拖拽缩放。可视区内自动标注最高/最低价。</p>
|
<p class="hint">数据来源:新浪财经。拖拽左右平移、滚轮缩放;按住图表上下拖动可平移价格轴。可视区内自动标注最高/最低价。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="card">
|
<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">
|
<form action="{{ url_for('settings') }}" method="post" class="form-row">
|
||||||
<input type="hidden" name="action" value="capital">
|
<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>
|
<button type="submit" class="btn-primary">保存</button>
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
<div class="card">
|
<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