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:
+155
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user