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:
dekun
2026-06-29 22:04:44 +08:00
parent b460c6c4e5
commit d8c6428eb5
7 changed files with 303 additions and 54 deletions
+155 -11
View File
@@ -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