diff --git a/.env.example b/.env.example index f288cec..d20c6a0 100644 --- a/.env.example +++ b/.env.example @@ -50,3 +50,6 @@ RISK_COOLING_HOURS_MANUAL=4 RISK_COOLING_HOURS_MANUAL_JOURNAL=1 RISK_MANUAL_CLOSE_DAILY_LIMIT=2 MAX_ACTIVE_POSITIONS=1 +RISK_DAILY_POSITION_LIMIT=5 +RISK_DAILY_TRADING_RISK_PCT=2 +TRADING_DAY_RESET_HOUR=8 diff --git a/dashboard_lib.py b/dashboard_lib.py index 4b09254..ae55f15 100644 --- a/dashboard_lib.py +++ b/dashboard_lib.py @@ -37,13 +37,17 @@ def build_risk_overview( margin_used: Optional[float] = None, ) -> dict[str, Any]: 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, get_risk_status, manual_close_daily_limit, max_active_positions, risk_control_enabled, - cooling_hours_manual, - cooling_hours_manual_journal, trading_day_label, trading_day_reset_hour, ) @@ -52,12 +56,11 @@ def build_risk_overview( get_fixed_lots, get_max_margin_pct, get_roll_max_margin_pct, - get_risk_percent, get_sizing_mode, ) 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() td = trading_day_label() 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_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 { "enabled": risk_control_enabled(), "status": risk, "manual_close_count_today": manual_count, "margin_pct_used": margin_pct_used, + "daily_open_count": daily_opens, + "daily_risk_used_pct": daily_risk_used, "limits": { "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(), "cooling_hours_manual": cooling_hours_manual(), "cooling_hours_manual_journal": cooling_hours_manual_journal(), "trading_day_reset_hour": trading_day_reset_hour(), "max_margin_pct": max_margin, "roll_max_margin_pct": get_roll_max_margin_pct(get_setting), - "risk_percent": get_risk_percent(get_setting), "sizing_mode": sizing, "sizing_label": sizing_label, "fixed_lots": get_fixed_lots(get_setting), @@ -167,11 +178,12 @@ def build_dashboard_payload( dist_upper = round(upper - float(price), 2) dist_lower = round(float(price) - lower, 2) mtype = r["monitor_type"] or "" + sf = _symbol_fields(sym) keys.append({ "id": r["id"], "symbol": sym, - "symbol_name": r["symbol_name"] or sym, - **_symbol_fields(sym), + **sf, + "symbol_name": r["symbol_name"] or sf.get("symbol_name") or sym, "monitor_type": mtype, "direction": r["direction"] or "", "direction_label": _direction_label(r["direction"] or "long") @@ -200,11 +212,13 @@ def build_dashboard_payload( closes: list[dict[str, Any]] = [] for r in close_rows: sym_code = r["symbol"] or "" + sf = _symbol_fields(sym_code) closes.append({ "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_fields(sym_code), + **sf, + "symbol_name": r["symbol_name"] or sf.get("symbol_name") or sym_code, "direction": r["direction"] or "long", "direction_label": _direction_label(r["direction"] or "long"), "lots": float(r["lots"] or 0), diff --git a/install_trading.py b/install_trading.py index 9a12dfe..4218e9f 100644 --- a/install_trading.py +++ b/install_trading.py @@ -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) _persist_ctp_snapshot_to_monitors(conn, rows, 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 { "ok": True, "rows": rows, @@ -1753,7 +1757,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se ctp_st = ctp_status(mode) capital = _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 {} active_trend = conn.execute( "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"): conn.close() 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: conn.close() 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) ctp_st = ctp_status(mode) 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() ctp_acc = _ctp_account(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() conn = get_db() init_strategy_tables(conn) - err = assert_can_open(conn) + capital = _capital(conn) + err = assert_can_open(conn, equity=capital) if err: conn.close() return jsonify({"ok": False, "error": err}), 403 - capital = _capital(conn) scope_err = assert_product_allowed_for_capital( 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( 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: _notify(False, err, entry=entry, sl=sl, tp=tp, lots=0) return False, err diff --git a/risk/account_risk_lib.py b/risk/account_risk_lib.py index 0ba3b4c..215042b 100644 --- a/risk/account_risk_lib.py +++ b/risk/account_risk_lib.py @@ -78,6 +78,22 @@ def max_active_positions() -> int: 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: try: 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() +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: try: 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: ensure_account_risk_schema(conn) 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) mx = max_active_positions() 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: return { + **base, "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 { + **base, "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 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: return { + **base, "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 { + **base, "status": STATUS_NORMAL, "status_label": STATUS_LABELS[STATUS_NORMAL], "can_trade": True, "can_roll": True, "reason": "可新开仓", - "active_count": active, - "max_active_positions": mx, } return _db_retry(_load) -def assert_can_open(conn, *, active_count: Optional[int] = None) -> Optional[str]: - rs = get_risk_status(conn, active_count=active_count) +def assert_can_open( + 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"): return rs.get("reason") or "当前不可开仓" return None diff --git a/static/css/dashboard.css b/static/css/dashboard.css index c4a4fe3..e3c561e 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -66,7 +66,41 @@ } .dashboard-risk-grid { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 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 { @@ -84,13 +118,12 @@ .dash-symbol-title { font-weight: 600; - line-height: 1.35; + line-height: 1.45; } -.dash-symbol-sub { - font-size: 0.72rem; - line-height: 1.35; - margin-top: 0.12rem; +.dash-symbol-ex { + font-weight: 400; + font-size: 0.78rem; } .dash-main-badge { diff --git a/static/js/dashboard.js b/static/js/dashboard.js index e8743ed..510af80 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -59,30 +59,42 @@ .replace(/"/g, '"'); } - function slTpText(row) { - var parts = []; - if (row.stop_loss != null) parts.push('SL ' + fmtNum(row.stop_loss)); - if (row.take_profit != null) parts.push('TP ' + fmtNum(row.take_profit)); - if (row.trailing_be) parts.push('移动保本'); - return parts.length ? parts.join(' · ') : '—'; + function slText(row) { + if (row.trailing_be && row.stop_loss == null) return '移动保本'; + if (row.stop_loss != null) return fmtNum(row.stop_loss); + return '—'; + } + + function tpText(row) { + if (row.trailing_be) return '移动保本'; + if (row.take_profit != null) return fmtNum(row.take_profit); + return '—'; } function symbolCellHtml(row) { var name = row.symbol_name || row.symbol || ''; 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 titleInner = escHtml(name) + mainBadge; + var titleInner = escHtml(name); + if (exchange) { + titleInner += ' ' + escHtml(exchange) + ''; + } + titleInner += mainBadge; if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { titleInner += ' ' + escHtml(code) + ''; } else if (!name && code) { - titleInner = '' + escHtml(code) + ''; + titleInner = (exchange + ? '' + escHtml(exchange) + ' ' + : '') + '' + escHtml(code) + ''; } - var sub = row.symbol_exchange || code || ''; return ( '
' + '
' + titleInner + '
' + - (sub ? '
' + escHtml(sub) + '
' : '') + '
' ); } @@ -132,6 +144,24 @@ 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 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 maxMarginPct = lim.max_margin_pct; var marginPctText = marginPct != null ? fmtNum(marginPct) + '%' : '—'; @@ -157,23 +187,24 @@ var items = [ { label: '风控开关', value: enabled ? '开启' : '关闭' }, { 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_journal) }, { label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) }, { label: '保证金占比', value: marginPctText }, { 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: lim.risk_percent != null ? fmtNum(lim.risk_percent) + '%' : '—' }, { label: '交易日切', value: lim.trading_day_reset_hour != null ? lim.trading_day_reset_hour + ':00' : '—' } ]; riskGridEl.innerHTML = items.map(function (it) { return ( - '
' + - '
' + escHtml(it.label) + '
' + - '
' + escHtml(it.value) + '
' + + '
' + + '
' + escHtml(it.label) + '
' + + '
' + escHtml(it.value) + '
' + '
' ); }).join(''); @@ -320,7 +351,7 @@ function renderPositions(rows) { if (!posBody) return; if (!rows.length) { - posBody.innerHTML = '暂无持仓'; + posBody.innerHTML = '暂无持仓'; posRowCache = {}; return; } @@ -337,7 +368,8 @@ '' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '' + '' + fmtPnl(r.float_pnl) + '' + '' + (r.margin != null ? fmtMoney(r.margin) : '—') + '' + - '' + escHtml(slTpText(r)) + '' + + '' + escHtml(slText(r)) + '' + + '' + escHtml(tpText(r)) + '' + '' ); }).join(''); @@ -396,7 +428,8 @@ var markEl = row.querySelector('.dash-p-mark'); var pnlEl = row.querySelector('.dash-p-pnl'); 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 (entryEl) entryEl.textContent = fmtNum(r.entry_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); } 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); }); } } diff --git a/templates/dashboard.html b/templates/dashboard.html index 636518d..179ccb4 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -51,11 +51,12 @@ 现价 浮盈亏 保证金 - 止损/止盈 + 止损 + 止盈 - 加载中… + 加载中…