Enhance dashboard with exchange labels, split SL/TP columns, and daily risk limits.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,3 +50,6 @@ RISK_COOLING_HOURS_MANUAL=4
|
|||||||
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
MAX_ACTIVE_POSITIONS=1
|
MAX_ACTIVE_POSITIONS=1
|
||||||
|
RISK_DAILY_POSITION_LIMIT=5
|
||||||
|
RISK_DAILY_TRADING_RISK_PCT=2
|
||||||
|
TRADING_DAY_RESET_HOUR=8
|
||||||
|
|||||||
+23
-9
@@ -37,13 +37,17 @@ def build_risk_overview(
|
|||||||
margin_used: Optional[float] = None,
|
margin_used: Optional[float] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from risk.account_risk_lib import (
|
from risk.account_risk_lib import (
|
||||||
|
cooling_hours_manual,
|
||||||
|
cooling_hours_manual_journal,
|
||||||
|
count_daily_opens,
|
||||||
|
daily_position_limit,
|
||||||
|
daily_trading_risk_pct_limit,
|
||||||
|
daily_trading_risk_used_pct,
|
||||||
ensure_account_risk_schema,
|
ensure_account_risk_schema,
|
||||||
get_risk_status,
|
get_risk_status,
|
||||||
manual_close_daily_limit,
|
manual_close_daily_limit,
|
||||||
max_active_positions,
|
max_active_positions,
|
||||||
risk_control_enabled,
|
risk_control_enabled,
|
||||||
cooling_hours_manual,
|
|
||||||
cooling_hours_manual_journal,
|
|
||||||
trading_day_label,
|
trading_day_label,
|
||||||
trading_day_reset_hour,
|
trading_day_reset_hour,
|
||||||
)
|
)
|
||||||
@@ -52,12 +56,11 @@ def build_risk_overview(
|
|||||||
get_fixed_lots,
|
get_fixed_lots,
|
||||||
get_max_margin_pct,
|
get_max_margin_pct,
|
||||||
get_roll_max_margin_pct,
|
get_roll_max_margin_pct,
|
||||||
get_risk_percent,
|
|
||||||
get_sizing_mode,
|
get_sizing_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
ensure_account_risk_schema(conn)
|
ensure_account_risk_schema(conn)
|
||||||
risk = dict(get_risk_status(conn) or {})
|
risk = dict(get_risk_status(conn, equity=equity) or {})
|
||||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||||
td = trading_day_label()
|
td = trading_day_label()
|
||||||
stored_td = str(row["trading_day"] or "") if row else ""
|
stored_td = str(row["trading_day"] or "") if row else ""
|
||||||
@@ -71,20 +74,28 @@ def build_risk_overview(
|
|||||||
sizing = get_sizing_mode(get_setting)
|
sizing = get_sizing_mode(get_setting)
|
||||||
sizing_label = "固定金额" if sizing == "amount" else "固定手数"
|
sizing_label = "固定金额" if sizing == "amount" else "固定手数"
|
||||||
|
|
||||||
|
daily_opens = int(risk.get("daily_open_count") or count_daily_opens(conn))
|
||||||
|
daily_risk_used = risk.get("daily_risk_used_pct")
|
||||||
|
if daily_risk_used is None and equity and equity > 0:
|
||||||
|
daily_risk_used = daily_trading_risk_used_pct(conn, float(equity))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": risk_control_enabled(),
|
"enabled": risk_control_enabled(),
|
||||||
"status": risk,
|
"status": risk,
|
||||||
"manual_close_count_today": manual_count,
|
"manual_close_count_today": manual_count,
|
||||||
"margin_pct_used": margin_pct_used,
|
"margin_pct_used": margin_pct_used,
|
||||||
|
"daily_open_count": daily_opens,
|
||||||
|
"daily_risk_used_pct": daily_risk_used,
|
||||||
"limits": {
|
"limits": {
|
||||||
"max_active_positions": max_active_positions(),
|
"max_active_positions": max_active_positions(),
|
||||||
|
"daily_position_limit": daily_position_limit(),
|
||||||
|
"daily_trading_risk_pct_limit": daily_trading_risk_pct_limit(),
|
||||||
"manual_close_daily_limit": manual_close_daily_limit(),
|
"manual_close_daily_limit": manual_close_daily_limit(),
|
||||||
"cooling_hours_manual": cooling_hours_manual(),
|
"cooling_hours_manual": cooling_hours_manual(),
|
||||||
"cooling_hours_manual_journal": cooling_hours_manual_journal(),
|
"cooling_hours_manual_journal": cooling_hours_manual_journal(),
|
||||||
"trading_day_reset_hour": trading_day_reset_hour(),
|
"trading_day_reset_hour": trading_day_reset_hour(),
|
||||||
"max_margin_pct": max_margin,
|
"max_margin_pct": max_margin,
|
||||||
"roll_max_margin_pct": get_roll_max_margin_pct(get_setting),
|
"roll_max_margin_pct": get_roll_max_margin_pct(get_setting),
|
||||||
"risk_percent": get_risk_percent(get_setting),
|
|
||||||
"sizing_mode": sizing,
|
"sizing_mode": sizing,
|
||||||
"sizing_label": sizing_label,
|
"sizing_label": sizing_label,
|
||||||
"fixed_lots": get_fixed_lots(get_setting),
|
"fixed_lots": get_fixed_lots(get_setting),
|
||||||
@@ -167,11 +178,12 @@ def build_dashboard_payload(
|
|||||||
dist_upper = round(upper - float(price), 2)
|
dist_upper = round(upper - float(price), 2)
|
||||||
dist_lower = round(float(price) - lower, 2)
|
dist_lower = round(float(price) - lower, 2)
|
||||||
mtype = r["monitor_type"] or ""
|
mtype = r["monitor_type"] or ""
|
||||||
|
sf = _symbol_fields(sym)
|
||||||
keys.append({
|
keys.append({
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"symbol": sym,
|
"symbol": sym,
|
||||||
"symbol_name": r["symbol_name"] or sym,
|
**sf,
|
||||||
**_symbol_fields(sym),
|
"symbol_name": r["symbol_name"] or sf.get("symbol_name") or sym,
|
||||||
"monitor_type": mtype,
|
"monitor_type": mtype,
|
||||||
"direction": r["direction"] or "",
|
"direction": r["direction"] or "",
|
||||||
"direction_label": _direction_label(r["direction"] or "long")
|
"direction_label": _direction_label(r["direction"] or "long")
|
||||||
@@ -200,11 +212,13 @@ def build_dashboard_payload(
|
|||||||
closes: list[dict[str, Any]] = []
|
closes: list[dict[str, Any]] = []
|
||||||
for r in close_rows:
|
for r in close_rows:
|
||||||
sym_code = r["symbol"] or ""
|
sym_code = r["symbol"] or ""
|
||||||
|
sf = _symbol_fields(sym_code)
|
||||||
closes.append({
|
closes.append({
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"symbol": r["symbol_name"] or sym_code,
|
"symbol": r["symbol_name"] or sf.get("symbol_name") or sym_code,
|
||||||
"symbol_code": sym_code,
|
"symbol_code": sym_code,
|
||||||
**_symbol_fields(sym_code),
|
**sf,
|
||||||
|
"symbol_name": r["symbol_name"] or sf.get("symbol_name") or sym_code,
|
||||||
"direction": r["direction"] or "long",
|
"direction": r["direction"] or "long",
|
||||||
"direction_label": _direction_label(r["direction"] or "long"),
|
"direction_label": _direction_label(r["direction"] or "long"),
|
||||||
"lots": float(r["lots"] or 0),
|
"lots": float(r["lots"] or 0),
|
||||||
|
|||||||
+27
-7
@@ -1564,7 +1564,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
rows = _apply_account_margin_to_rows(rows, mode, capital)
|
rows = _apply_account_margin_to_rows(rows, mode, capital)
|
||||||
_persist_ctp_snapshot_to_monitors(conn, rows, mode)
|
_persist_ctp_snapshot_to_monitors(conn, rows, mode)
|
||||||
pending_orders = _build_pending_orders(conn, mode)
|
pending_orders = _build_pending_orders(conn, mode)
|
||||||
risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
|
risk = get_risk_status(
|
||||||
|
conn,
|
||||||
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
|
equity=capital,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
@@ -1753,7 +1757,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
ctp_st = ctp_status(mode)
|
ctp_st = ctp_status(mode)
|
||||||
capital = _capital(conn)
|
capital = _capital(conn)
|
||||||
recommend_capital = _recommend_capital(conn)
|
recommend_capital = _recommend_capital(conn)
|
||||||
risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
|
risk = get_risk_status(
|
||||||
|
conn,
|
||||||
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
|
equity=capital,
|
||||||
|
)
|
||||||
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
|
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
|
||||||
active_trend = conn.execute(
|
active_trend = conn.execute(
|
||||||
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1"
|
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1"
|
||||||
@@ -2387,7 +2395,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
if d.get("trailing_be") and not d.get("stop_loss"):
|
if d.get("trailing_be") and not d.get("stop_loss"):
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400
|
return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400
|
||||||
err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode))
|
err = assert_can_open(
|
||||||
|
conn,
|
||||||
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
|
equity=_capital(conn),
|
||||||
|
)
|
||||||
if err:
|
if err:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": err}), 403
|
return jsonify({"ok": False, "error": err}), 403
|
||||||
@@ -2675,7 +2687,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
mode = get_trading_mode(get_setting)
|
mode = get_trading_mode(get_setting)
|
||||||
ctp_st = ctp_status(mode)
|
ctp_st = ctp_status(mode)
|
||||||
capital = _capital(conn)
|
capital = _capital(conn)
|
||||||
risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
|
risk = get_risk_status(
|
||||||
|
conn,
|
||||||
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
|
equity=capital,
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
|
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
|
||||||
positions = _ctp_positions(mode) if ctp_st.get("connected") else []
|
positions = _ctp_positions(mode) if ctp_st.get("connected") else []
|
||||||
@@ -2800,11 +2816,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
sym = (d.get("symbol") or "").strip()
|
sym = (d.get("symbol") or "").strip()
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
init_strategy_tables(conn)
|
init_strategy_tables(conn)
|
||||||
err = assert_can_open(conn)
|
capital = _capital(conn)
|
||||||
|
err = assert_can_open(conn, equity=capital)
|
||||||
if err:
|
if err:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": err}), 403
|
return jsonify({"ok": False, "error": err}), 403
|
||||||
capital = _capital(conn)
|
|
||||||
scope_err = assert_product_allowed_for_capital(
|
scope_err = assert_product_allowed_for_capital(
|
||||||
sym, capital, ctp_connected=is_ctp_connected(get_setting),
|
sym, capital, ctp_connected=is_ctp_connected(get_setting),
|
||||||
)
|
)
|
||||||
@@ -3405,7 +3421,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
sl, tp = calc_breakout_sl_tp(
|
sl, tp = calc_breakout_sl_tp(
|
||||||
sym=sym, direction=direction, entry=entry, bar=bar, risk_reward=rr,
|
sym=sym, direction=direction, entry=entry, bar=bar, risk_reward=rr,
|
||||||
)
|
)
|
||||||
err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode))
|
err = assert_can_open(
|
||||||
|
conn,
|
||||||
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
|
equity=_capital(conn),
|
||||||
|
)
|
||||||
if err:
|
if err:
|
||||||
_notify(False, err, entry=entry, sl=sl, tp=tp, lots=0)
|
_notify(False, err, entry=entry, sl=sl, tp=tp, lots=0)
|
||||||
return False, err
|
return False, err
|
||||||
|
|||||||
+155
-11
@@ -78,6 +78,22 @@ def max_active_positions() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def daily_position_limit() -> int:
|
||||||
|
"""当日最多开仓次数(含已平)。"""
|
||||||
|
try:
|
||||||
|
return max(1, int(os.getenv("RISK_DAILY_POSITION_LIMIT", "5")))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def daily_trading_risk_pct_limit() -> float:
|
||||||
|
"""当日累计止损风险占权益上限(%)。"""
|
||||||
|
try:
|
||||||
|
return max(0.1, float(os.getenv("RISK_DAILY_TRADING_RISK_PCT", "2")))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 2.0
|
||||||
|
|
||||||
|
|
||||||
def trading_day_reset_hour() -> int:
|
def trading_day_reset_hour() -> int:
|
||||||
try:
|
try:
|
||||||
return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))))
|
return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))))
|
||||||
@@ -151,6 +167,90 @@ def trading_day_label(now: Optional[datetime] = None) -> str:
|
|||||||
return dt.date().isoformat()
|
return dt.date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def trading_day_start(now: Optional[datetime] = None) -> datetime:
|
||||||
|
dt = now or datetime.now(_app_tz())
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=_app_tz())
|
||||||
|
reset_h = trading_day_reset_hour()
|
||||||
|
start = dt.replace(hour=reset_h, minute=0, second=0, microsecond=0)
|
||||||
|
if dt.hour < reset_h:
|
||||||
|
from datetime import timedelta
|
||||||
|
start = start - timedelta(days=1)
|
||||||
|
return start
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_open_time_ms(open_time: str) -> Optional[int]:
|
||||||
|
s = (open_time or "").strip().replace("T", " ")[:19]
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=_app_tz())
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(s[:10], "%Y-%m-%d").replace(tzinfo=_app_tz())
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _opened_in_trading_day(open_time: str, now: Optional[datetime] = None) -> bool:
|
||||||
|
oms = _parse_open_time_ms(open_time)
|
||||||
|
if oms is None:
|
||||||
|
return False
|
||||||
|
return oms >= int(trading_day_start(now).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def count_daily_opens(conn, now: Optional[datetime] = None) -> int:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT open_time FROM trade_order_monitors "
|
||||||
|
"WHERE open_time IS NOT NULL AND trim(open_time) <> ''"
|
||||||
|
).fetchall()
|
||||||
|
return sum(1 for r in rows if _opened_in_trading_day(r["open_time"], now))
|
||||||
|
|
||||||
|
|
||||||
|
def daily_trading_risk_used_pct(
|
||||||
|
conn, equity: float, now: Optional[datetime] = None,
|
||||||
|
) -> Optional[float]:
|
||||||
|
if equity <= 0:
|
||||||
|
return None
|
||||||
|
from contract_specs import calc_position_metrics
|
||||||
|
|
||||||
|
total = 0.0
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT symbol, direction, lots, entry_price, stop_loss, take_profit, open_time
|
||||||
|
FROM trade_order_monitors
|
||||||
|
WHERE open_time IS NOT NULL AND trim(open_time) <> ''"""
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
if not _opened_in_trading_day(r["open_time"], now):
|
||||||
|
continue
|
||||||
|
entry = float(r["entry_price"] or 0)
|
||||||
|
if entry <= 0:
|
||||||
|
continue
|
||||||
|
sl = float(r["stop_loss"] if r["stop_loss"] is not None else entry)
|
||||||
|
tp = float(r["take_profit"] if r["take_profit"] is not None else entry)
|
||||||
|
lots = int(r["lots"] or 0)
|
||||||
|
if lots <= 0:
|
||||||
|
continue
|
||||||
|
m = calc_position_metrics(
|
||||||
|
r["direction"] or "long",
|
||||||
|
entry,
|
||||||
|
sl,
|
||||||
|
tp,
|
||||||
|
lots,
|
||||||
|
entry,
|
||||||
|
equity,
|
||||||
|
r["symbol"] or "",
|
||||||
|
)
|
||||||
|
total += float(m.get("risk_amount") or 0)
|
||||||
|
if total <= 0:
|
||||||
|
return 0.0
|
||||||
|
return round(total / equity * 100, 2)
|
||||||
|
|
||||||
|
|
||||||
def count_active_trade_monitors(conn) -> int:
|
def count_active_trade_monitors(conn) -> int:
|
||||||
try:
|
try:
|
||||||
n = conn.execute(
|
n = conn.execute(
|
||||||
@@ -232,7 +332,13 @@ def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[dateti
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_risk_status(conn, *, now: Optional[datetime] = None, active_count: Optional[int] = None) -> dict:
|
def get_risk_status(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
active_count: Optional[int] = None,
|
||||||
|
equity: Optional[float] = None,
|
||||||
|
) -> dict:
|
||||||
def _load() -> dict:
|
def _load() -> dict:
|
||||||
ensure_account_risk_schema(conn)
|
ensure_account_risk_schema(conn)
|
||||||
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||||
@@ -252,56 +358,94 @@ def get_risk_status(conn, *, now: Optional[datetime] = None, active_count: Optio
|
|||||||
active = count_active_trade_monitors(conn) if active_count is None else int(active_count)
|
active = count_active_trade_monitors(conn) if active_count is None else int(active_count)
|
||||||
mx = max_active_positions()
|
mx = max_active_positions()
|
||||||
pos_limit = active >= mx
|
pos_limit = active >= mx
|
||||||
|
daily_opens = count_daily_opens(conn, now)
|
||||||
|
daily_pos_lim = daily_position_limit()
|
||||||
|
daily_open_limit = daily_opens >= daily_pos_lim
|
||||||
|
daily_risk_used: Optional[float] = None
|
||||||
|
daily_risk_lim = daily_trading_risk_pct_limit()
|
||||||
|
daily_risk_limit_hit = False
|
||||||
|
if equity and float(equity) > 0:
|
||||||
|
daily_risk_used = daily_trading_risk_used_pct(conn, float(equity), now)
|
||||||
|
if daily_risk_used is not None and daily_risk_used >= daily_risk_lim:
|
||||||
|
daily_risk_limit_hit = True
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"active_count": active,
|
||||||
|
"max_active_positions": mx,
|
||||||
|
"daily_open_count": daily_opens,
|
||||||
|
"daily_position_limit": daily_pos_lim,
|
||||||
|
"daily_risk_used_pct": daily_risk_used,
|
||||||
|
"daily_trading_risk_pct_limit": daily_risk_lim,
|
||||||
|
}
|
||||||
|
|
||||||
if daily:
|
if daily:
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"status": STATUS_DAILY,
|
"status": STATUS_DAILY,
|
||||||
"status_label": STATUS_LABELS[STATUS_DAILY],
|
"status_label": STATUS_LABELS[STATUS_DAILY],
|
||||||
"can_trade": False,
|
"can_trade": False,
|
||||||
"can_roll": False,
|
"can_roll": False,
|
||||||
"reason": "当日日冻结,禁止新开仓",
|
"reason": "当日日冻结,禁止新开仓",
|
||||||
"active_count": active,
|
|
||||||
"max_active_positions": mx,
|
|
||||||
}
|
}
|
||||||
if until and int(until) > now_ms:
|
if until and int(until) > now_ms:
|
||||||
rem = int((int(until) - now_ms) / 1000)
|
rem = int((int(until) - now_ms) / 1000)
|
||||||
hours = float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
|
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
|
st = STATUS_FREEZE_1H if hours <= cooling_hours_manual_journal() + 0.01 else STATUS_FREEZE_4H
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"status": st,
|
"status": st,
|
||||||
"status_label": STATUS_LABELS[st],
|
"status_label": STATUS_LABELS[st],
|
||||||
"can_trade": False,
|
"can_trade": False,
|
||||||
"can_roll": pos_limit,
|
"can_roll": pos_limit,
|
||||||
"reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m",
|
"reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m",
|
||||||
"freeze_remaining_sec": rem,
|
"freeze_remaining_sec": rem,
|
||||||
"active_count": active,
|
}
|
||||||
"max_active_positions": mx,
|
if daily_risk_limit_hit:
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"status": STATUS_DAILY,
|
||||||
|
"status_label": STATUS_LABELS[STATUS_DAILY],
|
||||||
|
"can_trade": False,
|
||||||
|
"can_roll": pos_limit,
|
||||||
|
"reason": f"已达日交易风险上限 {daily_risk_used:.2f}%/{daily_risk_lim:.2f}%",
|
||||||
|
}
|
||||||
|
if daily_open_limit:
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"status": STATUS_DAILY,
|
||||||
|
"status_label": STATUS_LABELS[STATUS_DAILY],
|
||||||
|
"can_trade": False,
|
||||||
|
"can_roll": pos_limit,
|
||||||
|
"reason": f"已达日持仓上限 {daily_opens}/{daily_pos_lim}",
|
||||||
}
|
}
|
||||||
if pos_limit:
|
if pos_limit:
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"status": STATUS_FREEZE_POSITION,
|
"status": STATUS_FREEZE_POSITION,
|
||||||
"status_label": STATUS_LABELS[STATUS_FREEZE_POSITION],
|
"status_label": STATUS_LABELS[STATUS_FREEZE_POSITION],
|
||||||
"can_trade": False,
|
"can_trade": False,
|
||||||
"can_roll": True,
|
"can_roll": True,
|
||||||
"reason": f"已达仓位上限 {active}/{mx}",
|
"reason": f"已达仓位上限 {active}/{mx}",
|
||||||
"active_count": active,
|
|
||||||
"max_active_positions": mx,
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"status": STATUS_NORMAL,
|
"status": STATUS_NORMAL,
|
||||||
"status_label": STATUS_LABELS[STATUS_NORMAL],
|
"status_label": STATUS_LABELS[STATUS_NORMAL],
|
||||||
"can_trade": True,
|
"can_trade": True,
|
||||||
"can_roll": True,
|
"can_roll": True,
|
||||||
"reason": "可新开仓",
|
"reason": "可新开仓",
|
||||||
"active_count": active,
|
|
||||||
"max_active_positions": mx,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return _db_retry(_load)
|
return _db_retry(_load)
|
||||||
|
|
||||||
|
|
||||||
def assert_can_open(conn, *, active_count: Optional[int] = None) -> Optional[str]:
|
def assert_can_open(
|
||||||
rs = get_risk_status(conn, active_count=active_count)
|
conn,
|
||||||
|
*,
|
||||||
|
active_count: Optional[int] = None,
|
||||||
|
equity: Optional[float] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
rs = get_risk_status(conn, active_count=active_count, equity=equity)
|
||||||
if not rs.get("can_trade"):
|
if not rs.get("can_trade"):
|
||||||
return rs.get("reason") or "当前不可开仓"
|
return rs.get("reason") or "当前不可开仓"
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -66,7 +66,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-risk-grid {
|
.dashboard-risk-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-item {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 5.8rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
text-align: center;
|
||||||
|
border-right: 1px solid var(--table-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-item:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-risk-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-title);
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-risk-grid .stat-item {
|
.dashboard-risk-grid .stat-item {
|
||||||
@@ -84,13 +118,12 @@
|
|||||||
|
|
||||||
.dash-symbol-title {
|
.dash-symbol-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.35;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-symbol-sub {
|
.dash-symbol-ex {
|
||||||
font-size: 0.72rem;
|
font-weight: 400;
|
||||||
line-height: 1.35;
|
font-size: 0.78rem;
|
||||||
margin-top: 0.12rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-main-badge {
|
.dash-main-badge {
|
||||||
|
|||||||
+54
-20
@@ -59,30 +59,42 @@
|
|||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function slTpText(row) {
|
function slText(row) {
|
||||||
var parts = [];
|
if (row.trailing_be && row.stop_loss == null) return '移动保本';
|
||||||
if (row.stop_loss != null) parts.push('SL ' + fmtNum(row.stop_loss));
|
if (row.stop_loss != null) return fmtNum(row.stop_loss);
|
||||||
if (row.take_profit != null) parts.push('TP ' + fmtNum(row.take_profit));
|
return '—';
|
||||||
if (row.trailing_be) parts.push('移动保本');
|
}
|
||||||
return parts.length ? parts.join(' · ') : '—';
|
|
||||||
|
function tpText(row) {
|
||||||
|
if (row.trailing_be) return '移动保本';
|
||||||
|
if (row.take_profit != null) return fmtNum(row.take_profit);
|
||||||
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function symbolCellHtml(row) {
|
function symbolCellHtml(row) {
|
||||||
var name = row.symbol_name || row.symbol || '';
|
var name = row.symbol_name || row.symbol || '';
|
||||||
var code = row.symbol_code || '';
|
var code = row.symbol_code || '';
|
||||||
|
if (!code && row.symbol && String(row.symbol).toLowerCase() !== String(name).toLowerCase()) {
|
||||||
|
code = row.symbol;
|
||||||
|
}
|
||||||
|
var exchange = row.symbol_exchange || '';
|
||||||
var mainBadge = row.symbol_is_main
|
var mainBadge = row.symbol_is_main
|
||||||
? ' <span class="badge planned dash-main-badge">主力</span>' : '';
|
? ' <span class="badge planned dash-main-badge">主力</span>' : '';
|
||||||
var titleInner = escHtml(name) + mainBadge;
|
var titleInner = escHtml(name);
|
||||||
|
if (exchange) {
|
||||||
|
titleInner += ' <span class="dash-symbol-ex text-muted">' + escHtml(exchange) + '</span>';
|
||||||
|
}
|
||||||
|
titleInner += mainBadge;
|
||||||
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
|
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
|
||||||
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span>';
|
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span>';
|
||||||
} else if (!name && code) {
|
} else if (!name && code) {
|
||||||
titleInner = '<span class="text-accent">' + escHtml(code) + '</span>';
|
titleInner = (exchange
|
||||||
|
? '<span class="dash-symbol-ex text-muted">' + escHtml(exchange) + '</span> '
|
||||||
|
: '') + '<span class="text-accent">' + escHtml(code) + '</span>';
|
||||||
}
|
}
|
||||||
var sub = row.symbol_exchange || code || '';
|
|
||||||
return (
|
return (
|
||||||
'<div class="dash-symbol-cell">' +
|
'<div class="dash-symbol-cell">' +
|
||||||
'<div class="dash-symbol-title">' + titleInner + '</div>' +
|
'<div class="dash-symbol-title">' + titleInner + '</div>' +
|
||||||
(sub ? '<div class="dash-symbol-sub text-muted">' + escHtml(sub) + '</div>' : '') +
|
|
||||||
'</div>'
|
'</div>'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -132,6 +144,24 @@
|
|||||||
var maxPos = lim.max_active_positions != null ? lim.max_active_positions : st.max_active_positions;
|
var maxPos = lim.max_active_positions != null ? lim.max_active_positions : st.max_active_positions;
|
||||||
var manualCnt = risk.manual_close_count_today != null ? risk.manual_close_count_today : 0;
|
var manualCnt = risk.manual_close_count_today != null ? risk.manual_close_count_today : 0;
|
||||||
var manualLim = lim.manual_close_daily_limit;
|
var manualLim = lim.manual_close_daily_limit;
|
||||||
|
var dailyOpens = risk.daily_open_count != null
|
||||||
|
? risk.daily_open_count
|
||||||
|
: (st.daily_open_count != null ? st.daily_open_count : '—');
|
||||||
|
var dailyPosLim = lim.daily_position_limit != null
|
||||||
|
? lim.daily_position_limit
|
||||||
|
: st.daily_position_limit;
|
||||||
|
var dailyRiskUsed = risk.daily_risk_used_pct != null
|
||||||
|
? risk.daily_risk_used_pct
|
||||||
|
: st.daily_risk_used_pct;
|
||||||
|
var dailyRiskLim = lim.daily_trading_risk_pct_limit != null
|
||||||
|
? lim.daily_trading_risk_pct_limit
|
||||||
|
: st.daily_trading_risk_pct_limit;
|
||||||
|
var dailyRiskText = dailyRiskUsed != null ? fmtNum(dailyRiskUsed) + '%' : '—';
|
||||||
|
if (dailyRiskLim != null && dailyRiskUsed != null) {
|
||||||
|
dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%';
|
||||||
|
} else if (dailyRiskLim != null) {
|
||||||
|
dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%';
|
||||||
|
}
|
||||||
var marginPct = risk.margin_pct_used;
|
var marginPct = risk.margin_pct_used;
|
||||||
var maxMarginPct = lim.max_margin_pct;
|
var maxMarginPct = lim.max_margin_pct;
|
||||||
var marginPctText = marginPct != null ? fmtNum(marginPct) + '%' : '—';
|
var marginPctText = marginPct != null ? fmtNum(marginPct) + '%' : '—';
|
||||||
@@ -157,23 +187,24 @@
|
|||||||
var items = [
|
var items = [
|
||||||
{ label: '风控开关', value: enabled ? '开启' : '关闭' },
|
{ label: '风控开关', value: enabled ? '开启' : '关闭' },
|
||||||
{ label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') },
|
{ label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') },
|
||||||
{ label: '日手动平仓', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') },
|
{ label: '日持仓限制', value: dailyOpens + ' / ' + (dailyPosLim != null ? dailyPosLim : '—') },
|
||||||
|
{ label: '日交易风险', value: dailyRiskText },
|
||||||
|
{ label: '手动平仓(冷静期触发)', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') },
|
||||||
{ label: '冷静期(默认)', value: fmtHours(lim.cooling_hours_manual) },
|
{ label: '冷静期(默认)', value: fmtHours(lim.cooling_hours_manual) },
|
||||||
{ label: '复盘后冷静', value: fmtHours(lim.cooling_hours_manual_journal) },
|
{ label: '复盘后冷静', value: fmtHours(lim.cooling_hours_manual_journal) },
|
||||||
{ label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) },
|
{ label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) },
|
||||||
{ label: '保证金占比', value: marginPctText },
|
{ label: '保证金占比', value: marginPctText },
|
||||||
{ label: '保证金上限', value: maxMarginPct != null ? fmtNum(maxMarginPct) + '%' : '—' },
|
{ label: '保证金上限', value: maxMarginPct != null ? fmtNum(maxMarginPct) + '%' : '—' },
|
||||||
{ label: '滚仓保证金上限', value: lim.roll_max_margin_pct != null ? fmtNum(lim.roll_max_margin_pct) + '%' : '—' },
|
{ label: '综合保证金上限', value: lim.roll_max_margin_pct != null ? fmtNum(lim.roll_max_margin_pct) + '%' : '—' },
|
||||||
{ label: '计仓模式', value: sizingDetail },
|
{ label: '计仓模式', value: sizingDetail },
|
||||||
{ label: '单笔风险', value: lim.risk_percent != null ? fmtNum(lim.risk_percent) + '%' : '—' },
|
|
||||||
{ label: '交易日切', value: lim.trading_day_reset_hour != null ? lim.trading_day_reset_hour + ':00' : '—' }
|
{ label: '交易日切', value: lim.trading_day_reset_hour != null ? lim.trading_day_reset_hour + ':00' : '—' }
|
||||||
];
|
];
|
||||||
|
|
||||||
riskGridEl.innerHTML = items.map(function (it) {
|
riskGridEl.innerHTML = items.map(function (it) {
|
||||||
return (
|
return (
|
||||||
'<div class="stat-item">' +
|
'<div class="dashboard-risk-item">' +
|
||||||
'<div class="label">' + escHtml(it.label) + '</div>' +
|
'<div class="dashboard-risk-label">' + escHtml(it.label) + '</div>' +
|
||||||
'<div class="value">' + escHtml(it.value) + '</div>' +
|
'<div class="dashboard-risk-value">' + escHtml(it.value) + '</div>' +
|
||||||
'</div>'
|
'</div>'
|
||||||
);
|
);
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -320,7 +351,7 @@
|
|||||||
function renderPositions(rows) {
|
function renderPositions(rows) {
|
||||||
if (!posBody) return;
|
if (!posBody) return;
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
posBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无持仓</td></tr>';
|
posBody.innerHTML = '<tr><td colspan="9" class="text-muted">暂无持仓</td></tr>';
|
||||||
posRowCache = {};
|
posRowCache = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -337,7 +368,8 @@
|
|||||||
'<td class="dash-p-mark">' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '</td>' +
|
'<td class="dash-p-mark">' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '</td>' +
|
||||||
'<td class="dash-p-pnl ' + pnlClass(r.float_pnl) + '">' + fmtPnl(r.float_pnl) + '</td>' +
|
'<td class="dash-p-pnl ' + pnlClass(r.float_pnl) + '">' + fmtPnl(r.float_pnl) + '</td>' +
|
||||||
'<td class="dash-p-margin">' + (r.margin != null ? fmtMoney(r.margin) : '—') + '</td>' +
|
'<td class="dash-p-margin">' + (r.margin != null ? fmtMoney(r.margin) : '—') + '</td>' +
|
||||||
'<td class="dash-p-sltp">' + escHtml(slTpText(r)) + '</td>' +
|
'<td class="dash-p-sl">' + escHtml(slText(r)) + '</td>' +
|
||||||
|
'<td class="dash-p-tp">' + escHtml(tpText(r)) + '</td>' +
|
||||||
'</tr>'
|
'</tr>'
|
||||||
);
|
);
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -396,7 +428,8 @@
|
|||||||
var markEl = row.querySelector('.dash-p-mark');
|
var markEl = row.querySelector('.dash-p-mark');
|
||||||
var pnlEl = row.querySelector('.dash-p-pnl');
|
var pnlEl = row.querySelector('.dash-p-pnl');
|
||||||
var marginEl = row.querySelector('.dash-p-margin');
|
var marginEl = row.querySelector('.dash-p-margin');
|
||||||
var sltpEl = row.querySelector('.dash-p-sltp');
|
var slEl = row.querySelector('.dash-p-sl');
|
||||||
|
var tpEl = row.querySelector('.dash-p-tp');
|
||||||
if (lotsEl) lotsEl.textContent = String(r.lots);
|
if (lotsEl) lotsEl.textContent = String(r.lots);
|
||||||
if (entryEl) entryEl.textContent = fmtNum(r.entry_price);
|
if (entryEl) entryEl.textContent = fmtNum(r.entry_price);
|
||||||
if (markEl) markEl.textContent = r.current_price != null ? fmtNum(r.current_price) : '—';
|
if (markEl) markEl.textContent = r.current_price != null ? fmtNum(r.current_price) : '—';
|
||||||
@@ -405,7 +438,8 @@
|
|||||||
pnlEl.className = 'dash-p-pnl ' + pnlClass(r.float_pnl);
|
pnlEl.className = 'dash-p-pnl ' + pnlClass(r.float_pnl);
|
||||||
}
|
}
|
||||||
if (marginEl) marginEl.textContent = r.margin != null ? fmtMoney(r.margin) : '—';
|
if (marginEl) marginEl.textContent = r.margin != null ? fmtMoney(r.margin) : '—';
|
||||||
if (sltpEl) sltpEl.textContent = slTpText(r);
|
if (slEl) slEl.textContent = slText(r);
|
||||||
|
if (tpEl) tpEl.textContent = tpText(r);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,12 @@
|
|||||||
<th>现价</th>
|
<th>现价</th>
|
||||||
<th>浮盈亏</th>
|
<th>浮盈亏</th>
|
||||||
<th>保证金</th>
|
<th>保证金</th>
|
||||||
<th>止损/止盈</th>
|
<th>止损</th>
|
||||||
|
<th>止盈</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="dash-positions-body">
|
<tbody id="dash-positions-body">
|
||||||
<tr><td colspan="8" class="text-muted">加载中…</td></tr>
|
<tr><td colspan="9" class="text-muted">加载中…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user