feat: 止盈止损秒级监控市价平仓记交易记录,并加手数超限提醒。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,7 @@ from flask import flash, jsonify, redirect, render_template, request, url_for, R
|
|||||||
from contract_specs import calc_position_metrics, get_contract_spec
|
from contract_specs import calc_position_metrics, get_contract_spec
|
||||||
from fee_specs import calc_fee_breakdown
|
from fee_specs import calc_fee_breakdown
|
||||||
from kline_stream import sse_format
|
from kline_stream import sse_format
|
||||||
|
from market_sessions import is_trading_session
|
||||||
from position_sizing import (
|
from position_sizing import (
|
||||||
MODE_FIXED,
|
MODE_FIXED,
|
||||||
MODE_RISK,
|
MODE_RISK,
|
||||||
@@ -479,6 +480,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
"ctp_status": ctp_st,
|
"ctp_status": ctp_st,
|
||||||
"trading_mode_label": trading_mode_label(get_setting),
|
"trading_mode_label": trading_mode_label(get_setting),
|
||||||
"risk_status": risk,
|
"risk_status": risk,
|
||||||
|
"trading_session": is_trading_session(),
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1411,6 +1413,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
db_path=DB_PATH,
|
db_path=DB_PATH,
|
||||||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||||
init_tables_fn=_init_tables,
|
init_tables_fn=_init_tables,
|
||||||
|
get_capital_fn=_capital,
|
||||||
|
notify_fn=send_wechat_msg,
|
||||||
|
interval=1,
|
||||||
)
|
)
|
||||||
start_ctp_fee_worker(
|
start_ctp_fee_worker(
|
||||||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||||
|
|||||||
+191
-16
@@ -4,10 +4,15 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from contract_specs import get_contract_spec
|
from contract_specs import calc_position_metrics
|
||||||
from ctp_symbol import ths_to_vnpy_symbol
|
from ctp_symbol import ths_to_vnpy_symbol
|
||||||
|
from fee_specs import calc_round_trip_fee
|
||||||
|
from market_sessions import is_trading_session
|
||||||
|
from symbols import ths_to_codes
|
||||||
from vnpy_bridge import (
|
from vnpy_bridge import (
|
||||||
ctp_cancel_order,
|
ctp_cancel_order,
|
||||||
ctp_get_tick_price,
|
ctp_get_tick_price,
|
||||||
@@ -19,10 +24,15 @@ from vnpy_bridge import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CHECK_INTERVAL_SEC = 5
|
TZ = ZoneInfo("Asia/Shanghai")
|
||||||
PLACE_COOLDOWN_SEC = 30
|
CHECK_INTERVAL_SEC = 1
|
||||||
|
CLOSED_MARKET_SLEEP_SEC = 30
|
||||||
|
DISCONNECTED_SLEEP_SEC = 5
|
||||||
|
PLACE_COOLDOWN_SEC = 3
|
||||||
|
|
||||||
_last_close_attempt: dict[int, float] = {}
|
_last_close_attempt: dict[int, float] = {}
|
||||||
|
_closing_monitors: set[int] = set()
|
||||||
|
_closing_lock = threading.Lock()
|
||||||
|
|
||||||
MONITOR_ORDER_COLUMNS = (
|
MONITOR_ORDER_COLUMNS = (
|
||||||
"ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT",
|
"ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT",
|
||||||
@@ -39,6 +49,7 @@ def ensure_monitor_order_columns(conn) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _tick_size(ths_code: str) -> float:
|
def _tick_size(ths_code: str) -> float:
|
||||||
|
from contract_specs import get_contract_spec
|
||||||
return float(get_contract_spec(ths_code).get("tick_size") or 1.0)
|
return float(get_contract_spec(ths_code).get("tick_size") or 1.0)
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +117,105 @@ def _mark_close_attempt(monitor_id: int) -> None:
|
|||||||
_last_close_attempt[monitor_id] = time.time()
|
_last_close_attempt[monitor_id] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _try_acquire_close(monitor_id: int) -> bool:
|
||||||
|
with _closing_lock:
|
||||||
|
if monitor_id in _closing_monitors:
|
||||||
|
return False
|
||||||
|
_closing_monitors.add(monitor_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _release_close(monitor_id: int) -> None:
|
||||||
|
with _closing_lock:
|
||||||
|
_closing_monitors.discard(monitor_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_type_label(raw: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"manual": "期货下单",
|
||||||
|
"trend": "趋势回调",
|
||||||
|
"roll": "顺势加仓",
|
||||||
|
}
|
||||||
|
return mapping.get(raw or "", raw or "程序监控")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_trade_log(
|
||||||
|
conn,
|
||||||
|
mon: dict,
|
||||||
|
*,
|
||||||
|
close_price: float,
|
||||||
|
reason: str,
|
||||||
|
trading_mode: str,
|
||||||
|
capital: float = 0.0,
|
||||||
|
) -> None:
|
||||||
|
"""止盈/止损触发平仓后写入 trade_logs。"""
|
||||||
|
sym = (mon.get("symbol") or "").strip()
|
||||||
|
direction = (mon.get("direction") or "long").strip().lower()
|
||||||
|
entry = float(mon.get("entry_price") or close_price)
|
||||||
|
sl_raw = mon.get("stop_loss")
|
||||||
|
tp_raw = mon.get("take_profit")
|
||||||
|
sl = float(sl_raw) if sl_raw is not None else entry
|
||||||
|
tp = float(tp_raw) if tp_raw is not None else entry
|
||||||
|
lots = float(mon.get("lots") or 1)
|
||||||
|
open_time = (mon.get("open_time") or "").strip()
|
||||||
|
close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
|
||||||
|
|
||||||
|
codes = ths_to_codes(sym) or {}
|
||||||
|
sina_code = codes.get("sina_code") or ""
|
||||||
|
symbol_name = mon.get("symbol_name") or sym
|
||||||
|
market_code = mon.get("market_code") or codes.get("market_code") or ""
|
||||||
|
|
||||||
|
metrics = calc_position_metrics(
|
||||||
|
direction, entry, sl, tp, lots, close_price, capital, sym,
|
||||||
|
)
|
||||||
|
pnl = metrics.get("float_pnl") or 0.0
|
||||||
|
fee = calc_round_trip_fee(
|
||||||
|
sym, entry, close_price, lots, open_time, close_time, trading_mode=trading_mode,
|
||||||
|
)
|
||||||
|
pnl_net = round(pnl - fee, 2)
|
||||||
|
result = "止盈" if reason == "take_profit" else "止损"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app import holding_to_minutes
|
||||||
|
minutes = holding_to_minutes(open_time, close_time)
|
||||||
|
except Exception:
|
||||||
|
minutes = 0
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO trade_logs
|
||||||
|
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
|
||||||
|
entry_price, stop_loss, take_profit, close_price, lots, margin,
|
||||||
|
holding_minutes, open_time, close_time, pnl, fee, pnl_net, result)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
sym,
|
||||||
|
symbol_name,
|
||||||
|
market_code,
|
||||||
|
sina_code,
|
||||||
|
_monitor_type_label(mon.get("monitor_type") or ""),
|
||||||
|
direction,
|
||||||
|
entry,
|
||||||
|
sl_raw if sl_raw is not None else sl,
|
||||||
|
tp_raw if tp_raw is not None else tp,
|
||||||
|
close_price,
|
||||||
|
lots,
|
||||||
|
metrics.get("margin"),
|
||||||
|
minutes,
|
||||||
|
open_time,
|
||||||
|
close_time,
|
||||||
|
pnl,
|
||||||
|
fee,
|
||||||
|
pnl_net,
|
||||||
|
result,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from stats_engine import refresh_stats_cache
|
||||||
|
refresh_stats_cache(conn, capital)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("stats refresh after SL/TP close: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool:
|
def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool:
|
||||||
buf = max(tick * 0.01, 1e-9)
|
buf = max(tick * 0.01, 1e-9)
|
||||||
if direction == "long":
|
if direction == "long":
|
||||||
@@ -216,6 +326,8 @@ def _execute_local_close(
|
|||||||
mode: str,
|
mode: str,
|
||||||
mark: float,
|
mark: float,
|
||||||
reason: str,
|
reason: str,
|
||||||
|
capital: float = 0.0,
|
||||||
|
notify_fn: Callable[[str], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
sym = (mon.get("symbol") or "").strip()
|
sym = (mon.get("symbol") or "").strip()
|
||||||
direction = (mon.get("direction") or "long").strip().lower()
|
direction = (mon.get("direction") or "long").strip().lower()
|
||||||
@@ -237,19 +349,41 @@ def _execute_local_close(
|
|||||||
price=mark,
|
price=mark,
|
||||||
order_type="market",
|
order_type="market",
|
||||||
)
|
)
|
||||||
|
_write_trade_log(
|
||||||
|
conn,
|
||||||
|
mon,
|
||||||
|
close_price=mark,
|
||||||
|
reason=reason,
|
||||||
|
trading_mode=mode,
|
||||||
|
capital=capital,
|
||||||
|
)
|
||||||
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],))
|
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
label = "止盈" if reason == "take_profit" else "止损"
|
||||||
logger.info(
|
logger.info(
|
||||||
"止盈止损本地触发 monitor=%s reason=%s %s %s %d手 @%s",
|
"止盈止损本地触发 monitor=%s reason=%s %s %s %d手 @%s",
|
||||||
mon.get("id"), reason, sym, direction, lots, mark,
|
mon.get("id"), reason, sym, direction, lots, mark,
|
||||||
)
|
)
|
||||||
|
if notify_fn:
|
||||||
|
try:
|
||||||
|
notify_fn(f"{label}平仓 {sym} {direction} {lots}手 @{mark},已记入交易记录")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("SL/TP notify failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def check_monitors_locally(conn, mode: str) -> int:
|
def check_monitors_locally(
|
||||||
"""扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻平仓。"""
|
conn,
|
||||||
|
mode: str,
|
||||||
|
*,
|
||||||
|
capital: float = 0.0,
|
||||||
|
notify_fn: Callable[[str], None] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻市价平仓并记交易记录。"""
|
||||||
ensure_monitor_order_columns(conn)
|
ensure_monitor_order_columns(conn)
|
||||||
if not ctp_status(mode).get("connected"):
|
if not ctp_status(mode).get("connected"):
|
||||||
return 0
|
return 0
|
||||||
|
if not is_trading_session():
|
||||||
|
return 0
|
||||||
reconcile_monitors_without_position(conn, mode)
|
reconcile_monitors_without_position(conn, mode)
|
||||||
closed = 0
|
closed = 0
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -293,12 +427,26 @@ def check_monitors_locally(conn, mode: str) -> int:
|
|||||||
continue
|
continue
|
||||||
if mid > 0 and not _can_close_now(mid):
|
if mid > 0 and not _can_close_now(mid):
|
||||||
continue
|
continue
|
||||||
|
if mid > 0 and not _try_acquire_close(mid):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
_mark_close_attempt(mid)
|
_execute_local_close(
|
||||||
_execute_local_close(conn, mon, mode=mode, mark=mark, reason=reason)
|
conn,
|
||||||
|
mon,
|
||||||
|
mode=mode,
|
||||||
|
mark=mark,
|
||||||
|
reason=reason,
|
||||||
|
capital=capital,
|
||||||
|
notify_fn=notify_fn,
|
||||||
|
)
|
||||||
|
if mid > 0:
|
||||||
|
_mark_close_attempt(mid)
|
||||||
closed += 1
|
closed += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("SL/TP local close failed monitor=%s: %s", mid, exc)
|
logger.warning("SL/TP local close failed monitor=%s: %s", mid, exc)
|
||||||
|
finally:
|
||||||
|
if mid > 0:
|
||||||
|
_release_close(mid)
|
||||||
return closed
|
return closed
|
||||||
|
|
||||||
|
|
||||||
@@ -358,6 +506,8 @@ def start_sl_tp_guard_worker(
|
|||||||
db_path: str,
|
db_path: str,
|
||||||
get_mode_fn: Callable[[], str],
|
get_mode_fn: Callable[[], str],
|
||||||
init_tables_fn: Callable | None = None,
|
init_tables_fn: Callable | None = None,
|
||||||
|
get_capital_fn: Callable | None = None,
|
||||||
|
notify_fn: Callable[[str], None] | None = None,
|
||||||
interval: int = CHECK_INTERVAL_SEC,
|
interval: int = CHECK_INTERVAL_SEC,
|
||||||
) -> None:
|
) -> None:
|
||||||
from db_conn import connect_db
|
from db_conn import connect_db
|
||||||
@@ -365,20 +515,45 @@ def start_sl_tp_guard_worker(
|
|||||||
def _loop() -> None:
|
def _loop() -> None:
|
||||||
time.sleep(8)
|
time.sleep(8)
|
||||||
while True:
|
while True:
|
||||||
|
sleep_sec = max(1, interval)
|
||||||
try:
|
try:
|
||||||
|
if not is_trading_session():
|
||||||
|
time.sleep(CLOSED_MARKET_SLEEP_SEC)
|
||||||
|
continue
|
||||||
mode = get_mode_fn()
|
mode = get_mode_fn()
|
||||||
if ctp_status(mode).get("connected"):
|
if not ctp_status(mode).get("connected"):
|
||||||
conn = connect_db(db_path)
|
time.sleep(DISCONNECTED_SLEEP_SEC)
|
||||||
try:
|
continue
|
||||||
if init_tables_fn:
|
conn = connect_db(db_path)
|
||||||
init_tables_fn(conn)
|
try:
|
||||||
n = check_monitors_locally(conn, mode)
|
if init_tables_fn:
|
||||||
|
init_tables_fn(conn)
|
||||||
|
has_monitors = conn.execute(
|
||||||
|
"""SELECT COUNT(*) AS n FROM trade_order_monitors
|
||||||
|
WHERE status='active'
|
||||||
|
AND (stop_loss IS NOT NULL OR take_profit IS NOT NULL)"""
|
||||||
|
).fetchone()["n"]
|
||||||
|
if not has_monitors:
|
||||||
|
sleep_sec = max(sleep_sec, 5)
|
||||||
|
else:
|
||||||
|
capital = 0.0
|
||||||
|
if get_capital_fn:
|
||||||
|
try:
|
||||||
|
capital = float(get_capital_fn(conn) or 0)
|
||||||
|
except Exception:
|
||||||
|
capital = 0.0
|
||||||
|
n = check_monitors_locally(
|
||||||
|
conn,
|
||||||
|
mode,
|
||||||
|
capital=capital,
|
||||||
|
notify_fn=notify_fn,
|
||||||
|
)
|
||||||
if n:
|
if n:
|
||||||
logger.info("止盈止损本地监控: 触发平仓 %d 笔", n)
|
logger.info("止盈止损本地监控: 触发平仓 %d 笔", n)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("sl_tp_guard worker: %s", exc)
|
logger.warning("sl_tp_guard worker: %s", exc)
|
||||||
time.sleep(max(3, interval))
|
time.sleep(sleep_sec)
|
||||||
|
|
||||||
threading.Thread(target=_loop, daemon=True, name="sl-tp-guard").start()
|
threading.Thread(target=_loop, daemon=True, name="sl-tp-guard").start()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)}
|
.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)}
|
||||||
.trade-field select,.trade-field input{width:100%;box-sizing:border-box}
|
.trade-field select,.trade-field input{width:100%;box-sizing:border-box}
|
||||||
.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default}
|
.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default}
|
||||||
|
.lots-warn{font-size:.7rem;margin-top:.25rem;margin-bottom:0}
|
||||||
.price-type-tabs{display:flex;gap:.35rem;margin-bottom:.35rem}
|
.price-type-tabs{display:flex;gap:.35rem;margin-bottom:.35rem}
|
||||||
.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center;width:auto}
|
.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center;width:auto}
|
||||||
.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)}
|
.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)}
|
||||||
|
|||||||
+100
-4
@@ -19,6 +19,13 @@
|
|||||||
var priceType = 'limit';
|
var priceType = 'limit';
|
||||||
var lastCtpReconnectAt = 0;
|
var lastCtpReconnectAt = 0;
|
||||||
var ctpReconnecting = false;
|
var ctpReconnecting = false;
|
||||||
|
var isTradingSession = false;
|
||||||
|
var hasSlTpMonitoring = false;
|
||||||
|
var ctpConnected = false;
|
||||||
|
var pollIntervalMs = 0;
|
||||||
|
var selectedMaxLots = null;
|
||||||
|
var recommendMaxByProduct = {};
|
||||||
|
var recommendMaxByCode = {};
|
||||||
|
|
||||||
function runWhenReady(fn) {
|
function runWhenReady(fn) {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
@@ -52,6 +59,63 @@
|
|||||||
return parseInt(lotsInput && lotsInput.value, 10) || 1;
|
return parseInt(lotsInput && lotsInput.value, 10) || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateRecommendMaxMaps(data) {
|
||||||
|
recommendMaxByProduct = {};
|
||||||
|
recommendMaxByCode = {};
|
||||||
|
(data && data.rows || []).forEach(function (r) {
|
||||||
|
if (!r || r.max_lots <= 0) return;
|
||||||
|
if (r.status !== 'ok' && r.status !== 'margin_ok') return;
|
||||||
|
if (r.ths) recommendMaxByProduct[String(r.ths).toLowerCase()] = r.max_lots;
|
||||||
|
if (r.main_code) recommendMaxByCode[String(r.main_code).toLowerCase()] = r.max_lots;
|
||||||
|
});
|
||||||
|
checkLotsLimit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxLotsForSymbol(sym) {
|
||||||
|
if (selectedMaxLots > 0) return selectedMaxLots;
|
||||||
|
var code = (sym || '').trim().toLowerCase();
|
||||||
|
if (!code) return 0;
|
||||||
|
if (recommendMaxByCode[code]) return recommendMaxByCode[code];
|
||||||
|
var m = code.match(/^([a-z]+)/i);
|
||||||
|
if (m && recommendMaxByProduct[m[1].toLowerCase()]) {
|
||||||
|
return recommendMaxByProduct[m[1].toLowerCase()];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLotsLimit() {
|
||||||
|
var warn = document.getElementById('lots-warn');
|
||||||
|
if (!warn) return;
|
||||||
|
var sym = selectedSymbol();
|
||||||
|
var maxLots = maxLotsForSymbol(sym);
|
||||||
|
var lots = effectiveLots();
|
||||||
|
if (maxLots > 0 && lots > maxLots) {
|
||||||
|
warn.hidden = false;
|
||||||
|
warn.textContent = '已超过最大手数 ' + maxLots + ' 手,请调整手数';
|
||||||
|
} else {
|
||||||
|
warn.hidden = true;
|
||||||
|
warn.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePositionPoll() {
|
||||||
|
var nextMs = 0;
|
||||||
|
if (hasSlTpMonitoring && isTradingSession) {
|
||||||
|
nextMs = 1000;
|
||||||
|
} else if (!ctpConnected) {
|
||||||
|
nextMs = 5000;
|
||||||
|
}
|
||||||
|
if (nextMs === pollIntervalMs && pollTimer) return;
|
||||||
|
pollIntervalMs = nextMs;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
if (nextMs > 0) {
|
||||||
|
pollTimer = setInterval(pollPositions, nextMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function entryPrice() {
|
function entryPrice() {
|
||||||
if (priceType === 'market') return lastQuotePrice;
|
if (priceType === 'market') return lastQuotePrice;
|
||||||
return parseFloat(priceInput && priceInput.value) || 0;
|
return parseFloat(priceInput && priceInput.value) || 0;
|
||||||
@@ -201,6 +265,7 @@
|
|||||||
if (!sym || !entry || !sl) {
|
if (!sym || !entry || !sl) {
|
||||||
lotsCalc.value = '';
|
lotsCalc.value = '';
|
||||||
lotsCalc.placeholder = '填写止损后自动计算';
|
lotsCalc.placeholder = '填写止损后自动计算';
|
||||||
|
checkLotsLimit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lotsCalc.placeholder = '计算中…';
|
lotsCalc.placeholder = '计算中…';
|
||||||
@@ -223,6 +288,7 @@
|
|||||||
}
|
}
|
||||||
lotsCalc.value = data.lots;
|
lotsCalc.value = data.lots;
|
||||||
lotsCalc.placeholder = '填写止损后自动计算';
|
lotsCalc.placeholder = '填写止损后自动计算';
|
||||||
|
checkLotsLimit();
|
||||||
scheduleQuote();
|
scheduleQuote();
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
lotsCalc.placeholder = '计算失败';
|
lotsCalc.placeholder = '计算失败';
|
||||||
@@ -273,6 +339,11 @@
|
|||||||
showOrderMsg('请填写手数', false);
|
showOrderMsg('请填写手数', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var maxLots = maxLotsForSymbol(sym);
|
||||||
|
if (maxLots > 0 && lots > maxLots) {
|
||||||
|
showOrderMsg('手数 ' + lots + ' 超过最大手数 ' + maxLots + ' 手', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var btnOpen = document.getElementById('btn-open');
|
var btnOpen = document.getElementById('btn-open');
|
||||||
if (btnOpen) {
|
if (btnOpen) {
|
||||||
@@ -562,6 +633,8 @@
|
|||||||
if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2);
|
if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2);
|
||||||
var connected = data.ctp_status && data.ctp_status.connected;
|
var connected = data.ctp_status && data.ctp_status.connected;
|
||||||
var connecting = data.ctp_status && data.ctp_status.connecting;
|
var connecting = data.ctp_status && data.ctp_status.connecting;
|
||||||
|
ctpConnected = !!connected;
|
||||||
|
isTradingSession = !!data.trading_session;
|
||||||
updateCtpBadge(!!connected, !!connecting);
|
updateCtpBadge(!!connected, !!connecting);
|
||||||
var riskBadge = document.getElementById('risk-badge');
|
var riskBadge = document.getElementById('risk-badge');
|
||||||
if (riskBadge && data.risk_status) {
|
if (riskBadge && data.risk_status) {
|
||||||
@@ -569,6 +642,10 @@
|
|||||||
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
|
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
|
||||||
}
|
}
|
||||||
var rows = data.rows || [];
|
var rows = data.rows || [];
|
||||||
|
hasSlTpMonitoring = rows.some(function (row) {
|
||||||
|
return row.stop_loss != null || row.take_profit != null;
|
||||||
|
});
|
||||||
|
schedulePositionPoll();
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
if (connecting) {
|
if (connecting) {
|
||||||
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
|
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
|
||||||
@@ -618,6 +695,7 @@
|
|||||||
|
|
||||||
function renderRecommendations(data) {
|
function renderRecommendations(data) {
|
||||||
if (!recommendList || !data) return;
|
if (!recommendList || !data) return;
|
||||||
|
updateRecommendMaxMaps(data);
|
||||||
var recCap = document.getElementById('rec-capital');
|
var recCap = document.getElementById('rec-capital');
|
||||||
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
|
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
|
||||||
var recUpdated = document.getElementById('rec-updated');
|
var recUpdated = document.getElementById('rec-updated');
|
||||||
@@ -666,13 +744,25 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (symInput) {
|
if (symInput) {
|
||||||
symInput.addEventListener('input', function () { scheduleQuote(); scheduleAutoCalc(); });
|
symInput.addEventListener('input', function () {
|
||||||
symInput.addEventListener('symbol-selected', function () {
|
selectedMaxLots = null;
|
||||||
scheduleQuote();
|
scheduleQuote();
|
||||||
scheduleAutoCalc();
|
scheduleAutoCalc();
|
||||||
|
checkLotsLimit();
|
||||||
|
});
|
||||||
|
symInput.addEventListener('symbol-selected', function (ev) {
|
||||||
|
var item = ev.detail || {};
|
||||||
|
selectedMaxLots = item.max_lots > 0 ? item.max_lots : null;
|
||||||
|
scheduleQuote();
|
||||||
|
scheduleAutoCalc();
|
||||||
|
checkLotsLimit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (lotsInput) lotsInput.addEventListener('input', scheduleQuote);
|
if (lotsInput) lotsInput.addEventListener('input', function () {
|
||||||
|
scheduleQuote();
|
||||||
|
checkLotsLimit();
|
||||||
|
});
|
||||||
|
if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit);
|
||||||
if (slInput) slInput.addEventListener('input', scheduleAutoCalc);
|
if (slInput) slInput.addEventListener('input', scheduleAutoCalc);
|
||||||
if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc);
|
if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc);
|
||||||
if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc);
|
if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc);
|
||||||
@@ -697,7 +787,13 @@
|
|||||||
setPriceType('limit');
|
setPriceType('limit');
|
||||||
pollPositions();
|
pollPositions();
|
||||||
connectRecommendStream();
|
connectRecommendStream();
|
||||||
pollTimer = setInterval(pollPositions, 3000);
|
fetch('/api/recommend/list')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) { if (data.ok) renderRecommendations(data); })
|
||||||
|
.catch(function () {});
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (document.visibilityState === 'visible') pollPositions();
|
||||||
|
});
|
||||||
scheduleQuote();
|
scheduleQuote();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
<label class="text-label">手数</label>
|
<label class="text-label">手数</label>
|
||||||
<input type="number" id="trade-lots" min="1" step="1" value="1" {% if sizing_mode == 'risk' %}hidden{% endif %}>
|
<input type="number" id="trade-lots" min="1" step="1" value="1" {% if sizing_mode == 'risk' %}hidden{% endif %}>
|
||||||
<input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="填写止损后自动计算" {% if sizing_mode != 'risk' %}hidden{% endif %}>
|
<input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="填写止损后自动计算" {% if sizing_mode != 'risk' %}hidden{% endif %}>
|
||||||
|
<p class="hint lots-warn text-loss" id="lots-warn" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
|
|
||||||
<div class="card trade-card" id="positions">
|
<div class="card trade-card" id="positions">
|
||||||
<h2>持仓监控</h2>
|
<h2>持仓监控</h2>
|
||||||
<p class="hint pos-hint">数据来自 CTP 柜台(交易所回报),浮盈等为柜台实际值。</p>
|
<p class="hint pos-hint">数据来自 CTP 柜台;设止盈/止损后程序在开盘期间每秒监控,触发即市价平仓并记入交易记录。</p>
|
||||||
<div class="card-body card-scroll" id="position-live-list">
|
<div class="card-body card-scroll" id="position-live-list">
|
||||||
<div class="empty-hint">{% if ctp_status.connected %}加载中…{% else %}请先连接 CTP 查看柜台持仓{% endif %}</div>
|
<div class="empty-hint">{% if ctp_status.connected %}加载中…{% else %}请先连接 CTP 查看柜台持仓{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user