Initial standalone crypto_okx with one-click deploy.
Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -0,0 +1,912 @@
|
||||
"""账户冷静期 / 日冻结风控(三所实例共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
STATUS_NORMAL = "normal"
|
||||
STATUS_FREEZE_1H = "freeze_1h"
|
||||
STATUS_FREEZE_4H = "freeze_4h"
|
||||
STATUS_DAILY = "freeze_daily"
|
||||
STATUS_FREEZE_POSITION = "freeze_position"
|
||||
|
||||
STATUS_LABELS = {
|
||||
STATUS_NORMAL: "正常",
|
||||
STATUS_FREEZE_1H: "1h冻结",
|
||||
STATUS_FREEZE_4H: "4h冻结",
|
||||
STATUS_DAILY: "日冻结",
|
||||
STATUS_FREEZE_POSITION: "仓位上限冻结",
|
||||
}
|
||||
|
||||
MOOD_ISSUE_OPTIONS = (
|
||||
"怕踏空",
|
||||
"报复开仓",
|
||||
"盈利飘了",
|
||||
"拿不住单",
|
||||
"扛单",
|
||||
"重仓违规",
|
||||
)
|
||||
|
||||
# 仅以下来源计入「手动平仓」风控(用户主动点平仓/结束计划)
|
||||
CLOSE_SOURCE_USER_INSTANCE = "user_instance"
|
||||
CLOSE_SOURCE_USER_HUB = "user_hub"
|
||||
CLOSE_SOURCE_USER_TREND_STOP = "user_trend_stop"
|
||||
|
||||
USER_INITIATED_CLOSE_SOURCES = frozenset(
|
||||
{
|
||||
CLOSE_SOURCE_USER_INSTANCE,
|
||||
CLOSE_SOURCE_USER_HUB,
|
||||
CLOSE_SOURCE_USER_TREND_STOP,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool = True) -> bool:
|
||||
raw = (os.getenv(key) or "").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _env_hours(key: str, default: float) -> float:
|
||||
try:
|
||||
v = float(os.getenv(key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
v = default
|
||||
return max(0.0, v)
|
||||
|
||||
|
||||
def _app_tz():
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
name = (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip()
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def risk_control_enabled() -> bool:
|
||||
return _env_bool("RISK_CONTROL_ENABLED", True)
|
||||
|
||||
|
||||
def cooling_hours_manual() -> float:
|
||||
return _env_hours("RISK_COOLING_HOURS_MANUAL", 4.0)
|
||||
|
||||
|
||||
def cooling_hours_manual_journal() -> float:
|
||||
return _env_hours("RISK_COOLING_HOURS_MANUAL_JOURNAL", 1.0)
|
||||
|
||||
|
||||
def manual_close_daily_limit() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2")))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
|
||||
def daily_loss_limit() -> int:
|
||||
"""日亏损次数上限:达限当日冻结开仓;0=不因亏损次数冻结."""
|
||||
try:
|
||||
return max(0, int(os.getenv("RISK_DAILY_LOSS_LIMIT", "2")))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
|
||||
def max_active_positions_from_env(default: int = 1) -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return max(1, default)
|
||||
|
||||
|
||||
def position_limit_reached(
|
||||
conn,
|
||||
*,
|
||||
max_active_positions: Optional[int] = None,
|
||||
) -> tuple[bool, int, int]:
|
||||
"""(已达上限, 计入上限的活跃数, 上限值)."""
|
||||
from lib.trade.trade_labels_lib import count_position_limit_active_monitors
|
||||
|
||||
mx = max(1, int(max_active_positions if max_active_positions is not None else max_active_positions_from_env()))
|
||||
ac = count_position_limit_active_monitors(conn)
|
||||
return ac >= mx, ac, mx
|
||||
|
||||
|
||||
def mood_issues_daily_freeze_enabled() -> bool:
|
||||
return _env_bool("RISK_MOOD_ISSUES_DAILY_FREEZE", True)
|
||||
|
||||
|
||||
def ensure_account_risk_schema(conn) -> None:
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS account_risk_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
trading_day TEXT,
|
||||
manual_close_count INTEGER DEFAULT 0,
|
||||
daily_loss_count INTEGER DEFAULT 0,
|
||||
cooloff_until_ms INTEGER,
|
||||
cooloff_hours INTEGER,
|
||||
daily_frozen INTEGER DEFAULT 0,
|
||||
pending_journal_trade_id INTEGER,
|
||||
last_close_at_ms INTEGER,
|
||||
updated_at TEXT
|
||||
)"""
|
||||
)
|
||||
cols = {
|
||||
str(r[1])
|
||||
for r in conn.execute("PRAGMA table_info(account_risk_state)").fetchall()
|
||||
}
|
||||
if "daily_loss_count" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE account_risk_state ADD COLUMN daily_loss_count INTEGER DEFAULT 0"
|
||||
)
|
||||
row = conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone()
|
||||
if not row:
|
||||
conn.execute(
|
||||
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_loss_count, daily_frozen) VALUES (1, '', 0, 0, 0)"
|
||||
)
|
||||
|
||||
|
||||
def _row_get(row, key, default=None):
|
||||
if row is None:
|
||||
return default
|
||||
try:
|
||||
return row[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _now_ms(now: Optional[datetime] = None) -> int:
|
||||
dt = now or datetime.now()
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=_app_tz())
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
def _normalize_epoch_ms(ms: int, ref_now_ms: Optional[int] = None) -> int:
|
||||
"""修正旧版把北京时间 naive 当作 UTC 写入的 epoch 毫秒."""
|
||||
tz = _app_tz()
|
||||
off = datetime.now(tz).utcoffset()
|
||||
if not off:
|
||||
return int(ms)
|
||||
offset_ms = int(off.total_seconds() * 1000)
|
||||
if offset_ms == 0:
|
||||
return int(ms)
|
||||
ref = int(ref_now_ms) if ref_now_ms is not None else _now_ms(datetime.now(tz))
|
||||
corrected = int(ms) - offset_ms
|
||||
if abs(int(ms) - ref) <= abs(corrected - ref):
|
||||
return int(ms)
|
||||
return corrected
|
||||
|
||||
|
||||
def _sanitize_last_close_ms(last_ms: int, now_ms: int) -> Optional[int]:
|
||||
"""平仓时刻须不晚于当前(允许 1 分钟时钟偏差);显著未来视为无效锚点."""
|
||||
slack_ms = 60 * 1000
|
||||
if last_ms > now_ms + slack_ms:
|
||||
return None
|
||||
return last_ms
|
||||
|
||||
|
||||
def _cooloff_duration_ms(hours: float) -> int:
|
||||
return int(max(0.0, float(hours)) * 3600 * 1000)
|
||||
|
||||
|
||||
def _cooloff_hours_value(row) -> float:
|
||||
return float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
|
||||
|
||||
|
||||
def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
|
||||
"""冷静期结束 = last_close + cooloff_hours;无效/已过期锚点不再重启计时."""
|
||||
hours = _cooloff_hours_value(row)
|
||||
journal_h = cooling_hours_manual_journal()
|
||||
duration_ms = _cooloff_duration_ms(hours)
|
||||
last_raw = _row_get(row, "last_close_at_ms")
|
||||
stored_raw = _cooloff_until_ms(row)
|
||||
|
||||
if last_raw is not None:
|
||||
try:
|
||||
last_ms = _sanitize_last_close_ms(
|
||||
_normalize_epoch_ms(int(last_raw), now_ms), now_ms
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
last_ms = None
|
||||
if last_ms is not None:
|
||||
end_ms = last_ms + duration_ms
|
||||
if end_ms > now_ms:
|
||||
return end_ms
|
||||
if hours <= journal_h + 1e-6:
|
||||
return None
|
||||
|
||||
if stored_raw is None:
|
||||
return None
|
||||
stored_ms = _normalize_epoch_ms(int(stored_raw), now_ms)
|
||||
return stored_ms if stored_ms > now_ms else None
|
||||
|
||||
|
||||
def _clear_inactive_cooloff(
|
||||
conn,
|
||||
*,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""冷静期已结束或锚点无效时清库,避免重启后误读旧冻结."""
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
cooloff_until_ms=NULL,
|
||||
cooloff_hours=NULL,
|
||||
last_close_at_ms=NULL,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
|
||||
)
|
||||
|
||||
|
||||
def _freeze_tier_from_remaining_ms(remaining_ms: int, hours: float) -> str:
|
||||
journal_h = cooling_hours_manual_journal()
|
||||
rh = remaining_ms / 3600000.0
|
||||
if rh <= journal_h + (5 / 60):
|
||||
return STATUS_FREEZE_1H
|
||||
return STATUS_FREEZE_4H
|
||||
|
||||
|
||||
def _freeze_status_label(hours: float, status: str) -> str:
|
||||
if status == STATUS_FREEZE_1H:
|
||||
return STATUS_LABELS[STATUS_FREEZE_1H]
|
||||
if status == STATUS_FREEZE_4H:
|
||||
h = int(hours) if float(hours) == int(hours) else round(float(hours), 1)
|
||||
if abs(float(hours) - 4.0) < 1e-6:
|
||||
return STATUS_LABELS[STATUS_FREEZE_4H]
|
||||
return f"{h}h冻结"
|
||||
return STATUS_LABELS.get(status, STATUS_LABELS[STATUS_NORMAL])
|
||||
|
||||
|
||||
def _ms_to_local_str(ms: Optional[int], fmt_local: Callable[[int], str]) -> Optional[str]:
|
||||
if ms is None:
|
||||
return None
|
||||
try:
|
||||
return fmt_local(int(ms))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _load_state(conn):
|
||||
ensure_account_risk_schema(conn)
|
||||
return conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||
|
||||
|
||||
def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) -> Any:
|
||||
row = _load_state(conn)
|
||||
td = (trading_day or "").strip()
|
||||
stored = str(_row_get(row, "trading_day") or "").strip()
|
||||
if stored != td:
|
||||
now_ms = _now_ms(now)
|
||||
cooloff_active = _resolved_cooloff_until_ms(row, now_ms)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day=?,
|
||||
manual_close_count=0,
|
||||
daily_loss_count=0,
|
||||
daily_frozen=0,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=?,
|
||||
last_close_at_ms=?,
|
||||
pending_journal_trade_id=NULL,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
(
|
||||
td,
|
||||
cooloff_active,
|
||||
_row_get(row, "cooloff_hours") if cooloff_active else None,
|
||||
_row_get(row, "last_close_at_ms") if cooloff_active else None,
|
||||
(now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
row = _load_state(conn)
|
||||
return row
|
||||
|
||||
|
||||
def _set_cooloff(
|
||||
conn,
|
||||
*,
|
||||
trading_day: str,
|
||||
close_at_ms: int,
|
||||
hours: float,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
_sync_trading_day(conn, trading_day, now=now)
|
||||
h = max(0.0, float(hours))
|
||||
until_ms = int(close_at_ms + h * 3600 * 1000)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=?,
|
||||
last_close_at_ms=?,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
(
|
||||
until_ms,
|
||||
int(h) if h == int(h) else int(round(h)),
|
||||
int(close_at_ms),
|
||||
(now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _set_cooloff_until(
|
||||
conn,
|
||||
*,
|
||||
trading_day: str,
|
||||
until_ms: int,
|
||||
hours: float,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
_sync_trading_day(conn, trading_day, now=now)
|
||||
h = max(0.0, float(hours))
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=?,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
(
|
||||
int(until_ms),
|
||||
int(h) if h == int(h) else int(round(h)),
|
||||
(now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _ms_trading_day_label(ms: int) -> str:
|
||||
dt = datetime.fromtimestamp(ms / 1000, tz=_app_tz())
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _parse_journal_close_ms(raw: Any) -> Optional[int]:
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
||||
try:
|
||||
dt = datetime.strptime(s[:19] if len(s) > 16 else s, fmt)
|
||||
return _now_ms(dt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _latest_journaled_manual_close_ms(conn, trading_day: str) -> Optional[int]:
|
||||
"""当日最近一条已复盘的手动平仓时刻(journal 有说明)."""
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT close_datetime FROM journal_entries
|
||||
WHERE early_exit_trigger='手动平仓'
|
||||
AND early_exit_note IS NOT NULL AND TRIM(early_exit_note) <> ''
|
||||
ORDER BY close_datetime DESC"""
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return None
|
||||
td = (trading_day or "").strip()
|
||||
best: Optional[int] = None
|
||||
for row in rows:
|
||||
ms = _parse_journal_close_ms(_row_get(row, "close_datetime"))
|
||||
if ms is None:
|
||||
continue
|
||||
if td and _ms_trading_day_label(ms) != td:
|
||||
continue
|
||||
if best is None or ms > best:
|
||||
best = ms
|
||||
return best
|
||||
|
||||
|
||||
def _journaled_manual_cooloff_expired(
|
||||
conn, *, trading_day: str, now_ms: int, pending: Any
|
||||
) -> bool:
|
||||
"""当日手动平仓已复盘且 1h 冷静期结束,且无待复盘的新平仓."""
|
||||
if pending is not None:
|
||||
try:
|
||||
if int(pending) != 0:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
close_ms = _latest_journaled_manual_close_ms(conn, trading_day)
|
||||
if close_ms is None:
|
||||
return False
|
||||
journal_ms = _cooloff_duration_ms(cooling_hours_manual_journal())
|
||||
return close_ms + journal_ms <= now_ms
|
||||
|
||||
|
||||
def _cooloff_until_ms(row) -> Optional[int]:
|
||||
raw = _row_get(row, "cooloff_until_ms")
|
||||
try:
|
||||
return int(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _repair_stale_cooloff_row(
|
||||
conn,
|
||||
row,
|
||||
*,
|
||||
now_ms: int,
|
||||
resolved_until_ms: Optional[int],
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""脏数据读时写回:过期/无效则清库,否则对齐 until / last_close."""
|
||||
last_raw = _row_get(row, "last_close_at_ms")
|
||||
stored_raw = _cooloff_until_ms(row)
|
||||
if last_raw is None and stored_raw is None:
|
||||
return
|
||||
if resolved_until_ms is None:
|
||||
if last_raw is not None or stored_raw is not None:
|
||||
_clear_inactive_cooloff(conn, now=now)
|
||||
return
|
||||
dirty = False
|
||||
new_last: Optional[int] = None
|
||||
if last_raw is not None:
|
||||
try:
|
||||
norm = _normalize_epoch_ms(int(last_raw), now_ms)
|
||||
sanitized = _sanitize_last_close_ms(norm, now_ms)
|
||||
if sanitized is None:
|
||||
dirty = True
|
||||
else:
|
||||
new_last = sanitized
|
||||
if sanitized != int(last_raw):
|
||||
dirty = True
|
||||
except (TypeError, ValueError):
|
||||
dirty = True
|
||||
if stored_raw is not None:
|
||||
stored_norm = _normalize_epoch_ms(int(stored_raw), now_ms)
|
||||
if abs(stored_norm - int(resolved_until_ms)) > 60 * 1000:
|
||||
dirty = True
|
||||
if not dirty:
|
||||
return
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=?,
|
||||
last_close_at_ms=?,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
(
|
||||
resolved_until_ms,
|
||||
_row_get(row, "cooloff_hours"),
|
||||
new_last,
|
||||
(now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool:
|
||||
if int(_row_get(row, "daily_frozen") or 0) == 1:
|
||||
return False
|
||||
if _resolved_cooloff_until_ms(row, now_ms) is None:
|
||||
return False
|
||||
journal_h = cooling_hours_manual_journal()
|
||||
cooloff_h = float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
|
||||
if cooloff_h <= journal_h + 1e-6:
|
||||
return False
|
||||
if pending is not None:
|
||||
try:
|
||||
if int(pending) != 0:
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int:
|
||||
journal_ms = int(max(0.0, float(journal_hours)) * 3600 * 1000)
|
||||
last_close_ms = _row_get(row, "last_close_at_ms")
|
||||
if last_close_ms:
|
||||
try:
|
||||
base_ms = _sanitize_last_close_ms(
|
||||
_normalize_epoch_ms(int(last_close_ms), now_ms), now_ms
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
base_ms = None
|
||||
if base_ms is None:
|
||||
base_ms = now_ms
|
||||
else:
|
||||
base_ms = now_ms
|
||||
until_from_close = base_ms + journal_ms
|
||||
if until_from_close > now_ms:
|
||||
return until_from_close
|
||||
return now_ms + journal_ms
|
||||
|
||||
|
||||
def _set_daily_frozen(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
||||
_sync_trading_day(conn, trading_day, now=now)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET daily_frozen=1, updated_at=? WHERE id=1""",
|
||||
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
|
||||
)
|
||||
|
||||
|
||||
def parse_mood_issues(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
parts = [str(x).strip() for x in raw if str(x).strip()]
|
||||
else:
|
||||
parts = [x.strip() for x in str(raw).split(",") if x.strip()]
|
||||
return [p for p in parts if p in MOOD_ISSUE_OPTIONS]
|
||||
|
||||
|
||||
def _record_one_user_initiated_close(
|
||||
conn,
|
||||
*,
|
||||
source: str,
|
||||
trade_record_id: Optional[int],
|
||||
closed_at_ms: Optional[int],
|
||||
trading_day: str,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
row = _sync_trading_day(conn, trading_day, now=now)
|
||||
count = int(_row_get(row, "manual_close_count") or 0) + 1
|
||||
close_ms = int(closed_at_ms) if closed_at_ms else _now_ms(now)
|
||||
pending = int(trade_record_id) if trade_record_id else None
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
manual_close_count=?,
|
||||
pending_journal_trade_id=?,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
(count, pending, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
if count >= manual_close_daily_limit():
|
||||
_set_daily_frozen(conn, trading_day=trading_day, now=now)
|
||||
return
|
||||
_set_cooloff(
|
||||
conn,
|
||||
trading_day=trading_day,
|
||||
close_at_ms=close_ms,
|
||||
hours=cooling_hours_manual(),
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def on_user_initiated_close(
|
||||
conn,
|
||||
*,
|
||||
source: str,
|
||||
trade_record_id: Optional[int] = None,
|
||||
closed_at_ms: Optional[int] = None,
|
||||
trading_day: str,
|
||||
now: Optional[datetime] = None,
|
||||
count: int = 1,
|
||||
) -> None:
|
||||
"""用户主动平仓/结束趋势计划:计入手动平仓次数与冷静期."""
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
src = (source or "").strip()
|
||||
if src not in USER_INITIATED_CLOSE_SOURCES:
|
||||
return
|
||||
n = max(1, int(count or 1))
|
||||
for i in range(n):
|
||||
_record_one_user_initiated_close(
|
||||
conn,
|
||||
source=src,
|
||||
trade_record_id=trade_record_id if i == 0 else None,
|
||||
closed_at_ms=closed_at_ms,
|
||||
trading_day=trading_day,
|
||||
now=now,
|
||||
)
|
||||
row = _load_state(conn)
|
||||
if int(_row_get(row, "daily_frozen") or 0) == 1:
|
||||
break
|
||||
|
||||
|
||||
def on_manual_close(
|
||||
conn,
|
||||
*,
|
||||
trade_record_id: int,
|
||||
closed_at_ms: Optional[int],
|
||||
trading_day: str,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""兼容旧调用:等同实例页用户平仓."""
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||
trade_record_id=trade_record_id,
|
||||
closed_at_ms=closed_at_ms,
|
||||
trading_day=trading_day,
|
||||
now=now,
|
||||
count=1,
|
||||
)
|
||||
|
||||
|
||||
def on_closed_trade_pnl(
|
||||
conn,
|
||||
*,
|
||||
pnl_amount: Any,
|
||||
trading_day: str,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""
|
||||
已平仓交易记盈亏后调用:亏损笔数达 RISK_DAILY_LOSS_LIMIT 则当日冻结开仓.
|
||||
上限为 0 时不启用本规则.
|
||||
"""
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
limit = daily_loss_limit()
|
||||
if limit <= 0:
|
||||
return
|
||||
try:
|
||||
pnl = float(pnl_amount)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if pnl >= 0:
|
||||
return
|
||||
row = _sync_trading_day(conn, trading_day, now=now)
|
||||
if int(_row_get(row, "daily_frozen") or 0) == 1:
|
||||
return
|
||||
count = int(_row_get(row, "daily_loss_count") or 0) + 1
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
daily_loss_count=?,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
(count, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
if count >= limit:
|
||||
_set_daily_frozen(conn, trading_day=trading_day, now=now)
|
||||
|
||||
|
||||
def on_journal_saved(
|
||||
conn,
|
||||
*,
|
||||
early_exit_trigger: str,
|
||||
early_exit_note: str,
|
||||
mood_issues_raw: Any,
|
||||
trading_day: str,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
if not risk_control_enabled():
|
||||
return
|
||||
row = _sync_trading_day(conn, trading_day, now=now)
|
||||
mood_list = parse_mood_issues(mood_issues_raw)
|
||||
if mood_issues_daily_freeze_enabled() and mood_list:
|
||||
_set_daily_frozen(conn, trading_day=trading_day, now=now)
|
||||
conn.execute(
|
||||
"UPDATE account_risk_state SET pending_journal_trade_id=NULL, updated_at=? WHERE id=1",
|
||||
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
|
||||
)
|
||||
return
|
||||
pending = _row_get(row, "pending_journal_trade_id")
|
||||
trigger = (early_exit_trigger or "").strip()
|
||||
note = (early_exit_note or "").strip()
|
||||
now_ms = _now_ms(now)
|
||||
if (
|
||||
trigger == "手动平仓"
|
||||
and note
|
||||
and int(_row_get(row, "daily_frozen") or 0) != 1
|
||||
and _journal_can_reduce_cooloff(row, pending, now_ms)
|
||||
):
|
||||
journal_h = cooling_hours_manual_journal()
|
||||
until_ms = _journal_cooloff_until_ms(row, now_ms, journal_h)
|
||||
_set_cooloff_until(
|
||||
conn,
|
||||
trading_day=trading_day,
|
||||
until_ms=until_ms,
|
||||
hours=journal_h,
|
||||
now=now,
|
||||
)
|
||||
anchor_ms = until_ms - int(journal_h * 3600 * 1000)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
pending_journal_trade_id=NULL,
|
||||
last_close_at_ms=?,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
(int(anchor_ms), (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def apply_manual_close_journal_cooloff(
|
||||
conn,
|
||||
*,
|
||||
early_exit_note: str,
|
||||
trading_day: str,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""核对修改或复盘:手动平仓 + 说明后尝试将 4h 冷静期降为 1h."""
|
||||
note = (early_exit_note or "").strip()
|
||||
if not note:
|
||||
return
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger="手动平仓",
|
||||
early_exit_note=note,
|
||||
mood_issues_raw="",
|
||||
trading_day=trading_day,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def _next_trading_day_reset_ms(now: datetime, reset_hour: int) -> int:
|
||||
from datetime import timedelta
|
||||
|
||||
h = max(0, min(23, int(reset_hour)))
|
||||
candidate = now.replace(hour=h, minute=0, second=0, microsecond=0)
|
||||
if now >= candidate:
|
||||
candidate = candidate + timedelta(days=1)
|
||||
return _now_ms(candidate)
|
||||
|
||||
|
||||
def enrich_risk_status_countdown(
|
||||
st: dict[str, Any],
|
||||
*,
|
||||
now: Optional[datetime] = None,
|
||||
daily_reset_hour: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""补充 freeze_until_ms / freeze_remaining_sec,供前端倒计时展示."""
|
||||
if not st.get("enabled", True):
|
||||
return st
|
||||
dt = now or datetime.now()
|
||||
now_ms = _now_ms(dt)
|
||||
until_ms: Optional[int] = None
|
||||
if st.get("daily_frozen"):
|
||||
until_ms = _next_trading_day_reset_ms(dt, daily_reset_hour)
|
||||
elif st.get("cooloff_until_ms"):
|
||||
try:
|
||||
until_ms = int(st["cooloff_until_ms"])
|
||||
except (TypeError, ValueError):
|
||||
until_ms = None
|
||||
if until_ms is not None and until_ms > now_ms:
|
||||
st["freeze_until_ms"] = until_ms
|
||||
st["freeze_remaining_sec"] = max(0, (until_ms - now_ms) // 1000)
|
||||
else:
|
||||
st["freeze_until_ms"] = None
|
||||
st["freeze_remaining_sec"] = 0
|
||||
return st
|
||||
|
||||
|
||||
def apply_position_limit_risk(
|
||||
st: dict[str, Any],
|
||||
active_count: int,
|
||||
*,
|
||||
max_active_positions: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""持仓达 env MAX_ACTIVE_POSITIONS 时叠加「仓位上限冻结」(时间冻结优先展示)."""
|
||||
out = dict(st or {})
|
||||
try:
|
||||
mx = max(1, int(max_active_positions if max_active_positions is not None else max_active_positions_from_env()))
|
||||
except (TypeError, ValueError):
|
||||
mx = max_active_positions_from_env()
|
||||
try:
|
||||
ac = max(0, int(active_count))
|
||||
except (TypeError, ValueError):
|
||||
ac = 0
|
||||
out["max_active_positions"] = mx
|
||||
out["active_count"] = ac
|
||||
if out.get("status") != STATUS_NORMAL:
|
||||
return out
|
||||
if ac >= mx:
|
||||
out["status"] = STATUS_FREEZE_POSITION
|
||||
out["status_label"] = STATUS_LABELS[STATUS_FREEZE_POSITION]
|
||||
out["can_trade"] = False
|
||||
out["can_roll"] = True
|
||||
out["reason"] = f"已达最大持仓数({ac}/{mx}),新开仓已冻结,顺势加仓仍可用"
|
||||
out["position_limit_frozen"] = True
|
||||
out["freeze_until_ms"] = None
|
||||
out["freeze_remaining_sec"] = 0
|
||||
else:
|
||||
out["position_limit_frozen"] = False
|
||||
out["can_roll"] = True
|
||||
return out
|
||||
|
||||
|
||||
def compute_account_risk_status(
|
||||
conn,
|
||||
*,
|
||||
trading_day: str,
|
||||
now: Optional[datetime] = None,
|
||||
fmt_local_ms: Optional[Callable[[int], str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
if not risk_control_enabled():
|
||||
return {
|
||||
"enabled": False,
|
||||
"status": STATUS_NORMAL,
|
||||
"status_label": STATUS_LABELS[STATUS_NORMAL],
|
||||
"can_trade": True,
|
||||
"reason": "",
|
||||
"cooloff_until_ms": None,
|
||||
"cooloff_until": None,
|
||||
"manual_close_count": 0,
|
||||
"daily_loss_count": 0,
|
||||
"daily_frozen": False,
|
||||
}
|
||||
row = _sync_trading_day(conn, trading_day, now=now)
|
||||
now_ms = _now_ms(now)
|
||||
daily_frozen = int(_row_get(row, "daily_frozen") or 0) == 1
|
||||
pending = _row_get(row, "pending_journal_trade_id")
|
||||
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
|
||||
if (
|
||||
not daily_frozen
|
||||
and cooloff_until_ms is not None
|
||||
and _journaled_manual_cooloff_expired(
|
||||
conn, trading_day=trading_day, now_ms=now_ms, pending=pending
|
||||
)
|
||||
):
|
||||
cooloff_until_ms = None
|
||||
if not daily_frozen:
|
||||
_repair_stale_cooloff_row(
|
||||
conn, row, now_ms=now_ms, resolved_until_ms=cooloff_until_ms, now=now
|
||||
)
|
||||
row = _load_state(conn)
|
||||
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
|
||||
manual_close_count = int(_row_get(row, "manual_close_count") or 0)
|
||||
daily_loss_count = int(_row_get(row, "daily_loss_count") or 0)
|
||||
loss_limit = daily_loss_limit()
|
||||
|
||||
status = STATUS_NORMAL
|
||||
reason = ""
|
||||
if daily_frozen:
|
||||
status = STATUS_DAILY
|
||||
parts = []
|
||||
if loss_limit > 0 and daily_loss_count >= loss_limit:
|
||||
parts.append(f"日亏损 {daily_loss_count}/{loss_limit} 次")
|
||||
if manual_close_count >= manual_close_daily_limit():
|
||||
parts.append(f"手动平仓 {manual_close_count} 次")
|
||||
if not parts:
|
||||
parts.append("手动平仓/日亏损达限或复盘情绪标签")
|
||||
reason = "账户今日已冻结(" + "、".join(parts) + ")"
|
||||
elif cooloff_until_ms is not None:
|
||||
remaining_ms = cooloff_until_ms - now_ms
|
||||
hours = _cooloff_hours_value(row)
|
||||
status = _freeze_tier_from_remaining_ms(remaining_ms, hours)
|
||||
status_label = _freeze_status_label(hours, status)
|
||||
until_str = _ms_to_local_str(cooloff_until_ms, fmt_local_ms) if fmt_local_ms else None
|
||||
label = status_label
|
||||
reason = f"账户{label}中"
|
||||
if until_str:
|
||||
reason += f",至 {until_str}"
|
||||
|
||||
can_trade = status == STATUS_NORMAL
|
||||
freeze_remaining_sec = (
|
||||
max(0, (cooloff_until_ms - now_ms) // 1000) if cooloff_until_ms is not None else 0
|
||||
)
|
||||
return {
|
||||
"enabled": True,
|
||||
"status": status,
|
||||
"status_label": _freeze_status_label(_cooloff_hours_value(row), status)
|
||||
if status in (STATUS_FREEZE_1H, STATUS_FREEZE_4H)
|
||||
else STATUS_LABELS[status],
|
||||
"can_trade": can_trade,
|
||||
"reason": reason,
|
||||
"cooloff_until_ms": cooloff_until_ms,
|
||||
"cooloff_until": _ms_to_local_str(cooloff_until_ms, fmt_local_ms)
|
||||
if fmt_local_ms and cooloff_until_ms
|
||||
else None,
|
||||
"manual_close_count": manual_close_count,
|
||||
"daily_loss_count": daily_loss_count,
|
||||
"daily_loss_limit": loss_limit,
|
||||
"daily_frozen": daily_frozen,
|
||||
"pending_journal_trade_id": pending,
|
||||
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
|
||||
}
|
||||
|
||||
|
||||
def account_risk_blocks_trading(
|
||||
conn,
|
||||
*,
|
||||
trading_day: str,
|
||||
now: Optional[datetime] = None,
|
||||
fmt_local_ms: Optional[Callable[[int], str]] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""返回 (允许交易, 拒绝原因)."""
|
||||
st = compute_account_risk_status(
|
||||
conn, trading_day=trading_day, now=now, fmt_local_ms=fmt_local_ms
|
||||
)
|
||||
if st.get("can_trade"):
|
||||
return True, ""
|
||||
return False, str(st.get("reason") or STATUS_LABELS.get(st.get("status"), "账户冻结"))
|
||||
|
||||
|
||||
def insert_trade_record_id(conn) -> int:
|
||||
row = conn.execute("SELECT last_insert_rowid()").fetchone()
|
||||
return int(row[0] if row else 0)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""开仓后挂 TP/SL 失败时的补偿平仓(避免裸仓)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def log_compensating_close_error(prefix: str, exc: BaseException) -> None:
|
||||
print(f"[{prefix}] {exc}", flush=True)
|
||||
|
||||
|
||||
def run_compensating_close(close_fn: Callable[[], None], *, log_prefix: str = "compensating_close") -> None:
|
||||
"""执行补偿平仓;二次失败只打日志,不掩盖原始异常."""
|
||||
try:
|
||||
close_fn()
|
||||
except Exception as e:
|
||||
log_compensating_close_error(log_prefix, e)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""单日开仓次数:软提醒阈值 + 硬上限(三所实例共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int:
|
||||
"""AI 克制提醒阈值;至少 1."""
|
||||
try:
|
||||
v = int(raw if raw is not None and str(raw).strip() != "" else default)
|
||||
except (TypeError, ValueError):
|
||||
v = default
|
||||
return max(1, v)
|
||||
|
||||
|
||||
def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int:
|
||||
"""硬上限;0 表示不启用.至少 0."""
|
||||
try:
|
||||
v = int(raw if raw is not None and str(raw).strip() != "" else default)
|
||||
except (TypeError, ValueError):
|
||||
v = default
|
||||
return max(0, v)
|
||||
|
||||
|
||||
def load_daily_open_limits_from_env(
|
||||
env: Optional[dict[str, str]] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""从环境变量读取 (alert_threshold, hard_limit)."""
|
||||
src = env if env is not None else os.environ
|
||||
alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD"))
|
||||
hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT"))
|
||||
return alert, hard
|
||||
|
||||
|
||||
def count_opens_for_trading_day(conn, trading_day: str) -> int:
|
||||
"""本交易日已成功写入 order_monitors 的开仓次数."""
|
||||
td = (trading_day or "").strip()
|
||||
if not td:
|
||||
return 0
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
|
||||
(td,),
|
||||
).fetchone()
|
||||
return int(row[0] if row else 0)
|
||||
|
||||
|
||||
def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool:
|
||||
return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit)
|
||||
|
||||
|
||||
def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str:
|
||||
return (
|
||||
f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)}),"
|
||||
f"次日北京时间 {int(reset_hour)}:00 后恢复"
|
||||
)
|
||||
|
||||
|
||||
def check_daily_open_hard_limit(
|
||||
conn,
|
||||
trading_day: str,
|
||||
hard_limit: int,
|
||||
reset_hour: int,
|
||||
) -> tuple[bool, str, int]:
|
||||
"""返回 (允许继续开仓, 拒绝原因, 当日已开次数)."""
|
||||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||||
if daily_open_hard_limit_blocks(opens_today, hard_limit):
|
||||
return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today
|
||||
return True, "", opens_today
|
||||
|
||||
|
||||
def can_trade_new_open(
|
||||
*,
|
||||
time_allows: bool,
|
||||
active_count: int,
|
||||
max_active_positions: int,
|
||||
opens_today: int,
|
||||
hard_limit: int,
|
||||
extra_blocks: bool = False,
|
||||
) -> bool:
|
||||
if extra_blocks:
|
||||
return False
|
||||
if not time_allows:
|
||||
return False
|
||||
if int(active_count) >= int(max_active_positions):
|
||||
return False
|
||||
if daily_open_hard_limit_blocks(opens_today, hard_limit):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool:
|
||||
return int(before) < int(alert_threshold) <= int(after)
|
||||
|
||||
|
||||
def build_daily_open_alert_prompt(
|
||||
trading_day: str,
|
||||
opens_after: int,
|
||||
alert_threshold: int,
|
||||
*,
|
||||
hard_limit: int = 0,
|
||||
detail_line: str = "",
|
||||
) -> str:
|
||||
hard_txt = (
|
||||
f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)."
|
||||
if int(hard_limit) > 0
|
||||
else "未配置单日硬上限."
|
||||
)
|
||||
extra = f" {detail_line}" if detail_line else ""
|
||||
return (
|
||||
f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after} 次"
|
||||
f"(AI 提醒阈值 {alert_threshold};{hard_txt})"
|
||||
f"{extra}"
|
||||
f"用户自述“上头了”.请给克制提醒."
|
||||
)
|
||||
|
||||
|
||||
def format_daily_open_counter_line(
|
||||
opens_today: int,
|
||||
alert_threshold: int,
|
||||
hard_limit: int,
|
||||
) -> str:
|
||||
if int(hard_limit) > 0:
|
||||
return (
|
||||
f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)} 次"
|
||||
f"(AI 提醒阈值 {int(alert_threshold)})"
|
||||
)
|
||||
return (
|
||||
f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)} 次"
|
||||
)
|
||||
|
||||
|
||||
def format_daily_open_summary_short(
|
||||
opens_today: int,
|
||||
alert_threshold: int,
|
||||
hard_limit: int,
|
||||
) -> str:
|
||||
if int(hard_limit) > 0:
|
||||
return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)})"
|
||||
return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)})"
|
||||
@@ -0,0 +1,503 @@
|
||||
"""趋势户开仓类型:反转·启动 / 顺势·大分歧 / 波段·小分歧;日内户单独 profile."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from lib.trade.trade_policy_lib import TradePolicy
|
||||
|
||||
PROFILE_TREND_DIV = "trend_div"
|
||||
PROFILE_INTRADAY = "intraday"
|
||||
|
||||
ENTRY_CATEGORY_REVERSAL = "reversal"
|
||||
ENTRY_CATEGORY_TREND = "trend"
|
||||
ENTRY_CATEGORY_SWING = "swing"
|
||||
|
||||
ENTRY_MODEL_LAUNCH_A = "launch_a"
|
||||
ENTRY_MODEL_LAUNCH_B = "launch_b"
|
||||
ENTRY_MODEL_BIG_DIV_A = "big_div_a"
|
||||
ENTRY_MODEL_BIG_DIV_B = "big_div_b"
|
||||
ENTRY_MODEL_SMALL_DIV = "small_div"
|
||||
|
||||
ENTRY_CATEGORY_INTRADAY = "intraday"
|
||||
ENTRY_MODEL_LIQUIDITY_FALSE_BREAK = "liquidity_false_break"
|
||||
ENTRY_MODEL_STRUCTURE_BREAKOUT = "structure_breakout"
|
||||
|
||||
VALID_ENTRY_MODEL_CODES = frozenset(
|
||||
{
|
||||
ENTRY_MODEL_LAUNCH_A,
|
||||
ENTRY_MODEL_LAUNCH_B,
|
||||
ENTRY_MODEL_BIG_DIV_A,
|
||||
ENTRY_MODEL_BIG_DIV_B,
|
||||
ENTRY_MODEL_SMALL_DIV,
|
||||
}
|
||||
)
|
||||
|
||||
INTRADAY_ENTRY_MODEL_CODES = frozenset(
|
||||
{
|
||||
ENTRY_MODEL_LIQUIDITY_FALSE_BREAK,
|
||||
ENTRY_MODEL_STRUCTURE_BREAKOUT,
|
||||
}
|
||||
)
|
||||
|
||||
ALL_ENTRY_MODEL_CODES = VALID_ENTRY_MODEL_CODES | INTRADAY_ENTRY_MODEL_CODES
|
||||
|
||||
ENTRY_CATEGORY_LABELS: dict[str, str] = {
|
||||
ENTRY_CATEGORY_REVERSAL: "反转",
|
||||
ENTRY_CATEGORY_TREND: "顺势",
|
||||
ENTRY_CATEGORY_SWING: "波段",
|
||||
}
|
||||
|
||||
TRADE_STYLE_FALLBACK_ENTRY_REASONS: Tuple[str, ...] = ("趋势单", "波段单")
|
||||
|
||||
INTRADAY_LEGACY_TREND_ENTRY_REASONS: Tuple[str, ...] = (
|
||||
"趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低",
|
||||
"趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高",
|
||||
"趋势多头:小分歧低吸入场(左侧),确认条件:二次探底",
|
||||
"趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶",
|
||||
"波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20",
|
||||
)
|
||||
|
||||
# code, label, category, trade_style, help
|
||||
_ENTRY_SPECS: Tuple[Tuple[str, str, str, str, str], ...] = (
|
||||
(
|
||||
ENTRY_MODEL_LAUNCH_A,
|
||||
"启动A",
|
||||
ENTRY_CATEGORY_REVERSAL,
|
||||
"trend",
|
||||
"反转链结构内:摸参考极值前小收敛,或 B 失败后 5m N 字试仓(不单列)",
|
||||
),
|
||||
(
|
||||
ENTRY_MODEL_LAUNCH_B,
|
||||
"启动B",
|
||||
ENTRY_CATEGORY_REVERSAL,
|
||||
"trend",
|
||||
"第二次到参考极值附近,无小收敛时的实体突破",
|
||||
),
|
||||
(
|
||||
ENTRY_MODEL_BIG_DIV_A,
|
||||
"大分歧A",
|
||||
ENTRY_CATEGORY_TREND,
|
||||
"trend",
|
||||
"主升已确立:突破前收敛,不创新低企稳(空:不创新高)",
|
||||
),
|
||||
(
|
||||
ENTRY_MODEL_BIG_DIV_B,
|
||||
"大分歧B",
|
||||
ENTRY_CATEGORY_TREND,
|
||||
"trend",
|
||||
"主升已确立:结构实体突破确认后入场",
|
||||
),
|
||||
(
|
||||
ENTRY_MODEL_SMALL_DIV,
|
||||
"小分歧",
|
||||
ENTRY_CATEGORY_SWING,
|
||||
"swing",
|
||||
"主升已确立:二次探底 N 字或 5m 三均线重新多头(空:二次探顶 / 空头均线)",
|
||||
),
|
||||
)
|
||||
|
||||
_INTRADAY_ENTRY_SPECS: Tuple[Tuple[str, str, str, str, str], ...] = (
|
||||
(
|
||||
ENTRY_MODEL_LIQUIDITY_FALSE_BREAK,
|
||||
"假破",
|
||||
ENTRY_CATEGORY_INTRADAY,
|
||||
"trend",
|
||||
"流动性扫单 → 假突破验证 → 5m N 字 → 15m 顶/底分型",
|
||||
),
|
||||
(
|
||||
ENTRY_MODEL_STRUCTURE_BREAKOUT,
|
||||
"结构突破",
|
||||
ENTRY_CATEGORY_INTRADAY,
|
||||
"trend",
|
||||
"15m 结构有效突破(收盘确认)",
|
||||
),
|
||||
)
|
||||
|
||||
_CODE_TO_LABEL = {code: label for code, label, _, _, _ in _ENTRY_SPECS}
|
||||
_CODE_TO_LABEL.update({code: label for code, label, _, _, _ in _INTRADAY_ENTRY_SPECS})
|
||||
_CODE_TO_STYLE = {code: style for code, _, _, style, _ in _ENTRY_SPECS}
|
||||
_CODE_TO_STYLE.update({code: style for code, _, _, style, _ in _INTRADAY_ENTRY_SPECS})
|
||||
_CODE_TO_CATEGORY = {code: cat for code, _, cat, _, _ in _ENTRY_SPECS}
|
||||
_CODE_TO_CATEGORY.update({code: cat for code, _, cat, _, _ in _INTRADAY_ENTRY_SPECS})
|
||||
_LABEL_TO_CODE = {label: code for code, label, _, _, _ in _ENTRY_SPECS}
|
||||
_LABEL_TO_CODE.update({label: code for code, label, _, _, _ in _INTRADAY_ENTRY_SPECS})
|
||||
_CODE_TO_HELP = {code: help for code, _, _, _, help in _ENTRY_SPECS}
|
||||
_CODE_TO_HELP.update({code: help for code, _, _, _, help in _INTRADAY_ENTRY_SPECS})
|
||||
|
||||
_CATEGORY_ORDER: Tuple[str, ...] = (
|
||||
ENTRY_CATEGORY_REVERSAL,
|
||||
ENTRY_CATEGORY_TREND,
|
||||
ENTRY_CATEGORY_SWING,
|
||||
)
|
||||
|
||||
_INTRADAY_WHITELIST = frozenset({"BTC", "ETH"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntryModelOption:
|
||||
code: str
|
||||
label: str
|
||||
category: str
|
||||
trade_style: str
|
||||
help: str
|
||||
|
||||
|
||||
def is_intraday_trading_profile(policy: TradePolicy) -> bool:
|
||||
"""日内户:启用 BTC/ETH 白名单(env 中 TRADE_SYMBOL_WHITELIST)."""
|
||||
if not policy.symbol_restrict_enabled:
|
||||
return False
|
||||
if not policy.symbol_whitelist:
|
||||
return False
|
||||
return all(s in _INTRADAY_WHITELIST for s in policy.symbol_whitelist)
|
||||
|
||||
|
||||
def order_entry_profile(policy: TradePolicy) -> str:
|
||||
return PROFILE_INTRADAY if is_intraday_trading_profile(policy) else PROFILE_TREND_DIV
|
||||
|
||||
|
||||
def entry_model_options() -> Tuple[EntryModelOption, ...]:
|
||||
return tuple(
|
||||
EntryModelOption(code=code, label=label, category=cat, trade_style=style, help=help)
|
||||
for code, label, cat, style, help in _ENTRY_SPECS
|
||||
)
|
||||
|
||||
|
||||
def intraday_entry_model_options() -> Tuple[EntryModelOption, ...]:
|
||||
return tuple(
|
||||
EntryModelOption(code=code, label=label, category=cat, trade_style=style, help=help)
|
||||
for code, label, cat, style, help in _INTRADAY_ENTRY_SPECS
|
||||
)
|
||||
|
||||
|
||||
def entry_model_categories() -> list[dict[str, Any]]:
|
||||
"""两级 UI:反转 / 顺势 / 波段 → 子选项."""
|
||||
opts = entry_model_options()
|
||||
out: list[dict[str, Any]] = []
|
||||
for cat_key in _CATEGORY_ORDER:
|
||||
children = [
|
||||
{
|
||||
"code": o.code,
|
||||
"label": o.label,
|
||||
"trade_style": o.trade_style,
|
||||
"help": o.help,
|
||||
}
|
||||
for o in opts
|
||||
if o.category == cat_key
|
||||
]
|
||||
if not children:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"key": cat_key,
|
||||
"label": ENTRY_CATEGORY_LABELS.get(cat_key, cat_key),
|
||||
"options": children,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def entry_model_category(code: Optional[str]) -> str:
|
||||
c = normalize_entry_model_code(code)
|
||||
return _CODE_TO_CATEGORY.get(c, "")
|
||||
|
||||
|
||||
def normalize_entry_model_code(raw: Optional[str]) -> str:
|
||||
v = (raw or "").strip().lower()
|
||||
if v in ALL_ENTRY_MODEL_CODES:
|
||||
return v
|
||||
label = (raw or "").strip()
|
||||
if label in _LABEL_TO_CODE:
|
||||
return _LABEL_TO_CODE[label]
|
||||
return ""
|
||||
|
||||
|
||||
def entry_model_label(code: Optional[str]) -> str:
|
||||
c = normalize_entry_model_code(code)
|
||||
return _CODE_TO_LABEL.get(c, "")
|
||||
|
||||
|
||||
def entry_category_display_prefix(category: str) -> str:
|
||||
"""两级展示用的一级前缀:反转 / 顺势 / 波段单(含日内)."""
|
||||
cat = (category or "").strip()
|
||||
if cat in (ENTRY_CATEGORY_SWING, ENTRY_CATEGORY_INTRADAY):
|
||||
return "波段单"
|
||||
return ENTRY_CATEGORY_LABELS.get(cat, "")
|
||||
|
||||
|
||||
def entry_model_display_label(code: Optional[str]) -> str:
|
||||
"""两级展示:反转/启动A,顺势/大分歧A,波段单/小分歧,波段单/假破."""
|
||||
c = normalize_entry_model_code(code)
|
||||
if not c:
|
||||
return ""
|
||||
label = entry_model_label(c)
|
||||
if not label:
|
||||
return ""
|
||||
prefix = entry_category_display_prefix(entry_model_category(c))
|
||||
if prefix:
|
||||
return f"{prefix}/{label}"
|
||||
return label
|
||||
|
||||
|
||||
def format_entry_type_display(
|
||||
text: Optional[str] = None,
|
||||
*,
|
||||
entry_model: Optional[str] = None,
|
||||
trade_style: Optional[str] = None,
|
||||
) -> str:
|
||||
"""交易记录/持仓展示:已知 entry_model 或短标签 → 两级文案."""
|
||||
if entry_model:
|
||||
disp = entry_model_display_label(entry_model)
|
||||
if disp:
|
||||
return disp
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
ts = (trade_style or "").strip().lower()
|
||||
if ts in ("trend", "swing"):
|
||||
return trade_style_label_zh(ts)
|
||||
return ""
|
||||
if "/" in raw:
|
||||
return raw
|
||||
code = normalize_entry_model_code(raw)
|
||||
if code:
|
||||
disp = entry_model_display_label(code)
|
||||
if disp:
|
||||
return disp
|
||||
return raw
|
||||
|
||||
|
||||
def trade_style_for_entry_model(code: Optional[str]) -> str:
|
||||
c = normalize_entry_model_code(code)
|
||||
return _CODE_TO_STYLE.get(c, "trend")
|
||||
|
||||
|
||||
def trade_style_label_zh(trade_style: str) -> str:
|
||||
return "波段单" if (trade_style or "").strip().lower() == "swing" else "趋势单"
|
||||
|
||||
|
||||
def trend_div_entry_reason_display_options() -> Tuple[str, ...]:
|
||||
return tuple(entry_model_display_label(code) for code, _, _, _, _ in _ENTRY_SPECS)
|
||||
|
||||
|
||||
def intraday_entry_reason_display_options() -> Tuple[str, ...]:
|
||||
return tuple(entry_model_display_label(code) for code, _, _, _, _ in _INTRADAY_ENTRY_SPECS)
|
||||
|
||||
|
||||
def normalize_review_entry_reason(raw: Optional[str], allowed: Sequence[str]) -> str:
|
||||
"""复盘/核对开仓类型:允许两级展示名,兼容旧短标签."""
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
allowed_set = frozenset(allowed)
|
||||
if s in allowed_set:
|
||||
return s
|
||||
disp = format_entry_type_display(s)
|
||||
if disp in allowed_set:
|
||||
return disp
|
||||
code = normalize_entry_model_code(s)
|
||||
if code:
|
||||
disp2 = entry_model_display_label(code)
|
||||
if disp2 in allowed_set:
|
||||
return disp2
|
||||
return ""
|
||||
|
||||
|
||||
def trend_manual_entry_reason_count(policy: TradePolicy) -> int:
|
||||
if is_intraday_trading_profile(policy):
|
||||
return len(intraday_entry_reason_display_options())
|
||||
return len(trend_div_entry_reason_display_options())
|
||||
|
||||
|
||||
def build_journal_entry_reason_options() -> Tuple[str, ...]:
|
||||
"""复盘开仓类型:仅 entry model,不含 trade_style 兜底与策略/关键位自动类型."""
|
||||
return trend_div_entry_reason_display_options()
|
||||
|
||||
|
||||
def build_trend_div_entry_reason_options(
|
||||
strategy_options: Sequence[str],
|
||||
) -> Tuple[str, ...]:
|
||||
del strategy_options
|
||||
return trend_div_entry_reason_display_options() + TRADE_STYLE_FALLBACK_ENTRY_REASONS
|
||||
|
||||
|
||||
def build_intraday_entry_reason_options(
|
||||
key_options: Sequence[str],
|
||||
strategy_options: Sequence[str],
|
||||
) -> Tuple[str, ...]:
|
||||
del strategy_options, key_options
|
||||
return intraday_entry_reason_display_options()
|
||||
|
||||
|
||||
def entry_reason_options_for_policy(
|
||||
policy: TradePolicy,
|
||||
key_options: Sequence[str],
|
||||
strategy_options: Sequence[str],
|
||||
) -> Tuple[str, ...]:
|
||||
del key_options, strategy_options
|
||||
if is_intraday_trading_profile(policy):
|
||||
return build_intraday_entry_reason_options((), ())
|
||||
return build_trend_div_entry_reason_options(())
|
||||
|
||||
|
||||
def parse_manual_order_style_fields(
|
||||
policy: TradePolicy,
|
||||
form: Mapping[str, Any],
|
||||
*,
|
||||
default_trade_style: str = "trend",
|
||||
) -> Tuple[str, Optional[str], Optional[str]]:
|
||||
"""返回 (trade_style, entry_model_code|None, error_message|None)."""
|
||||
if is_intraday_trading_profile(policy):
|
||||
entry_model = normalize_entry_model_code(form.get("entry_model"))
|
||||
if entry_model in INTRADAY_ENTRY_MODEL_CODES:
|
||||
return "trend", entry_model, None
|
||||
raw_style = (form.get("trade_style") or "").strip().lower()
|
||||
if raw_style in ("trend", "swing"):
|
||||
return raw_style, None, None
|
||||
return "", None, "请选择开仓类型(假破 / 结构突破)"
|
||||
|
||||
entry_model = normalize_entry_model_code(form.get("entry_model"))
|
||||
if not entry_model:
|
||||
return "", None, "请选择开仓类型(反转 / 顺势 / 波段)"
|
||||
trade_style = trade_style_for_entry_model(entry_model)
|
||||
return trade_style, entry_model, None
|
||||
|
||||
|
||||
def resolve_trade_record_entry_reason(
|
||||
*,
|
||||
entry_reason: Optional[str] = None,
|
||||
entry_model: Optional[str] = None,
|
||||
key_signal_type: Optional[str] = None,
|
||||
monitor_type: Optional[str] = None,
|
||||
trade_style: Optional[str] = None,
|
||||
entry_reason_from_key_signal=None,
|
||||
entry_reason_for_monitor_type=None,
|
||||
) -> str:
|
||||
er = (entry_reason or "").strip()
|
||||
if er:
|
||||
return er
|
||||
label = entry_model_display_label(entry_model)
|
||||
if label:
|
||||
return label
|
||||
kst = (key_signal_type or "").strip()
|
||||
if kst and entry_reason_from_key_signal is not None:
|
||||
from_key = (entry_reason_from_key_signal(kst) or "").strip()
|
||||
if from_key:
|
||||
return from_key
|
||||
if entry_reason_for_monitor_type is not None:
|
||||
from_mt = (entry_reason_for_monitor_type(monitor_type) or "").strip()
|
||||
if from_mt:
|
||||
return from_mt
|
||||
ts = (trade_style or "").strip().lower()
|
||||
if ts in ("trend", "swing"):
|
||||
return trade_style_label_zh(ts)
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_effective_trade_entry_reason(
|
||||
*,
|
||||
reviewed_entry_reason: Optional[str] = None,
|
||||
entry_reason: Optional[str] = None,
|
||||
entry_model: Optional[str] = None,
|
||||
key_signal_type: Optional[str] = None,
|
||||
monitor_type: Optional[str] = None,
|
||||
trade_style: Optional[str] = None,
|
||||
entry_reason_from_key_signal=None,
|
||||
entry_reason_for_monitor_type=None,
|
||||
) -> str:
|
||||
"""交易记录展示/导出用:复盘优先,再回落 entry_model / 关键位 / 策略 / trade_style."""
|
||||
for raw in (reviewed_entry_reason, entry_reason):
|
||||
er = (raw or "").strip()
|
||||
if er:
|
||||
return format_entry_type_display(
|
||||
er,
|
||||
entry_model=entry_model,
|
||||
trade_style=trade_style,
|
||||
)
|
||||
return format_entry_type_display(
|
||||
resolve_trade_record_entry_reason(
|
||||
entry_model=entry_model,
|
||||
key_signal_type=key_signal_type,
|
||||
monitor_type=monitor_type,
|
||||
trade_style=trade_style,
|
||||
entry_reason_from_key_signal=entry_reason_from_key_signal,
|
||||
entry_reason_for_monitor_type=entry_reason_for_monitor_type,
|
||||
),
|
||||
entry_model=entry_model,
|
||||
trade_style=trade_style,
|
||||
)
|
||||
|
||||
|
||||
def enrich_entry_model_display(item: dict) -> dict:
|
||||
code = normalize_entry_model_code(item.get("entry_model"))
|
||||
if code:
|
||||
item["entry_model"] = code
|
||||
item["entry_model_label"] = entry_model_display_label(code)
|
||||
cat = entry_model_category(code)
|
||||
if cat:
|
||||
item["entry_model_category"] = cat
|
||||
item["entry_model_category_label"] = ENTRY_CATEGORY_LABELS.get(cat, "")
|
||||
else:
|
||||
item.setdefault("entry_model_label", "")
|
||||
return item
|
||||
|
||||
|
||||
def open_position_button_label(policy: TradePolicy, sizing_mode: str) -> str:
|
||||
from lib.trade.position_sizing_lib import mode_label_zh
|
||||
|
||||
mode_txt = mode_label_zh(sizing_mode)
|
||||
if is_intraday_trading_profile(policy):
|
||||
return f"开仓(日内·{mode_txt})"
|
||||
return f"开仓({mode_txt})"
|
||||
|
||||
|
||||
def order_entry_template_context(policy: TradePolicy) -> dict:
|
||||
profile = order_entry_profile(policy)
|
||||
opts = entry_model_options()
|
||||
intraday_opts = intraday_entry_model_options()
|
||||
return {
|
||||
"order_entry_profile": profile,
|
||||
"intraday_discipline": profile == PROFILE_INTRADAY,
|
||||
"intraday_entry_model_options": [
|
||||
{
|
||||
"code": o.code,
|
||||
"label": o.label,
|
||||
"trade_style": o.trade_style,
|
||||
"help": o.help,
|
||||
}
|
||||
for o in intraday_opts
|
||||
],
|
||||
"entry_model_options": [
|
||||
{
|
||||
"code": o.code,
|
||||
"label": o.label,
|
||||
"category": o.category,
|
||||
"trade_style": o.trade_style,
|
||||
"help": o.help,
|
||||
}
|
||||
for o in opts
|
||||
],
|
||||
"entry_model_categories": entry_model_categories(),
|
||||
"entry_model_trade_style_map": {o.code: o.trade_style for o in opts},
|
||||
"entry_model_code_to_category": {o.code: o.category for o in opts},
|
||||
}
|
||||
|
||||
|
||||
def meta_entry_context(policy: TradePolicy) -> dict:
|
||||
"""开仓入口展示上下文(风格/是否隐藏委托等)."""
|
||||
profile = order_entry_profile(policy)
|
||||
return {
|
||||
"order_entry_profile": profile,
|
||||
"intraday_discipline": profile == PROFILE_INTRADAY,
|
||||
}
|
||||
|
||||
|
||||
def migrate_entry_model_columns(conn) -> None:
|
||||
for table in ("order_monitors", "trade_records"):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN entry_model TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 兼容旧引用:现为两级展示文案
|
||||
TREND_DIV_ENTRY_REASON_LABELS = trend_div_entry_reason_display_options()
|
||||
@@ -0,0 +1,366 @@
|
||||
"""整点强制清仓(FORCE_CLOSE_*):UI 标识与持仓倒计时."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
FORCE_CLOSE_RESULT = "强制清仓"
|
||||
# 默认宽限(分钟);运行时优先读 FORCE_CLOSE_GRACE_MINUTES
|
||||
FORCE_CLOSE_GRACE_MINUTES = 5
|
||||
|
||||
|
||||
def force_close_grace_minutes(override: Any = None) -> int:
|
||||
"""整点强制清仓执行窗口长度(分钟)."""
|
||||
if override is not None and str(override).strip() != "":
|
||||
raw = override
|
||||
else:
|
||||
raw = os.getenv("FORCE_CLOSE_GRACE_MINUTES")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
raw = FORCE_CLOSE_GRACE_MINUTES
|
||||
try:
|
||||
return max(1, int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return max(1, int(FORCE_CLOSE_GRACE_MINUTES))
|
||||
|
||||
|
||||
def app_timezone_name() -> str:
|
||||
return (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||
|
||||
|
||||
def normalize_force_close_bj_hour(value: Any) -> int:
|
||||
try:
|
||||
h = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(0, min(23, h))
|
||||
|
||||
|
||||
def _now_dt(*, now_ms: Optional[int] = None, tz_name: Optional[str] = None) -> datetime:
|
||||
tz = ZoneInfo(tz_name or app_timezone_name())
|
||||
if now_ms is None:
|
||||
return datetime.now(tz)
|
||||
return datetime.fromtimestamp(int(now_ms) / 1000, tz=tz)
|
||||
|
||||
|
||||
def force_close_hour_label(bj_hour: Any) -> str:
|
||||
return f"{normalize_force_close_bj_hour(bj_hour):02d}:00"
|
||||
|
||||
|
||||
def force_close_label(bj_hour: Any) -> str:
|
||||
return f"强制清仓 {force_close_hour_label(bj_hour)}"
|
||||
|
||||
|
||||
def is_force_close_active_hour(
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
grace_minutes: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""当前是否处于整点强制清仓执行窗口(整点起 grace 分钟内)."""
|
||||
return is_force_close_executing(
|
||||
bj_hour,
|
||||
now_ms=now_ms,
|
||||
tz_name=tz_name,
|
||||
grace_minutes=grace_minutes,
|
||||
)
|
||||
|
||||
|
||||
def is_force_close_executing(
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
grace_minutes: Optional[int] = None,
|
||||
) -> bool:
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
now = _now_dt(now_ms=now_ms, tz_name=tz_name)
|
||||
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if now < target:
|
||||
return False
|
||||
grace = force_close_grace_minutes(grace_minutes)
|
||||
end = target + timedelta(minutes=grace)
|
||||
return now < end
|
||||
|
||||
|
||||
def force_close_blocks_new_open(
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
grace_minutes: Optional[int] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""强制清仓执行窗口内禁止新开仓.返回 (是否拦截, 说明文案)."""
|
||||
if not enabled:
|
||||
return False, ""
|
||||
grace = force_close_grace_minutes(grace_minutes)
|
||||
if not is_force_close_executing(
|
||||
bj_hour, now_ms=now_ms, tz_name=tz_name, grace_minutes=grace
|
||||
):
|
||||
return False, ""
|
||||
label = force_close_hour_label(bj_hour)
|
||||
return (
|
||||
True,
|
||||
f"强制清仓窗口内(北京时间 {label} 起 {grace} 分钟),暂不可开仓",
|
||||
)
|
||||
|
||||
|
||||
def parse_closed_at_dt(
|
||||
closed_at: Any,
|
||||
*,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> Optional[datetime]:
|
||||
if closed_at is None:
|
||||
return None
|
||||
text = str(closed_at).strip()
|
||||
if not text:
|
||||
return None
|
||||
tz = ZoneInfo(tz_name or app_timezone_name())
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).replace(tzinfo=tz)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def is_close_at_force_close_window(
|
||||
closed_at: Any,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
grace_minutes: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""平仓时刻是否落在北京时间整点强制清仓窗口内."""
|
||||
dt = parse_closed_at_dt(closed_at, tz_name=tz_name)
|
||||
if dt is None:
|
||||
return False
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
if dt.hour != hour:
|
||||
return False
|
||||
return dt.minute < force_close_grace_minutes(grace_minutes)
|
||||
|
||||
|
||||
def infer_force_close_result(
|
||||
closed_at: Any,
|
||||
*,
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
grace_minutes: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not enabled:
|
||||
return None
|
||||
if is_close_at_force_close_window(
|
||||
closed_at, bj_hour, grace_minutes=grace_minutes, tz_name=tz_name
|
||||
):
|
||||
return FORCE_CLOSE_RESULT
|
||||
return None
|
||||
|
||||
|
||||
def coerce_force_close_result(
|
||||
result: Optional[str],
|
||||
closed_at: Any,
|
||||
*,
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
miss_reason: Optional[str] = None,
|
||||
grace_minutes: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> tuple[str, str]:
|
||||
"""同步平仓归类:整点窗口内优先记为强制清仓."""
|
||||
res = (result or "").strip()
|
||||
note = (miss_reason or "").strip()
|
||||
if res == FORCE_CLOSE_RESULT:
|
||||
return res, note
|
||||
fc = infer_force_close_result(
|
||||
closed_at,
|
||||
enabled=enabled,
|
||||
bj_hour=bj_hour,
|
||||
grace_minutes=grace_minutes,
|
||||
tz_name=tz_name,
|
||||
)
|
||||
if not fc:
|
||||
return res, note
|
||||
if not note:
|
||||
note = f"北京时间 {force_close_hour_label(bj_hour)} 整点风控清仓"
|
||||
return fc, note
|
||||
|
||||
|
||||
def apply_force_close_display_result(
|
||||
result: Optional[str],
|
||||
closed_at: Any,
|
||||
*,
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
grace_minutes: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""展示层:外部平仓/手动平仓若落在整点窗口,显示为强制清仓."""
|
||||
res = (result or "").strip()
|
||||
if res == FORCE_CLOSE_RESULT:
|
||||
return res
|
||||
fc = infer_force_close_result(
|
||||
closed_at,
|
||||
enabled=enabled,
|
||||
bj_hour=bj_hour,
|
||||
grace_minutes=grace_minutes,
|
||||
tz_name=tz_name,
|
||||
)
|
||||
if fc and (res in ("", "外部平仓", "手动平仓") or res.startswith("外部平仓")):
|
||||
return fc
|
||||
return res
|
||||
|
||||
|
||||
def compute_next_force_close_at_ms(
|
||||
*,
|
||||
bj_hour: Any,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""下一次强制清仓时刻(北京时间整点)的 epoch 毫秒."""
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
now = _now_dt(now_ms=now_ms, tz_name=tz_name)
|
||||
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if now >= target:
|
||||
target += timedelta(days=1)
|
||||
return int(target.timestamp() * 1000)
|
||||
|
||||
|
||||
def force_close_remaining_seconds(
|
||||
close_at_ms: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
) -> Optional[int]:
|
||||
try:
|
||||
close_at = int(close_at_ms)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
return max(0, int((close_at - now) / 1000))
|
||||
|
||||
|
||||
def format_force_close_countdown(seconds: Any, *, active: bool = False) -> str:
|
||||
if active:
|
||||
return "执行中"
|
||||
try:
|
||||
sec = max(0, int(seconds))
|
||||
except (TypeError, ValueError):
|
||||
return "--:--:--"
|
||||
h = sec // 3600
|
||||
m = (sec % 3600) // 60
|
||||
s = sec % 60
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def build_force_close_state(
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
has_active_positions: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""实例级强制清仓状态(模板 / API 共用)."""
|
||||
grace = force_close_grace_minutes()
|
||||
if not enabled:
|
||||
return {
|
||||
"enabled": False,
|
||||
"bj_hour": normalize_force_close_bj_hour(bj_hour),
|
||||
"hour_label": force_close_hour_label(bj_hour),
|
||||
"label": force_close_label(bj_hour),
|
||||
"grace_minutes": grace,
|
||||
"next_at_ms": None,
|
||||
"remaining_sec": None,
|
||||
"countdown": "",
|
||||
"active": False,
|
||||
"executing": False,
|
||||
}
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
executing = is_force_close_executing(
|
||||
hour, now_ms=now_ms, tz_name=tz_name, grace_minutes=grace
|
||||
)
|
||||
active = executing and (has_active_positions is not False)
|
||||
next_at_ms = compute_next_force_close_at_ms(bj_hour=hour, now_ms=now_ms, tz_name=tz_name)
|
||||
rem = force_close_remaining_seconds(next_at_ms, now_ms=now_ms) if next_at_ms else None
|
||||
return {
|
||||
"enabled": True,
|
||||
"bj_hour": hour,
|
||||
"hour_label": force_close_hour_label(hour),
|
||||
"label": force_close_label(hour),
|
||||
"grace_minutes": grace,
|
||||
"next_at_ms": next_at_ms,
|
||||
"remaining_sec": rem,
|
||||
"countdown": format_force_close_countdown(rem, active=active),
|
||||
"active": active,
|
||||
"executing": executing,
|
||||
}
|
||||
|
||||
|
||||
def force_close_template_context(
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
has_active_positions: Optional[bool] = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
"force_close": build_force_close_state(
|
||||
enabled,
|
||||
bj_hour,
|
||||
now_ms=now_ms,
|
||||
tz_name=tz_name,
|
||||
has_active_positions=has_active_positions,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def apply_force_close_to_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""为 active 持仓 JSON 附加整点强制清仓倒计时."""
|
||||
state = build_force_close_state(
|
||||
enabled,
|
||||
bj_hour,
|
||||
now_ms=now_ms,
|
||||
tz_name=tz_name,
|
||||
has_active_positions=True,
|
||||
)
|
||||
payload["force_close_enabled"] = bool(state["enabled"])
|
||||
payload["force_close_bj_hour"] = state["bj_hour"]
|
||||
payload["force_close_at_ms"] = state["next_at_ms"]
|
||||
payload["force_close_label"] = state["label"] if state["enabled"] else ""
|
||||
payload["force_close_remaining_sec"] = state["remaining_sec"]
|
||||
payload["force_close_countdown"] = state["countdown"]
|
||||
payload["force_close_active"] = bool(state["active"])
|
||||
|
||||
|
||||
def enrich_orders_force_close(
|
||||
orders: list[dict[str, Any]],
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> None:
|
||||
if not enabled or not orders:
|
||||
return
|
||||
for item in orders:
|
||||
if isinstance(item, dict):
|
||||
apply_force_close_to_payload(
|
||||
item,
|
||||
enabled=enabled,
|
||||
bj_hour=bj_hour,
|
||||
now_ms=now_ms,
|
||||
tz_name=tz_name,
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""实盘人工下单:止盈止损模式(价格 / 百分比 / 固定盈亏比)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
MANUAL_FIXED_RR_DEFAULT = 1.5
|
||||
|
||||
SLTP_MODE_PRICE = "price"
|
||||
SLTP_MODE_PCT = "pct"
|
||||
SLTP_MODE_FIXED_RR = "fixed_rr"
|
||||
|
||||
OPEN_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT, SLTP_MODE_FIXED_RR})
|
||||
ENTRUST_SLTP_MODES = frozenset({SLTP_MODE_PRICE, SLTP_MODE_PCT})
|
||||
|
||||
|
||||
def normalize_open_sltp_mode(raw: Optional[str]) -> str:
|
||||
mode = (raw or SLTP_MODE_FIXED_RR).strip().lower()
|
||||
if mode in OPEN_SLTP_MODES:
|
||||
return mode
|
||||
return SLTP_MODE_PRICE
|
||||
|
||||
|
||||
def normalize_entrust_sltp_mode(raw: Optional[str]) -> str:
|
||||
mode = (raw or SLTP_MODE_PRICE).strip().lower()
|
||||
if mode in ENTRUST_SLTP_MODES:
|
||||
return mode
|
||||
return SLTP_MODE_PRICE
|
||||
|
||||
|
||||
def parse_fixed_rr(raw: Any, *, default: float = MANUAL_FIXED_RR_DEFAULT) -> float:
|
||||
try:
|
||||
v = float(raw)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return float(default)
|
||||
|
||||
|
||||
def calc_tp_from_fixed_rr(
|
||||
direction: str,
|
||||
entry_price: float,
|
||||
stop_loss: float,
|
||||
rr_ratio: float,
|
||||
) -> float:
|
||||
entry = float(entry_price)
|
||||
sl = float(stop_loss)
|
||||
rr = float(rr_ratio)
|
||||
if entry <= 0 or sl <= 0 or rr <= 0:
|
||||
raise ValueError("固定盈亏比参数无效")
|
||||
side = (direction or "long").strip().lower()
|
||||
if side == "short":
|
||||
risk = sl - entry
|
||||
if risk <= 0:
|
||||
raise ValueError("止损方向不合法:做空时止损须高于入场价")
|
||||
return entry - risk * rr
|
||||
risk = entry - sl
|
||||
if risk <= 0:
|
||||
raise ValueError("止损方向不合法:做多时止损须低于入场价")
|
||||
return entry + risk * rr
|
||||
|
||||
|
||||
def _resolve_pct_sltp(direction: str, live_price: float, data: dict[str, Any]) -> Tuple[float, float]:
|
||||
sl_pct = float(data.get("sl_pct") or 0)
|
||||
tp_pct = float(data.get("tp_pct") or 0)
|
||||
if sl_pct <= 0 or tp_pct <= 0:
|
||||
raise ValueError("百分比止盈止损须为正数")
|
||||
sl_ratio = sl_pct / 100.0
|
||||
tp_ratio = tp_pct / 100.0
|
||||
entry = float(live_price)
|
||||
if (direction or "long").strip().lower() == "short":
|
||||
stop_loss = entry * (1 + sl_ratio)
|
||||
take_profit = entry * (1 - tp_ratio)
|
||||
else:
|
||||
stop_loss = entry * (1 - sl_ratio)
|
||||
take_profit = entry * (1 + tp_ratio)
|
||||
return stop_loss, take_profit
|
||||
|
||||
|
||||
def _resolve_price_sltp(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
fallback_sl: Optional[float] = None,
|
||||
fallback_tp: Optional[float] = None,
|
||||
require_tp: bool = True,
|
||||
) -> Tuple[float, float]:
|
||||
stop_loss = float(data.get("sl") or data.get("stop_loss") or 0)
|
||||
take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0)
|
||||
if stop_loss <= 0 and fallback_sl is not None:
|
||||
stop_loss = float(fallback_sl)
|
||||
if take_profit <= 0 and fallback_tp is not None:
|
||||
take_profit = float(fallback_tp)
|
||||
if stop_loss <= 0:
|
||||
raise ValueError("止损价格须大于 0" if require_tp else "请填写止损价格")
|
||||
if require_tp and take_profit <= 0:
|
||||
raise ValueError("止盈止损价格须大于 0" if fallback_tp is None else "请填写止盈价格,或保留原计划止盈")
|
||||
return stop_loss, take_profit
|
||||
|
||||
|
||||
def resolve_open_sltp_prices(
|
||||
direction: str,
|
||||
live_price: float,
|
||||
sltp_mode: Optional[str],
|
||||
data: dict[str, Any],
|
||||
) -> Tuple[float, float]:
|
||||
"""新开仓 /add_order:支持 price,pct,fixed_rr."""
|
||||
mode = normalize_open_sltp_mode(sltp_mode)
|
||||
if mode == SLTP_MODE_PCT:
|
||||
return _resolve_pct_sltp(direction, live_price, data)
|
||||
if mode == SLTP_MODE_FIXED_RR:
|
||||
stop_loss, _ = _resolve_price_sltp(data, require_tp=False)
|
||||
rr = parse_fixed_rr(data.get("fixed_rr"))
|
||||
take_profit = calc_tp_from_fixed_rr(direction, live_price, stop_loss, rr)
|
||||
return stop_loss, take_profit
|
||||
return _resolve_price_sltp(data, require_tp=True)
|
||||
|
||||
|
||||
def resolve_entrust_sltp_prices(
|
||||
direction: str,
|
||||
live_price: float,
|
||||
sltp_mode: Optional[str],
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
fallback_sl: Optional[float] = None,
|
||||
fallback_tp: Optional[float] = None,
|
||||
) -> Tuple[float, float]:
|
||||
"""持仓委托弹窗:仅 price / pct,不校验盈亏比."""
|
||||
mode = normalize_entrust_sltp_mode(sltp_mode)
|
||||
if mode == SLTP_MODE_PCT:
|
||||
return _resolve_pct_sltp(direction, live_price, data)
|
||||
return _resolve_price_sltp(
|
||||
data,
|
||||
fallback_sl=fallback_sl,
|
||||
fallback_tp=fallback_tp,
|
||||
require_tp=True,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""三所开仓门禁:账户风控 + 强制清仓窗口 + 仓位/日开仓上限."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.trade.daily_open_limit_lib import can_trade_new_open
|
||||
from lib.trade.force_close_lib import force_close_blocks_new_open
|
||||
|
||||
|
||||
def resolve_manual_open_gate(
|
||||
*,
|
||||
time_allows: bool,
|
||||
active_count: int,
|
||||
max_active_positions: int,
|
||||
opens_today: int,
|
||||
hard_limit: int,
|
||||
risk_status: Optional[dict[str, Any]],
|
||||
force_close_enabled: bool,
|
||||
force_close_bj_hour: Any,
|
||||
now_ms: Optional[int] = None,
|
||||
reset_hour: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""汇总是否可开仓及按钮旁说明文案."""
|
||||
rs = risk_status if isinstance(risk_status, dict) else {}
|
||||
risk_can = bool(rs.get("can_trade", True))
|
||||
fc_block, fc_note = force_close_blocks_new_open(
|
||||
bool(force_close_enabled),
|
||||
force_close_bj_hour,
|
||||
now_ms=now_ms,
|
||||
)
|
||||
can_trade = can_trade_new_open(
|
||||
time_allows=time_allows,
|
||||
active_count=active_count,
|
||||
max_active_positions=max_active_positions,
|
||||
opens_today=opens_today,
|
||||
hard_limit=hard_limit,
|
||||
extra_blocks=(not risk_can) or fc_block,
|
||||
)
|
||||
note = ""
|
||||
if fc_block and fc_note:
|
||||
note = fc_note
|
||||
elif not risk_can:
|
||||
note = str(rs.get("reason") or "账户冷静期/日冻结中,暂不可开仓")
|
||||
elif not time_allows:
|
||||
note = f"未到北京时间 {int(reset_hour)}:00,暂不可开仓"
|
||||
elif int(active_count) >= int(max_active_positions):
|
||||
note = f"已达最大持仓数({int(active_count)}/{int(max_active_positions)}),暂不可开仓"
|
||||
elif int(hard_limit) > 0 and int(opens_today) >= int(hard_limit):
|
||||
note = (
|
||||
f"本交易日开仓已达上限({int(opens_today)}/{int(hard_limit)}),"
|
||||
f"次日北京时间 {int(reset_hour)}:00 后恢复"
|
||||
)
|
||||
return {
|
||||
"can_trade": can_trade,
|
||||
"open_block_note": note if not can_trade else "",
|
||||
"force_close_blocks": fc_block,
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
"""实时持仓展示:开仓快照盈亏比,交易所止损是否已保本."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
def _positive_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
v = float(value)
|
||||
return v if v > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float]:
|
||||
"""展示盈亏比 / 交易记录时优先用开仓时止损快照,不用后续改单后的止损."""
|
||||
sl = _positive_float(initial_stop_loss)
|
||||
if sl is not None:
|
||||
return sl
|
||||
return _positive_float(stop_loss)
|
||||
|
||||
|
||||
def monitor_open_stop_loss(row: Any) -> Optional[float]:
|
||||
"""从 order_monitors 行取开仓止损快照."""
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else ()
|
||||
except Exception:
|
||||
keys = ()
|
||||
init = row["initial_stop_loss"] if "initial_stop_loss" in keys else None
|
||||
cur = row["stop_loss"] if "stop_loss" in keys else None
|
||||
if init is None and isinstance(row, dict):
|
||||
init = row.get("initial_stop_loss")
|
||||
cur = row.get("stop_loss")
|
||||
return snapshot_stop_loss(init, cur)
|
||||
|
||||
|
||||
def snapshot_rr(
|
||||
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
||||
direction: str,
|
||||
trigger_price: Any,
|
||||
initial_stop_loss: Any,
|
||||
stop_loss: Any,
|
||||
take_profit: Any,
|
||||
) -> Optional[float]:
|
||||
entry = _positive_float(trigger_price)
|
||||
sl = snapshot_stop_loss(initial_stop_loss, stop_loss)
|
||||
tp = _positive_float(take_profit)
|
||||
if entry is None or sl is None or tp is None:
|
||||
return None
|
||||
return calc_rr_ratio_fn(direction or "long", entry, sl, tp)
|
||||
|
||||
|
||||
def tpsl_slot_trigger_price(slot: Any) -> Optional[float]:
|
||||
if not isinstance(slot, dict):
|
||||
return None
|
||||
for key in ("trigger_price", "trigger_display"):
|
||||
v = _positive_float(slot.get(key))
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def stop_is_profit_protecting(direction: str, entry_price: Any, stop_loss: Any) -> bool:
|
||||
"""
|
||||
止损是否已在盈利侧(保本/锁盈),不再适用「开仓盈亏比」风控.
|
||||
做空:止损 < 成交价;做多:止损 > 成交价.
|
||||
"""
|
||||
entry = _positive_float(entry_price)
|
||||
sl = _positive_float(stop_loss)
|
||||
if entry is None or sl is None:
|
||||
return False
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
return sl < entry
|
||||
return sl > entry
|
||||
|
||||
|
||||
def tpsl_update_passes_rr_gate(
|
||||
direction: str,
|
||||
entry_price: Any,
|
||||
stop_loss: Any,
|
||||
take_profit: Any,
|
||||
min_rr: float,
|
||||
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""持仓委托改价:盈利侧止损跳过最低盈亏比;否则按开仓价几何校验."""
|
||||
if stop_is_profit_protecting(direction, entry_price, stop_loss):
|
||||
return True, None
|
||||
rr = calc_rr_ratio_fn(direction or "long", entry_price, stop_loss, take_profit)
|
||||
if rr is not None and rr >= float(min_rr):
|
||||
return True, None
|
||||
rr_txt = f"{rr:.4f}" if rr is not None else "无法计算"
|
||||
return False, f"计划盈亏比 {rr_txt}:1 低于最低要求 {min_rr}:1(盈利侧保本止损不受此限)"
|
||||
|
||||
|
||||
def resolve_breakeven_entry_price(entry_price: Any, avg_entry_price: Any = None) -> Optional[float]:
|
||||
"""保本判断基准价:有持仓加权均价时优先(滚仓后),否则用首仓成交价."""
|
||||
avg = _positive_float(avg_entry_price)
|
||||
if avg is not None:
|
||||
return avg
|
||||
return _positive_float(entry_price)
|
||||
|
||||
|
||||
def stale_breakeven_armed(direction: str, entry_price: Any, stop_loss: Any, breakeven_armed: Any) -> bool:
|
||||
"""止损已回到亏损侧时 breakeven_armed 视为过期(如滚仓下移止损)."""
|
||||
try:
|
||||
armed = int(breakeven_armed or 0) != 0
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if not armed:
|
||||
return False
|
||||
return not stop_is_profit_protecting(direction, entry_price, stop_loss)
|
||||
|
||||
|
||||
def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool:
|
||||
"""
|
||||
交易所当前止损相对开仓成交价是否已保本.
|
||||
做多:止损 >= 成交价;做空:止损 <= 成交价.
|
||||
"""
|
||||
entry = _positive_float(entry_price)
|
||||
sl = _positive_float(exchange_sl_price)
|
||||
if entry is None or sl is None:
|
||||
return False
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
return sl <= entry
|
||||
return sl >= entry
|
||||
|
||||
|
||||
def sl_breakeven_from_exchange_tpsl(
|
||||
direction: str,
|
||||
entry_price: Any,
|
||||
exchange_tpsl: Any,
|
||||
) -> bool:
|
||||
if not isinstance(exchange_tpsl, dict):
|
||||
return False
|
||||
sl_px = tpsl_slot_trigger_price(exchange_tpsl.get("sl"))
|
||||
if sl_px is None:
|
||||
return False
|
||||
return is_sl_breakeven_secured(direction, entry_price, sl_px)
|
||||
|
||||
|
||||
def enrich_order_display_fields(item: dict[str, Any], calc_rr_ratio_fn: Callable[..., Optional[float]]) -> dict[str, Any]:
|
||||
item["rr_ratio"] = snapshot_rr(
|
||||
calc_rr_ratio_fn,
|
||||
item.get("direction") or "long",
|
||||
item.get("trigger_price"),
|
||||
item.get("initial_stop_loss"),
|
||||
item.get("stop_loss"),
|
||||
item.get("take_profit"),
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def apply_order_live_price_display(
|
||||
payload: dict[str, Any],
|
||||
symbol: Any,
|
||||
ticker_price: Any,
|
||||
exchange_mark_price: Any,
|
||||
format_price_fn: Callable[[Any, Any], str],
|
||||
) -> dict[str, Any]:
|
||||
"""标记价/现价展示:与交易所 price_to_precision 对齐,避免前端 toFixed(8)."""
|
||||
px_for_fmt = ticker_price
|
||||
mark_raw = exchange_mark_price
|
||||
if mark_raw is not None:
|
||||
try:
|
||||
px_for_fmt = float(mark_raw)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
px_disp = format_price_fn(symbol, px_for_fmt)
|
||||
payload["price_display"] = px_disp
|
||||
if mark_raw is not None:
|
||||
try:
|
||||
payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw))
|
||||
except (TypeError, ValueError):
|
||||
payload["exchange_mark_price_display"] = px_disp
|
||||
else:
|
||||
payload["exchange_mark_price_display"] = None
|
||||
return payload
|
||||
|
||||
|
||||
def resolve_live_tpsl_prices(
|
||||
plan_sl: Any,
|
||||
plan_tp: Any,
|
||||
exchange_tpsl: Any,
|
||||
) -> tuple[Optional[float], Optional[float], Optional[float], Optional[float]]:
|
||||
"""返回 (展示用止损, 展示用止盈, 交易所止损, 交易所止盈)."""
|
||||
ex_sl = ex_tp = None
|
||||
if isinstance(exchange_tpsl, dict):
|
||||
ex_sl = tpsl_slot_trigger_price(exchange_tpsl.get("sl"))
|
||||
ex_tp = tpsl_slot_trigger_price(exchange_tpsl.get("tp"))
|
||||
disp_sl = ex_sl if ex_sl is not None else _positive_float(plan_sl)
|
||||
disp_tp = ex_tp if ex_tp is not None else _positive_float(plan_tp)
|
||||
return disp_sl, disp_tp, ex_sl, ex_tp
|
||||
|
||||
|
||||
def calc_risk_fraction(direction: str, entry_price: Any, stop_loss: Any) -> Optional[float]:
|
||||
"""|入场-止损|/入场;盈利侧止损返回 0."""
|
||||
entry = _positive_float(entry_price)
|
||||
sl = _positive_float(stop_loss)
|
||||
if entry is None or sl is None:
|
||||
return None
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
risk = sl - entry
|
||||
else:
|
||||
risk = entry - sl
|
||||
if risk <= 0:
|
||||
return 0.0
|
||||
return risk / entry
|
||||
|
||||
|
||||
def calc_latest_risk_amount(
|
||||
direction: str,
|
||||
entry_price: Any,
|
||||
stop_loss: Any,
|
||||
*,
|
||||
margin_capital: Any = None,
|
||||
leverage: Any = None,
|
||||
exchange_notional: Any = None,
|
||||
contracts: Any = None,
|
||||
contract_size: Any = None,
|
||||
mark_price: Any = None,
|
||||
funds_decimals: int = 2,
|
||||
) -> Optional[float]:
|
||||
"""按当前止损与持仓名义价值估算最新风险(U)."""
|
||||
rf = calc_risk_fraction(direction, entry_price, stop_loss)
|
||||
if rf is None:
|
||||
return None
|
||||
if rf <= 0:
|
||||
return 0.0
|
||||
notional = _positive_float(exchange_notional)
|
||||
if notional is None:
|
||||
try:
|
||||
mc = float(margin_capital or 0)
|
||||
lev = float(leverage or 0)
|
||||
if mc > 0 and lev > 0:
|
||||
notional = mc * lev
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if notional is None:
|
||||
try:
|
||||
c = abs(float(contracts or 0))
|
||||
cs = float(contract_size or 1)
|
||||
if cs <= 0:
|
||||
cs = 1.0
|
||||
px = _positive_float(mark_price) or _positive_float(entry_price)
|
||||
if c > 0 and px is not None:
|
||||
notional = c * cs * px
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if notional is None or notional <= 0:
|
||||
return None
|
||||
return round(notional * rf, funds_decimals)
|
||||
|
||||
|
||||
def order_monitor_tpsl_needs_sync(
|
||||
plan_sl: Any,
|
||||
plan_tp: Any,
|
||||
exchange_tpsl: Any,
|
||||
*,
|
||||
eps: float = 1e-12,
|
||||
) -> tuple[Optional[float], Optional[float], bool]:
|
||||
"""若交易所 TP/SL 与库中不一致,返回应写回的 (sl, tp) 及是否需更新."""
|
||||
_, _, ex_sl, ex_tp = resolve_live_tpsl_prices(plan_sl, plan_tp, exchange_tpsl)
|
||||
try:
|
||||
cur_sl = float(plan_sl or 0)
|
||||
cur_tp = float(plan_tp or 0)
|
||||
except (TypeError, ValueError):
|
||||
cur_sl, cur_tp = 0.0, 0.0
|
||||
new_sl = ex_sl if ex_sl is not None else cur_sl
|
||||
new_tp = ex_tp if ex_tp is not None else cur_tp
|
||||
changed = (
|
||||
(ex_sl is not None and abs(new_sl - cur_sl) > eps)
|
||||
or (ex_tp is not None and abs(new_tp - cur_tp) > eps)
|
||||
)
|
||||
return new_sl, new_tp, changed
|
||||
|
||||
|
||||
def apply_order_price_display_fields(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
direction: str,
|
||||
entry_price: Any,
|
||||
initial_stop_loss: Any,
|
||||
stop_loss: Any,
|
||||
take_profit: Any,
|
||||
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
||||
exchange_tpsl: Any = None,
|
||||
format_price_fn: Optional[Callable[[Any, Any], str]] = None,
|
||||
symbol: Any = None,
|
||||
margin_capital: Any = None,
|
||||
leverage: Any = None,
|
||||
exchange_notional: Any = None,
|
||||
contracts: Any = None,
|
||||
contract_size: Any = None,
|
||||
mark_price: Any = None,
|
||||
avg_entry_price: Any = None,
|
||||
funds_decimals: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
disp_sl, disp_tp, _, _ = resolve_live_tpsl_prices(stop_loss, take_profit, exchange_tpsl)
|
||||
payload["stop_loss_raw"] = _positive_float(stop_loss)
|
||||
payload["take_profit_raw"] = _positive_float(take_profit)
|
||||
payload["rr_ratio"] = snapshot_rr(
|
||||
calc_rr_ratio_fn,
|
||||
direction,
|
||||
entry_price,
|
||||
initial_stop_loss,
|
||||
stop_loss,
|
||||
take_profit,
|
||||
)
|
||||
risk_entry = resolve_breakeven_entry_price(entry_price, avg_entry_price)
|
||||
payload["avg_entry_price"] = risk_entry
|
||||
payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl(
|
||||
direction, risk_entry, exchange_tpsl
|
||||
)
|
||||
payload["stop_loss"] = disp_sl
|
||||
payload["take_profit"] = disp_tp
|
||||
if disp_sl is not None and disp_tp is not None:
|
||||
payload["display_rr_ratio"] = calc_rr_ratio_fn(
|
||||
direction or "long", entry_price, disp_sl, disp_tp
|
||||
)
|
||||
else:
|
||||
payload["display_rr_ratio"] = None
|
||||
if contracts is not None:
|
||||
try:
|
||||
from lib.market.position_metrics_lib import normalize_contracts_qty
|
||||
|
||||
c = normalize_contracts_qty(contracts)
|
||||
if c > 0:
|
||||
payload["contracts"] = c
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
payload["latest_risk_amount"] = calc_latest_risk_amount(
|
||||
direction,
|
||||
risk_entry,
|
||||
disp_sl if disp_sl is not None else stop_loss,
|
||||
margin_capital=margin_capital,
|
||||
leverage=leverage,
|
||||
exchange_notional=exchange_notional,
|
||||
contracts=payload.get("contracts") if payload.get("contracts") is not None else contracts,
|
||||
contract_size=contract_size,
|
||||
mark_price=mark_price,
|
||||
funds_decimals=funds_decimals,
|
||||
)
|
||||
tp_for_reward = disp_tp if disp_tp is not None else _positive_float(take_profit)
|
||||
qty_for_reward = payload.get("contracts")
|
||||
if qty_for_reward is None and contracts is not None:
|
||||
try:
|
||||
qty_for_reward = abs(float(contracts))
|
||||
except (TypeError, ValueError):
|
||||
qty_for_reward = None
|
||||
if risk_entry is not None and tp_for_reward is not None and qty_for_reward:
|
||||
try:
|
||||
def reward_at_tp_usdt(direction, entry, tp, contracts, contract_size, fee_rate=0.0):
|
||||
try:
|
||||
d = (direction or "long").lower()
|
||||
e, t, c, cs = float(entry), float(tp), float(contracts), float(contract_size or 1)
|
||||
if e <= 0 or t <= 0 or c <= 0:
|
||||
return None
|
||||
raw = (t - e) * c * cs if d == "long" else (e - t) * c * cs
|
||||
return round(raw, 2)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
reward = reward_at_tp_usdt(
|
||||
direction,
|
||||
risk_entry,
|
||||
tp_for_reward,
|
||||
float(qty_for_reward),
|
||||
contract_size=float(contract_size or 1.0),
|
||||
)
|
||||
payload["reward_at_tp_usdt"] = (
|
||||
round(reward, funds_decimals) if reward is not None else None
|
||||
)
|
||||
except Exception:
|
||||
payload["reward_at_tp_usdt"] = None
|
||||
else:
|
||||
payload["reward_at_tp_usdt"] = None
|
||||
if format_price_fn is not None and symbol is not None:
|
||||
payload["stop_loss_display"] = (
|
||||
format_price_fn(symbol, disp_sl) if disp_sl is not None else "—"
|
||||
)
|
||||
payload["take_profit_display"] = (
|
||||
format_price_fn(symbol, disp_tp) if disp_tp is not None else "—"
|
||||
)
|
||||
mark_raw = mark_price if mark_price is not None else None
|
||||
if mark_raw is not None and format_price_fn is not None and symbol is not None:
|
||||
try:
|
||||
payload["exchange_mark_price_display"] = format_price_fn(symbol, float(mark_raw))
|
||||
except (TypeError, ValueError):
|
||||
payload["exchange_mark_price_display"] = None
|
||||
return payload
|
||||
|
||||
|
||||
def enrich_active_monitor_tpsl_json(
|
||||
row: Any,
|
||||
stop_loss: Any,
|
||||
take_profit: Any,
|
||||
exchange_tpsl: Any,
|
||||
*,
|
||||
position_row: Any = None,
|
||||
exchange_notional: Any = None,
|
||||
contracts: Any = None,
|
||||
contract_size: float = 1.0,
|
||||
mark_price: Any = None,
|
||||
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
||||
format_price_fn: Optional[Callable[[Any, Any], str]] = None,
|
||||
symbol: Any = None,
|
||||
funds_decimals: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""place_tpsl 响应:展示用 TP/SL,最新风险,当前盈亏比."""
|
||||
def _row_val(key: str, default=None):
|
||||
try:
|
||||
if hasattr(row, "keys") and key in row.keys():
|
||||
return row[key]
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(row, dict):
|
||||
return row.get(key, default)
|
||||
return default
|
||||
|
||||
direction = _row_val("direction") or "long"
|
||||
entry = _row_val("trigger_price")
|
||||
init_sl = _row_val("initial_stop_loss")
|
||||
margin = _row_val("margin_capital")
|
||||
leverage = _row_val("leverage")
|
||||
if position_row is not None:
|
||||
from lib.market.position_metrics_lib import parse_position_entry_price, position_contracts
|
||||
|
||||
live_c = position_contracts(position_row)
|
||||
if abs(live_c) >= 1e-12:
|
||||
contracts = abs(live_c)
|
||||
avg_entry = parse_position_entry_price(position_row)
|
||||
else:
|
||||
avg_entry = None
|
||||
payload: dict[str, Any] = {
|
||||
"stop_loss": stop_loss,
|
||||
"take_profit": take_profit,
|
||||
}
|
||||
apply_order_price_display_fields(
|
||||
payload,
|
||||
direction=direction,
|
||||
entry_price=entry,
|
||||
initial_stop_loss=init_sl,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
calc_rr_ratio_fn=calc_rr_ratio_fn,
|
||||
exchange_tpsl=exchange_tpsl,
|
||||
format_price_fn=format_price_fn,
|
||||
symbol=symbol or _row_val("symbol"),
|
||||
margin_capital=margin,
|
||||
leverage=leverage,
|
||||
exchange_notional=exchange_notional,
|
||||
contracts=contracts,
|
||||
contract_size=contract_size,
|
||||
mark_price=mark_price,
|
||||
avg_entry_price=avg_entry,
|
||||
funds_decimals=funds_decimals,
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
三所共用:计仓模式 risk(以损定仓)| full_margin(全仓杠杆).
|
||||
仅 env POSITION_SIZING_MODE 切换;须无持仓(由部署流程保证).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
MODE_RISK = "risk"
|
||||
MODE_FULL_MARGIN = "full_margin"
|
||||
VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN})
|
||||
|
||||
OPEN_SOURCE_MANUAL = "manual"
|
||||
# 历史兼容常量(策略/关键位自动单已移除,assert 仍可引用)
|
||||
OPEN_SOURCE_KEY_AUTO = "key_auto"
|
||||
OPEN_SOURCE_KEY_FIB = "key_fib"
|
||||
OPEN_SOURCE_KEY_TRIGGER = "key_trigger"
|
||||
OPEN_SOURCE_TREND = "trend"
|
||||
OPEN_SOURCE_ROLL = "roll"
|
||||
|
||||
FULL_MARGIN_BLOCKED_SOURCES = frozenset()
|
||||
|
||||
|
||||
def normalize_position_sizing_mode(raw: Optional[str]) -> str:
|
||||
v = (raw or MODE_RISK).strip().lower()
|
||||
if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"):
|
||||
return MODE_FULL_MARGIN
|
||||
return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK
|
||||
|
||||
|
||||
def load_position_sizing_mode(env: Optional[dict] = None) -> str:
|
||||
e = env if env is not None else os.environ
|
||||
return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE"))
|
||||
|
||||
|
||||
def is_full_margin_mode(mode: str) -> bool:
|
||||
return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN
|
||||
|
||||
|
||||
def mode_label_zh(mode: str) -> str:
|
||||
return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓"
|
||||
|
||||
|
||||
def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int:
|
||||
sym = (symbol or "").strip().upper()
|
||||
if sym.startswith("BTC") or sym.startswith("ETH"):
|
||||
return max(1, int(btc_leverage or 10))
|
||||
return max(1, int(alt_leverage or 5))
|
||||
|
||||
|
||||
def round_funds(value: float, decimals: int = 2) -> float:
|
||||
return round(float(value), int(decimals))
|
||||
|
||||
|
||||
def risk_percent_for_storage(mode: str, risk_percent: float) -> Optional[float]:
|
||||
"""全仓杠杆:库内不写风险百分比(仅 risk_amount U)."""
|
||||
if is_full_margin_mode(mode):
|
||||
return None
|
||||
return risk_percent
|
||||
|
||||
|
||||
def format_risk_display_text(
|
||||
mode: str,
|
||||
risk_percent: Optional[float],
|
||||
risk_amount: Optional[float],
|
||||
*,
|
||||
decimals: int = 2,
|
||||
) -> str:
|
||||
"""持仓/通知「风险」文案:全仓仅 U;以损定仓为 %≈U."""
|
||||
amt: Optional[float] = None
|
||||
if risk_amount is not None and risk_amount != "":
|
||||
try:
|
||||
amt = float(risk_amount)
|
||||
except (TypeError, ValueError):
|
||||
amt = None
|
||||
if is_full_margin_mode(mode):
|
||||
if amt is None:
|
||||
return "—"
|
||||
return f"{round_funds(amt, decimals)}U"
|
||||
pct: Optional[float] = None
|
||||
if risk_percent is not None and risk_percent != "":
|
||||
try:
|
||||
pct = float(risk_percent)
|
||||
except (TypeError, ValueError):
|
||||
pct = None
|
||||
pct_txt = f"{pct:g}" if pct is not None else "—"
|
||||
amt_txt = round_funds(amt, decimals) if amt is not None else "—"
|
||||
return f"{pct_txt}%≈{amt_txt}U"
|
||||
|
||||
|
||||
def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]:
|
||||
if not is_full_margin_mode(mode):
|
||||
return True, ""
|
||||
src = (source or "").strip().lower()
|
||||
if src in FULL_MARGIN_BLOCKED_SOURCES:
|
||||
return False, (
|
||||
"当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin),"
|
||||
"不允许关键位突破/斐波自动开仓,趋势回调与顺势加仓;"
|
||||
"仅支持实盘人工下单与阻力/支撑提醒."
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]:
|
||||
if active_count > 0:
|
||||
return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓"
|
||||
return True, ""
|
||||
|
||||
|
||||
def compute_full_margin_sizing(
|
||||
*,
|
||||
symbol: str,
|
||||
available_usdt: float,
|
||||
capital_base: float,
|
||||
buffer_ratio: float,
|
||||
btc_leverage: int,
|
||||
alt_leverage: int,
|
||||
funds_decimals: int = 2,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
if available_usdt is None or float(available_usdt) <= 0:
|
||||
return None, "全仓杠杆:无法读取合约账户可用保证金"
|
||||
lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage)
|
||||
margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals)
|
||||
if margin <= 0:
|
||||
return None, "全仓杠杆:可用保证金不足"
|
||||
notional = round_funds(margin * lev, funds_decimals)
|
||||
ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0
|
||||
return {
|
||||
"margin_capital": margin,
|
||||
"leverage": lev,
|
||||
"notional_value": notional,
|
||||
"position_ratio": ratio,
|
||||
"mode": MODE_FULL_MARGIN,
|
||||
}, None
|
||||
@@ -0,0 +1,150 @@
|
||||
"""持仓时间平仓:开仓后按 1h/2h/4h 定时市价平仓."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
ALLOWED_TIME_CLOSE_HOURS = (1, 2, 4)
|
||||
TIME_CLOSE_RESULT = "时间平仓"
|
||||
|
||||
|
||||
def parse_time_close_enabled_form(form_value: Any) -> int:
|
||||
return 1 if str(form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0
|
||||
|
||||
|
||||
def parse_time_close_hours_form(form_value: Any, *, default: int = 4) -> Optional[int]:
|
||||
raw = str(form_value or "").strip().lower().rstrip("h")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
h = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if h in ALLOWED_TIME_CLOSE_HOURS:
|
||||
return h
|
||||
return None
|
||||
|
||||
|
||||
def normalize_time_close_hours(value: Any) -> Optional[int]:
|
||||
try:
|
||||
h = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return h if h in ALLOWED_TIME_CLOSE_HOURS else None
|
||||
|
||||
|
||||
def _row_val(row: Any, key: str, default=None):
|
||||
if row is None:
|
||||
return default
|
||||
try:
|
||||
if hasattr(row, "keys") and key in row.keys():
|
||||
return row[key]
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(row, dict):
|
||||
return row.get(key, default)
|
||||
return default
|
||||
|
||||
|
||||
def time_close_settings_from_row(row: Any) -> tuple[int, Optional[int], Optional[int]]:
|
||||
"""返回 (enabled, hours, close_at_ms)."""
|
||||
enabled = int(_row_val(row, "time_close_enabled", 0) or 0) != 0
|
||||
hours = normalize_time_close_hours(_row_val(row, "time_close_hours"))
|
||||
close_at = _row_val(row, "time_close_at_ms")
|
||||
try:
|
||||
close_at_ms = int(close_at) if close_at not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
close_at_ms = None
|
||||
if enabled and hours and not close_at_ms:
|
||||
opened_ms = _row_val(row, "opened_at_ms")
|
||||
try:
|
||||
opened_ms = int(opened_ms) if opened_ms not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
opened_ms = None
|
||||
close_at_ms = compute_close_at_ms(opened_ms, hours)
|
||||
return (1 if enabled and hours else 0, hours, close_at_ms)
|
||||
|
||||
|
||||
def compute_close_at_ms(opened_at_ms: Any, hours: Any) -> Optional[int]:
|
||||
h = normalize_time_close_hours(hours)
|
||||
try:
|
||||
opened = int(opened_at_ms)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not h or opened <= 0:
|
||||
return None
|
||||
return opened + h * 3600 * 1000
|
||||
|
||||
|
||||
def should_trigger_time_close(row: Any, *, now_ms: Optional[int] = None) -> bool:
|
||||
enabled, hours, close_at_ms = time_close_settings_from_row(row)
|
||||
if not enabled or not close_at_ms:
|
||||
return False
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
return now >= int(close_at_ms)
|
||||
|
||||
|
||||
def time_close_remaining_seconds(close_at_ms: Any, *, now_ms: Optional[int] = None) -> Optional[int]:
|
||||
try:
|
||||
close_at = int(close_at_ms)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
return max(0, int((close_at - now) / 1000))
|
||||
|
||||
|
||||
def format_time_close_countdown(seconds: Any) -> str:
|
||||
try:
|
||||
sec = max(0, int(seconds))
|
||||
except (TypeError, ValueError):
|
||||
return "--:--:--"
|
||||
h = sec // 3600
|
||||
m = (sec % 3600) // 60
|
||||
s = sec % 60
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def time_close_label(hours: Any) -> str:
|
||||
h = normalize_time_close_hours(hours)
|
||||
return f"时间平仓 {h}h" if h else "时间平仓"
|
||||
|
||||
|
||||
def apply_time_close_to_payload(payload: dict[str, Any], row: Any, *, now_ms: Optional[int] = None) -> None:
|
||||
enabled, hours, close_at_ms = time_close_settings_from_row(row)
|
||||
payload["time_close_enabled"] = bool(enabled)
|
||||
payload["time_close_hours"] = hours
|
||||
payload["time_close_at_ms"] = close_at_ms
|
||||
payload["time_close_label"] = time_close_label(hours) if enabled else ""
|
||||
if enabled and close_at_ms:
|
||||
rem = time_close_remaining_seconds(close_at_ms, now_ms=now_ms)
|
||||
payload["time_close_remaining_sec"] = rem
|
||||
payload["time_close_countdown"] = format_time_close_countdown(rem)
|
||||
else:
|
||||
payload["time_close_remaining_sec"] = None
|
||||
payload["time_close_countdown"] = ""
|
||||
|
||||
|
||||
def ensure_time_close_schema(cursor) -> None:
|
||||
ddl_list = (
|
||||
"ALTER TABLE order_monitors ADD COLUMN time_close_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE order_monitors ADD COLUMN time_close_hours INTEGER",
|
||||
"ALTER TABLE order_monitors ADD COLUMN time_close_at_ms INTEGER",
|
||||
"ALTER TABLE key_monitors ADD COLUMN time_close_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE key_monitors ADD COLUMN time_close_hours INTEGER",
|
||||
)
|
||||
for ddl in ddl_list:
|
||||
try:
|
||||
cursor.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def time_close_insert_values(
|
||||
enabled: int,
|
||||
hours: Optional[int],
|
||||
opened_at_ms: Optional[int],
|
||||
) -> tuple[int, Optional[int], Optional[int]]:
|
||||
en = 1 if int(enabled or 0) != 0 and hours else 0
|
||||
h = normalize_time_close_hours(hours) if en else None
|
||||
close_at = compute_close_at_ms(opened_at_ms, h) if en else None
|
||||
return en, h, close_at
|
||||
@@ -0,0 +1,229 @@
|
||||
"""平仓交易:交易所口径双边成交额与手续费(三所共用聚合逻辑)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
def _coerce_ts_ms(raw: Any) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
v = int(raw)
|
||||
return v if v > 1_000_000_000_000 else v * 1000
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def quote_turnover_usdt_from_fill(trade: dict, *, contract_size: float = 1.0) -> float:
|
||||
"""单笔成交的报价币成交额(USDT 口径)."""
|
||||
info = trade.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
for key in ("quoteQty", "quote_qty", "fillNotionalUsd", "notional"):
|
||||
try:
|
||||
v = float(info.get(key) or 0)
|
||||
if v > 0:
|
||||
return abs(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
cost = float(trade.get("cost") or 0)
|
||||
if cost > 0:
|
||||
return abs(cost)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
price = float(trade.get("price") or 0)
|
||||
amount = float(trade.get("amount") or 0) * float(contract_size or 1.0)
|
||||
if price > 0 and amount > 0:
|
||||
return abs(price * amount)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def commission_usdt_from_fill(trade: dict) -> float:
|
||||
"""单笔成交手续费(正数表示成本)."""
|
||||
fee = trade.get("fee")
|
||||
if isinstance(fee, dict):
|
||||
try:
|
||||
cost = float(fee.get("cost") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cost = 0.0
|
||||
if cost != 0:
|
||||
cur = str(fee.get("currency") or "USDT").upper()
|
||||
if cur in ("USDT", "USD", "BUSD", "USDC"):
|
||||
return abs(cost)
|
||||
return abs(cost)
|
||||
info = trade.get("info") or {}
|
||||
if isinstance(info, dict):
|
||||
for key in ("fee", "commission", "fillFee"):
|
||||
try:
|
||||
v = float(info.get(key) or 0)
|
||||
if v != 0:
|
||||
return abs(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
|
||||
def aggregate_bilateral_stats(
|
||||
fills: list[dict],
|
||||
*,
|
||||
contract_size: float = 1.0,
|
||||
) -> dict[str, float] | None:
|
||||
"""双边成交额 = 开+平所有相关 fill 的报价币成交额之和;手续费 = fill fee 之和."""
|
||||
if not fills:
|
||||
return None
|
||||
turnover = 0.0
|
||||
commission = 0.0
|
||||
for t in fills:
|
||||
turnover += quote_turnover_usdt_from_fill(t, contract_size=contract_size)
|
||||
commission += commission_usdt_from_fill(t)
|
||||
if turnover <= 0 and commission <= 0:
|
||||
return None
|
||||
return {
|
||||
"exchange_turnover_usdt": round(turnover, 4),
|
||||
"exchange_commission_usdt": round(commission, 4),
|
||||
}
|
||||
|
||||
|
||||
def filter_position_lifecycle_fills(
|
||||
trades: list[dict],
|
||||
direction: str,
|
||||
open_ms: int | None,
|
||||
close_ms: int | None,
|
||||
*,
|
||||
hedge_mode: bool = False,
|
||||
close_buffer_ms: int = 15 * 60 * 1000,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
持仓生命周期内 fill:多=开买+平卖;空=开卖+平买.
|
||||
hedge_mode 时按 posSide 与 direction 过滤.
|
||||
"""
|
||||
direction = (direction or "long").strip().lower()
|
||||
open_side = "buy" if direction == "long" else "sell"
|
||||
close_side = "sell" if direction == "long" else "buy"
|
||||
allowed_sides = {open_side, close_side}
|
||||
upper = int(close_ms) + int(close_buffer_ms) if close_ms else None
|
||||
out: list[dict] = []
|
||||
for t in trades or []:
|
||||
side = (t.get("side") or "").lower()
|
||||
if side not in allowed_sides:
|
||||
continue
|
||||
ts = _coerce_ts_ms(t.get("timestamp"))
|
||||
if ts is None:
|
||||
continue
|
||||
if open_ms and ts < int(open_ms) - 60_000:
|
||||
continue
|
||||
if upper and ts > upper:
|
||||
continue
|
||||
if hedge_mode:
|
||||
info = t.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
pos_side = (info.get("posSide") or t.get("posSide") or "").lower()
|
||||
if pos_side in ("long", "short") and pos_side != direction:
|
||||
continue
|
||||
out.append(t)
|
||||
out.sort(key=lambda x: x.get("timestamp") or 0)
|
||||
return out
|
||||
|
||||
|
||||
def sum_binance_commission_income(entries: list[dict], trade_ids: set[str] | None) -> float | None:
|
||||
"""Binance income 流水中 COMMISSION 合计(负值取绝对值为成本)."""
|
||||
if not entries:
|
||||
return None
|
||||
total = 0.0
|
||||
found = False
|
||||
for e in entries:
|
||||
it = (e.get("incomeType") or e.get("income_type") or "").strip()
|
||||
if it != "COMMISSION":
|
||||
continue
|
||||
if trade_ids:
|
||||
tid = str(e.get("tradeId") or e.get("trade_id") or "").strip()
|
||||
if tid and tid not in trade_ids:
|
||||
continue
|
||||
try:
|
||||
total += float(e.get("income") or 0)
|
||||
found = True
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not found:
|
||||
return None
|
||||
return round(abs(total), 4)
|
||||
|
||||
|
||||
def trade_ids_from_fills(fills: list[dict]) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for t in fills or []:
|
||||
info = t.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
for key in ("id", "tradeId", "trade_id"):
|
||||
raw = t.get(key) if key in t else info.get(key)
|
||||
if raw is not None and str(raw).strip():
|
||||
out.add(str(raw).strip())
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def merge_commission_prefer_income(
|
||||
fill_commission: float,
|
||||
income_commission: float | None,
|
||||
) -> float:
|
||||
if income_commission is not None and income_commission > 0:
|
||||
return round(income_commission, 4)
|
||||
return round(max(fill_commission, 0.0), 4)
|
||||
|
||||
|
||||
def update_trade_record_stats_columns(
|
||||
conn: Any,
|
||||
trade_id: int,
|
||||
turnover_usdt: float | None,
|
||||
commission_usdt: float | None,
|
||||
) -> None:
|
||||
if turnover_usdt is None and commission_usdt is None:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE trade_records
|
||||
SET exchange_turnover_usdt = COALESCE(?, exchange_turnover_usdt),
|
||||
exchange_commission_usdt = COALESCE(?, exchange_commission_usdt)
|
||||
WHERE id = ?
|
||||
""",
|
||||
(turnover_usdt, commission_usdt, int(trade_id)),
|
||||
)
|
||||
|
||||
|
||||
def attach_exchange_stats_to_trade(
|
||||
conn: Any,
|
||||
trade_id: int,
|
||||
*,
|
||||
fetch_fills: Callable[[], list[dict]],
|
||||
contract_size: float = 1.0,
|
||||
income_commission: float | None = None,
|
||||
) -> dict[str, float] | None:
|
||||
"""拉 fill 并写库;仅在新单平仓路径调用."""
|
||||
try:
|
||||
fills = fetch_fills() or []
|
||||
except Exception:
|
||||
fills = []
|
||||
stats = aggregate_bilateral_stats(fills, contract_size=contract_size)
|
||||
if not stats and income_commission is None:
|
||||
return None
|
||||
turnover = stats.get("exchange_turnover_usdt") if stats else None
|
||||
fill_comm = float(stats.get("exchange_commission_usdt") or 0) if stats else 0.0
|
||||
commission = merge_commission_prefer_income(fill_comm, income_commission)
|
||||
update_trade_record_stats_columns(
|
||||
conn,
|
||||
trade_id,
|
||||
turnover,
|
||||
commission if commission > 0 else None,
|
||||
)
|
||||
out = {}
|
||||
if turnover is not None:
|
||||
out["exchange_turnover_usdt"] = turnover
|
||||
if commission > 0:
|
||||
out["exchange_commission_usdt"] = commission
|
||||
return out or None
|
||||
@@ -0,0 +1,94 @@
|
||||
"""永续估算盈亏:固定 taker 手续费(默认单边 0.05%,开+平双边).
|
||||
|
||||
浮盈亏仍读交易所;本模块只服务「盈利金额 / 止盈盈利 / 推送 / 记账 pnl_amount」等估算口径.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _finite(v) -> Optional[float]:
|
||||
try:
|
||||
f = float(v)
|
||||
return f if math.isfinite(f) else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def taker_fee_rate() -> float:
|
||||
"""单边 taker 费率,默认 0.0005(=0.05%)."""
|
||||
raw = os.getenv("PERP_TAKER_FEE_RATE", "0.0005")
|
||||
rate = _finite(raw)
|
||||
if rate is None or rate < 0:
|
||||
return 0.0005
|
||||
return rate
|
||||
|
||||
|
||||
def notional_usdt(price, qty, contract_size: float = 1.0) -> Optional[float]:
|
||||
"""名义价值 U = 价格 × 张数 × 合约面值."""
|
||||
p = _finite(price)
|
||||
q = _finite(qty)
|
||||
cs = _finite(contract_size)
|
||||
if p is None or q is None or p <= 0 or q <= 0:
|
||||
return None
|
||||
if cs is None or cs <= 0:
|
||||
cs = 1.0
|
||||
return abs(q) * p * cs
|
||||
|
||||
|
||||
def estimate_roundtrip_fee_usdt(
|
||||
entry_price,
|
||||
exit_price,
|
||||
qty=None,
|
||||
contract_size: float = 1.0,
|
||||
*,
|
||||
open_notional: float | None = None,
|
||||
rate: float | None = None,
|
||||
) -> float:
|
||||
"""开+平双边手续费(各单边 rate).
|
||||
|
||||
优先用 价×张×面值;若无张数则用 open_notional 估开仓名义,
|
||||
平仓名义按 exit/entry 缩放.
|
||||
"""
|
||||
fee_rate = taker_fee_rate() if rate is None else float(rate)
|
||||
if fee_rate <= 0:
|
||||
return 0.0
|
||||
entry = _finite(entry_price)
|
||||
exit_p = _finite(exit_price)
|
||||
open_n = notional_usdt(entry, qty, contract_size) if qty is not None else None
|
||||
if open_n is None:
|
||||
open_n = _finite(open_notional)
|
||||
if open_n is None or open_n <= 0:
|
||||
return 0.0
|
||||
if entry is not None and entry > 0 and exit_p is not None and exit_p > 0:
|
||||
close_n = open_n * (exit_p / entry)
|
||||
else:
|
||||
close_n = open_n
|
||||
return round(open_n * fee_rate + close_n * fee_rate, 8)
|
||||
|
||||
|
||||
def net_pnl_after_fee(
|
||||
gross_pnl,
|
||||
entry_price,
|
||||
exit_price,
|
||||
qty=None,
|
||||
contract_size: float = 1.0,
|
||||
*,
|
||||
open_notional: float | None = None,
|
||||
rate: float | None = None,
|
||||
) -> Optional[float]:
|
||||
"""毛利扣双边手续费后的净盈亏;gross 无效则返回 None."""
|
||||
gross = _finite(gross_pnl)
|
||||
if gross is None:
|
||||
return None
|
||||
fee = estimate_roundtrip_fee_usdt(
|
||||
entry_price,
|
||||
exit_price,
|
||||
qty,
|
||||
contract_size,
|
||||
open_notional=open_notional,
|
||||
rate=rate,
|
||||
)
|
||||
return round(gross - fee, 4)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""交易记录 / 下单监控标签工具(原 lib.strategy.strategy_trade_labels 瘦身后)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
ORDER_TYPE_MANUAL = "下单监控"
|
||||
ORDER_TYPE_KEY = "关键位监控"
|
||||
|
||||
# 历史数据兼容可读;新复盘下拉不再提供策略开仓类型
|
||||
MONITOR_TYPE_TREND_PULLBACK = "趋势回调"
|
||||
MONITOR_TYPE_ROLL = "顺势加仓"
|
||||
ENTRY_REASON_TREND_PULLBACK = "趋势回调"
|
||||
ENTRY_REASON_ROLL = "顺势加仓"
|
||||
|
||||
JOURNAL_ORDER_TYPE_OPTIONS = (
|
||||
ORDER_TYPE_MANUAL,
|
||||
ORDER_TYPE_KEY,
|
||||
)
|
||||
|
||||
# 旧库兼容(normalize / 读历史);不进 JOURNAL 下拉
|
||||
STRATEGY_ENTRY_REASON_OPTIONS = (
|
||||
ENTRY_REASON_TREND_PULLBACK,
|
||||
ENTRY_REASON_ROLL,
|
||||
)
|
||||
|
||||
TREND_HANDOFF_KEY_SIGNAL = ENTRY_REASON_TREND_PULLBACK
|
||||
TREND_HANDOFF_TRADE_NOTE = "趋势回调计划"
|
||||
|
||||
|
||||
def normalize_journal_order_type(raw: Optional[str]) -> str:
|
||||
s = (raw or "").strip()
|
||||
if s in JOURNAL_ORDER_TYPE_OPTIONS:
|
||||
return s
|
||||
# 历史策略类型仍可读,映射到相近展示
|
||||
if s in (MONITOR_TYPE_TREND_PULLBACK, MONITOR_TYPE_ROLL):
|
||||
return s
|
||||
if "关键位" in s:
|
||||
return ORDER_TYPE_KEY
|
||||
return ""
|
||||
|
||||
|
||||
def order_type_from_monitor_type(
|
||||
monitor_type: Optional[str],
|
||||
key_signal_type: Optional[str] = None,
|
||||
) -> str:
|
||||
del key_signal_type
|
||||
mt = (monitor_type or "").strip()
|
||||
if mt == MONITOR_TYPE_TREND_PULLBACK:
|
||||
return MONITOR_TYPE_TREND_PULLBACK
|
||||
if mt == MONITOR_TYPE_ROLL:
|
||||
return MONITOR_TYPE_ROLL
|
||||
if mt == ORDER_TYPE_KEY or "关键位" in mt:
|
||||
return ORDER_TYPE_KEY
|
||||
return ORDER_TYPE_MANUAL
|
||||
|
||||
|
||||
def handoff_trade_miss_reason(miss_reason, row) -> Optional[str]:
|
||||
"""历史趋势保本移交监控单平仓备注兼容."""
|
||||
if trend_plan_id_from_monitor_row(row) is None:
|
||||
return miss_reason
|
||||
base = (miss_reason or "").strip()
|
||||
if TREND_HANDOFF_TRADE_NOTE in base:
|
||||
return base or TREND_HANDOFF_TRADE_NOTE
|
||||
if base:
|
||||
return f"{TREND_HANDOFF_TRADE_NOTE};{base}"
|
||||
return TREND_HANDOFF_TRADE_NOTE
|
||||
|
||||
|
||||
def trend_plan_id_from_monitor_row(row) -> Optional[int]:
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else []
|
||||
except Exception:
|
||||
keys = []
|
||||
if "trend_plan_id" not in keys or row["trend_plan_id"] in (None, ""):
|
||||
return None
|
||||
try:
|
||||
tid = int(row["trend_plan_id"])
|
||||
return tid if tid > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def order_had_roll_fills(conn, order_monitor_id) -> bool:
|
||||
"""策略已移除:始终 False(roll_legs 表可能仍在旧库)."""
|
||||
del conn, order_monitor_id
|
||||
return False
|
||||
|
||||
|
||||
def _row_monitor_type(row, default_manual: str) -> str:
|
||||
if row is None:
|
||||
return default_manual
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else []
|
||||
except Exception:
|
||||
keys = []
|
||||
if "monitor_type" in keys:
|
||||
mt = (row["monitor_type"] or "").strip()
|
||||
if mt:
|
||||
return mt
|
||||
return default_manual
|
||||
|
||||
|
||||
def _row_key_signal_type(row) -> str:
|
||||
if row is None:
|
||||
return ""
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else []
|
||||
except Exception:
|
||||
keys = []
|
||||
if "key_signal_type" not in keys:
|
||||
return ""
|
||||
return (row["key_signal_type"] or "").strip()
|
||||
|
||||
|
||||
def order_monitor_source_type(row, *, default_manual: str = "下单监控") -> str:
|
||||
"""展示/平仓记录:历史趋势移交单仍标「趋势回调」."""
|
||||
if trend_plan_id_from_monitor_row(row) is not None:
|
||||
return MONITOR_TYPE_TREND_PULLBACK
|
||||
mt = _row_monitor_type(row, default_manual)
|
||||
if mt != default_manual:
|
||||
return mt
|
||||
kst = _row_key_signal_type(row)
|
||||
if kst in (
|
||||
MONITOR_TYPE_TREND_PULLBACK,
|
||||
TREND_HANDOFF_KEY_SIGNAL,
|
||||
TREND_HANDOFF_TRADE_NOTE,
|
||||
ENTRY_REASON_TREND_PULLBACK,
|
||||
):
|
||||
return MONITOR_TYPE_TREND_PULLBACK
|
||||
return mt
|
||||
|
||||
|
||||
def apply_order_monitor_source_labels(item: dict, *, default_manual: str = "下单监控") -> dict:
|
||||
out = dict(item or {})
|
||||
out["monitor_type"] = order_monitor_source_type(out, default_manual=default_manual)
|
||||
return out
|
||||
|
||||
|
||||
def trade_record_monitor_type(conn, order_row, *, default_manual: str = "下单监控") -> str:
|
||||
del conn
|
||||
return order_monitor_source_type(order_row, default_manual=default_manual)
|
||||
|
||||
|
||||
def entry_reason_for_monitor_type(monitor_type: str | None) -> str:
|
||||
mt = (monitor_type or "").strip()
|
||||
if mt == MONITOR_TYPE_TREND_PULLBACK:
|
||||
return ENTRY_REASON_TREND_PULLBACK
|
||||
if mt == MONITOR_TYPE_ROLL:
|
||||
return ENTRY_REASON_ROLL
|
||||
return ""
|
||||
|
||||
|
||||
def order_monitor_excluded_from_position_limit(conn, row) -> bool:
|
||||
del conn
|
||||
return order_monitor_source_type(row) == MONITOR_TYPE_TREND_PULLBACK
|
||||
|
||||
|
||||
def count_position_limit_active_monitors(conn) -> int:
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||||
except Exception:
|
||||
return 0
|
||||
n = 0
|
||||
for row in rows:
|
||||
if not order_monitor_excluded_from_position_limit(conn, row):
|
||||
n += 1
|
||||
return n
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Flask 实例接入 trade policy(三所 app.py 共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Tuple
|
||||
|
||||
from lib.trade.trade_policy_lib import (
|
||||
TradePolicy,
|
||||
assert_direction_allowed,
|
||||
assert_symbol_allowed,
|
||||
assert_trade_policy_open,
|
||||
trade_policy_to_dict,
|
||||
)
|
||||
|
||||
|
||||
def trade_policy_template_context(policy: TradePolicy) -> dict:
|
||||
return trade_policy_to_dict(policy)
|
||||
|
||||
|
||||
def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str:
|
||||
d = (raw_default or "").strip()
|
||||
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
|
||||
# 白名单仅一币时直接用 env 币种,表单下拉同步默认选中
|
||||
if len(policy.symbol_whitelist) == 1:
|
||||
return f"{policy.symbol_whitelist[0]}/USDT"
|
||||
from lib.trade.trade_policy_lib import symbol_base_coin
|
||||
|
||||
base = symbol_base_coin(d or "BTC/USDT")
|
||||
if base not in policy.symbol_whitelist:
|
||||
return f"{policy.symbol_whitelist[0]}/USDT"
|
||||
if d:
|
||||
return d if "/" in d else f"{base}/USDT"
|
||||
return f"{policy.symbol_whitelist[0]}/USDT"
|
||||
return d or "BTC/USDT"
|
||||
|
||||
def check_symbol_policy(
|
||||
policy: TradePolicy,
|
||||
symbol: str,
|
||||
normalize_symbol_fn: Callable[[str], str],
|
||||
) -> Tuple[bool, str]:
|
||||
return assert_symbol_allowed(
|
||||
policy, symbol, normalize_symbol_fn=normalize_symbol_fn
|
||||
)
|
||||
|
||||
|
||||
def check_direction_policy(policy: TradePolicy, direction: str) -> Tuple[bool, str]:
|
||||
return assert_direction_allowed(policy, direction)
|
||||
|
||||
|
||||
def check_open_policy(
|
||||
policy: TradePolicy,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
normalize_symbol_fn: Callable[[str], str],
|
||||
) -> Tuple[bool, str]:
|
||||
return assert_trade_policy_open(
|
||||
policy, symbol, direction, normalize_symbol_fn=normalize_symbol_fn
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
三所共用:账户级方向 / 币种白名单(.env 开关,默认关闭=不限制).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, FrozenSet, Optional, Sequence, Tuple
|
||||
|
||||
DIR_BOTH = "both"
|
||||
DIR_LONG_ONLY = "long_only"
|
||||
DIR_SHORT_ONLY = "short_only"
|
||||
VALID_DIRECTION_MODES = frozenset({DIR_BOTH, DIR_LONG_ONLY, DIR_SHORT_ONLY})
|
||||
|
||||
_DIR_ALIASES = {
|
||||
"both": DIR_BOTH,
|
||||
"双向": DIR_BOTH,
|
||||
"long": DIR_LONG_ONLY,
|
||||
"long_only": DIR_LONG_ONLY,
|
||||
"多": DIR_LONG_ONLY,
|
||||
"仅多": DIR_LONG_ONLY,
|
||||
"做多": DIR_LONG_ONLY,
|
||||
"short": DIR_SHORT_ONLY,
|
||||
"short_only": DIR_SHORT_ONLY,
|
||||
"空": DIR_SHORT_ONLY,
|
||||
"仅空": DIR_SHORT_ONLY,
|
||||
"做空": DIR_SHORT_ONLY,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(raw: Optional[str], default: bool = False) -> bool:
|
||||
if raw is None:
|
||||
return default
|
||||
return (raw or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def normalize_direction_mode(raw: Optional[str]) -> str:
|
||||
v = (raw or DIR_BOTH).strip().lower()
|
||||
return _DIR_ALIASES.get(v, v if v in VALID_DIRECTION_MODES else DIR_BOTH)
|
||||
|
||||
|
||||
def symbol_base_coin(symbol: str) -> str:
|
||||
"""BTC/USDT:USDT,BTC/USDT,BTC,btc -> BTC"""
|
||||
s = (symbol or "").strip().upper()
|
||||
if not s:
|
||||
return ""
|
||||
if ":" in s:
|
||||
s = s.split(":", 1)[0]
|
||||
if "/" in s:
|
||||
return s.split("/", 1)[0].strip()
|
||||
if s.endswith("USDT") and len(s) > 4:
|
||||
return s[:-4]
|
||||
return s
|
||||
|
||||
|
||||
def parse_symbol_whitelist(raw: Optional[str]) -> Tuple[str, ...]:
|
||||
if not raw or not str(raw).strip():
|
||||
return ()
|
||||
parts = []
|
||||
for piece in str(raw).replace(";", ",").split(","):
|
||||
base = symbol_base_coin(piece.strip())
|
||||
if base and base not in parts:
|
||||
parts.append(base)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TradePolicy:
|
||||
direction_restrict_enabled: bool
|
||||
direction_mode: str
|
||||
symbol_restrict_enabled: bool
|
||||
symbol_whitelist: Tuple[str, ...]
|
||||
|
||||
@property
|
||||
def allows_long(self) -> bool:
|
||||
if not self.direction_restrict_enabled:
|
||||
return True
|
||||
return self.direction_mode in (DIR_BOTH, DIR_LONG_ONLY)
|
||||
|
||||
@property
|
||||
def allows_short(self) -> bool:
|
||||
if not self.direction_restrict_enabled:
|
||||
return True
|
||||
return self.direction_mode in (DIR_BOTH, DIR_SHORT_ONLY)
|
||||
|
||||
|
||||
def load_trade_policy(env: Optional[dict] = None) -> TradePolicy:
|
||||
e = env if env is not None else os.environ
|
||||
direction_restrict = _env_bool(e.get("TRADE_DIRECTION_RESTRICT_ENABLED"), False)
|
||||
symbol_restrict = _env_bool(e.get("TRADE_SYMBOL_RESTRICT_ENABLED"), False)
|
||||
direction_mode = normalize_direction_mode(e.get("TRADE_DIRECTION"))
|
||||
whitelist = parse_symbol_whitelist(e.get("TRADE_SYMBOL_WHITELIST"))
|
||||
if symbol_restrict and not whitelist:
|
||||
symbol_restrict = False
|
||||
return TradePolicy(
|
||||
direction_restrict_enabled=direction_restrict,
|
||||
direction_mode=direction_mode,
|
||||
symbol_restrict_enabled=symbol_restrict,
|
||||
symbol_whitelist=whitelist,
|
||||
)
|
||||
|
||||
|
||||
def direction_mode_label_zh(mode: str) -> str:
|
||||
m = normalize_direction_mode(mode)
|
||||
if m == DIR_LONG_ONLY:
|
||||
return "仅多"
|
||||
if m == DIR_SHORT_ONLY:
|
||||
return "仅空"
|
||||
return "双向"
|
||||
|
||||
|
||||
def trade_policy_badge_parts(policy: TradePolicy) -> Tuple[str, ...]:
|
||||
parts: list[str] = []
|
||||
if policy.direction_restrict_enabled:
|
||||
if policy.direction_mode == DIR_LONG_ONLY:
|
||||
parts.append("仅多")
|
||||
elif policy.direction_mode == DIR_SHORT_ONLY:
|
||||
parts.append("仅空")
|
||||
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
|
||||
parts.append("/".join(policy.symbol_whitelist))
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def trade_policy_to_dict(policy: TradePolicy) -> dict:
|
||||
badges = trade_policy_badge_parts(policy)
|
||||
return {
|
||||
"direction_restrict_enabled": policy.direction_restrict_enabled,
|
||||
"direction_mode": policy.direction_mode,
|
||||
"direction_label_zh": (
|
||||
direction_mode_label_zh(policy.direction_mode)
|
||||
if policy.direction_restrict_enabled
|
||||
else "双向"
|
||||
),
|
||||
"allows_long": policy.allows_long,
|
||||
"allows_short": policy.allows_short,
|
||||
"symbol_restrict_enabled": policy.symbol_restrict_enabled,
|
||||
"symbol_whitelist": list(policy.symbol_whitelist),
|
||||
"badge_parts": list(badges),
|
||||
"badge_text": " · ".join(badges),
|
||||
}
|
||||
|
||||
|
||||
def normalize_open_direction(policy: TradePolicy, direction: str) -> str:
|
||||
d = (direction or "long").strip().lower()
|
||||
if d not in ("long", "short"):
|
||||
d = "long"
|
||||
if policy.direction_restrict_enabled:
|
||||
if policy.direction_mode == DIR_LONG_ONLY:
|
||||
return "long"
|
||||
if policy.direction_mode == DIR_SHORT_ONLY:
|
||||
return "short"
|
||||
return d
|
||||
|
||||
|
||||
def assert_direction_allowed(policy: TradePolicy, direction: str) -> Tuple[bool, str]:
|
||||
d = (direction or "").strip().lower()
|
||||
if d not in ("long", "short"):
|
||||
if d in ("watch", ""):
|
||||
return True, ""
|
||||
return False, "方向无效,请选择做多或做空"
|
||||
if d == "long" and not policy.allows_long:
|
||||
return False, "当前账户配置为仅做空,不允许做多"
|
||||
if d == "short" and not policy.allows_short:
|
||||
return False, "当前账户配置为仅做多,不允许做空"
|
||||
return True, ""
|
||||
|
||||
|
||||
def assert_symbol_allowed(
|
||||
policy: TradePolicy,
|
||||
symbol: str,
|
||||
*,
|
||||
normalize_symbol_fn: Optional[Callable[[str], str]] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
if not policy.symbol_restrict_enabled:
|
||||
return True, ""
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
return False, "请选择币种"
|
||||
if normalize_symbol_fn is not None:
|
||||
sym_norm = (normalize_symbol_fn(sym) or "").strip()
|
||||
else:
|
||||
sym_norm = sym
|
||||
base = symbol_base_coin(sym_norm or sym)
|
||||
allowed: FrozenSet[str] = frozenset(policy.symbol_whitelist)
|
||||
if base not in allowed:
|
||||
allowed_txt = ",".join(policy.symbol_whitelist)
|
||||
return False, f"当前账户仅允许 {allowed_txt},不允许 {base or sym}"
|
||||
return True, ""
|
||||
|
||||
|
||||
def assert_trade_policy_open(
|
||||
policy: TradePolicy,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
normalize_symbol_fn: Optional[Callable[[str], str]] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
ok_sym, msg_sym = assert_symbol_allowed(
|
||||
policy, symbol, normalize_symbol_fn=normalize_symbol_fn
|
||||
)
|
||||
if not ok_sym:
|
||||
return False, msg_sym
|
||||
ok_dir, msg_dir = assert_direction_allowed(policy, direction)
|
||||
if not ok_dir:
|
||||
return False, msg_dir
|
||||
return True, ""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""交易结果展示与入库时的语义归一化."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
_WIN_EPS = 1e-9
|
||||
|
||||
|
||||
def classify_exit_by_levels(
|
||||
direction,
|
||||
trigger_price,
|
||||
stop_loss,
|
||||
take_profit,
|
||||
exit_price,
|
||||
) -> Optional[str]:
|
||||
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None.
|
||||
|
||||
交易所条件止盈常按标记价触发、市价成交,成交价可能偏离计划止盈数个 tick.
|
||||
因此先用窄带,失败后再用宽带;仍失败则看是否落在入场→止盈/止损的「盈利/亏损侧」。
|
||||
"""
|
||||
try:
|
||||
tp = float(take_profit)
|
||||
sl = float(stop_loss)
|
||||
ex = float(exit_price)
|
||||
trig = float(trigger_price)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
d = (direction or "").strip().lower()
|
||||
if d not in ("long", "short"):
|
||||
return None
|
||||
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
|
||||
# 宽带:覆盖 BTC 等高价币种条件单滑点(实测 Gate 止盈成交可偏出窄带 ~100U)
|
||||
band_loose = max(abs(trig) * 0.003, abs(tp - sl) * 0.05, band * 4.0, 1e-12)
|
||||
|
||||
def _is_tp(b: float) -> bool:
|
||||
return ex >= tp - b if d == "long" else ex <= tp + b
|
||||
|
||||
def _is_sl(b: float) -> bool:
|
||||
return ex <= sl + b if d == "long" else ex >= sl - b
|
||||
|
||||
if _is_tp(band):
|
||||
return "止盈"
|
||||
if _is_sl(band):
|
||||
return "止损"
|
||||
if _is_tp(band_loose):
|
||||
return "止盈"
|
||||
if _is_sl(band_loose):
|
||||
return "止损"
|
||||
|
||||
# 盈利侧且更靠近止盈 → 止盈; 亏损侧且更靠近止损 → 止损
|
||||
if d == "long":
|
||||
if ex > trig and abs(ex - tp) <= abs(ex - trig):
|
||||
return "止盈"
|
||||
if ex < trig and abs(ex - sl) <= abs(ex - trig):
|
||||
return "止损"
|
||||
else:
|
||||
if ex < trig and abs(ex - tp) <= abs(ex - trig):
|
||||
return "止盈"
|
||||
if ex > trig and abs(ex - sl) <= abs(ex - trig):
|
||||
return "止损"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_display_result(result):
|
||||
"""展示用:外部平仓一律视为手动平仓."""
|
||||
res = (result or "").strip()
|
||||
if res == "外部平仓" or res.startswith("外部平仓"):
|
||||
return "手动平仓"
|
||||
return res
|
||||
|
||||
|
||||
def is_winning_pnl(pnl_amount) -> bool:
|
||||
"""胜率统计:盈亏为正即计为盈利单."""
|
||||
try:
|
||||
return float(pnl_amount or 0) > _WIN_EPS
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def sql_effective_pnl_expr() -> str:
|
||||
"""与 to_effective_trade_dict / hub_trades_lib 一致的盈亏 SQL 表达式."""
|
||||
return "COALESCE(reviewed_pnl_amount, exchange_realized_pnl, pnl_amount, 0)"
|
||||
|
||||
|
||||
def count_winning_trades(trades) -> int:
|
||||
return sum(1 for r in trades or [] if is_winning_pnl(r.get("effective_pnl_amount")))
|
||||
|
||||
|
||||
MISS_TRADE_RESULT = "错过"
|
||||
|
||||
|
||||
def is_miss_trade_result(result) -> bool:
|
||||
return (result or "").strip() == MISS_TRADE_RESULT
|
||||
|
||||
|
||||
def filter_trade_records_excluding_miss(records):
|
||||
"""列表/统计:不展示,不计入「错过」类交易记录."""
|
||||
return [
|
||||
r
|
||||
for r in (records or [])
|
||||
if not is_miss_trade_result(r.get("effective_result") or r.get("result"))
|
||||
]
|
||||
|
||||
|
||||
def normalize_result_with_pnl(result, pnl_amount):
|
||||
"""
|
||||
非手动平仓且实际盈利时,不应记为「止损」.
|
||||
程序触发的止损类平仓若盈亏为正,归类为「移动止盈」.
|
||||
"""
|
||||
res = normalize_display_result(result)
|
||||
if res == "手动平仓":
|
||||
return res
|
||||
if res == "止损":
|
||||
try:
|
||||
if float(pnl_amount or 0) > 0:
|
||||
return "移动止盈"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return res
|
||||
@@ -0,0 +1,115 @@
|
||||
"""按交易日聚合实例 trade_records 盈亏,供统计分析页日历 API 使用."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def build_trade_stats_calendar(
|
||||
pnls: list[tuple],
|
||||
year: int,
|
||||
month: int,
|
||||
segment_key: str,
|
||||
row_matches_fn: Callable[[Any, str], bool],
|
||||
*,
|
||||
reset_hour: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""pnls: _load_completed_trade_pnls 返回值 (pnl, close_dt, trading_day, row)."""
|
||||
y = int(year)
|
||||
m = int(month)
|
||||
if m < 1 or m > 12:
|
||||
raise ValueError("month 无效")
|
||||
first = f"{y:04d}-{m:02d}-01"
|
||||
if m == 12:
|
||||
next_first = datetime(y + 1, 1, 1)
|
||||
else:
|
||||
next_first = datetime(y, m + 1, 1)
|
||||
last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
seg = (segment_key or "all").strip() or "all"
|
||||
days: dict[str, dict[str, Any]] = {}
|
||||
for pnl, _close_dt, td, row in pnls:
|
||||
if not td or td < first or td > last:
|
||||
continue
|
||||
if not row_matches_fn(row, seg):
|
||||
continue
|
||||
bucket = days.setdefault(
|
||||
td,
|
||||
{
|
||||
"trading_day": td,
|
||||
"open_count": 0,
|
||||
"pnl_total": 0.0,
|
||||
"turnover_total": 0.0,
|
||||
"commission_total": 0.0,
|
||||
"has_sick": False,
|
||||
"sick_count": 0,
|
||||
},
|
||||
)
|
||||
bucket["open_count"] += 1
|
||||
bucket["pnl_total"] += float(pnl or 0)
|
||||
try:
|
||||
bucket["turnover_total"] += float(row["exchange_turnover_usdt"] or 0)
|
||||
except (TypeError, ValueError, KeyError):
|
||||
pass
|
||||
try:
|
||||
bucket["commission_total"] += float(row["exchange_commission_usdt"] or 0)
|
||||
except (TypeError, ValueError, KeyError):
|
||||
pass
|
||||
for d in days.values():
|
||||
d["pnl_total"] = round(float(d["pnl_total"]), 4)
|
||||
d["turnover_total"] = round(float(d["turnover_total"]), 4)
|
||||
d["commission_total"] = round(float(d["commission_total"]), 4)
|
||||
month_pnl = sum(float(d["pnl_total"]) for d in days.values())
|
||||
month_count = sum(int(d["open_count"]) for d in days.values())
|
||||
return {
|
||||
"year": y,
|
||||
"month": m,
|
||||
"date_from": first,
|
||||
"date_to": last,
|
||||
"segment": seg,
|
||||
"reset_hour": int(reset_hour),
|
||||
"days": days,
|
||||
"month_pnl_total": round(month_pnl, 4),
|
||||
"month_open_count": month_count,
|
||||
}
|
||||
|
||||
|
||||
def build_initial_stats_calendar(
|
||||
pnls: list[tuple],
|
||||
now_dt: datetime,
|
||||
row_matches_fn: Callable[[Any, str], bool],
|
||||
*,
|
||||
reset_hour: int = 8,
|
||||
segment_key: str = "all",
|
||||
) -> dict[str, Any]:
|
||||
"""统计页首屏内嵌日历(当前自然月,默认品类)."""
|
||||
return build_trade_stats_calendar(
|
||||
pnls,
|
||||
now_dt.year,
|
||||
now_dt.month,
|
||||
segment_key,
|
||||
row_matches_fn,
|
||||
reset_hour=reset_hour,
|
||||
)
|
||||
|
||||
|
||||
def build_stats_calendar_bootstrap(
|
||||
pnls: list[tuple],
|
||||
now_dt: datetime,
|
||||
row_matches_fn: Callable[[Any, str], bool],
|
||||
*,
|
||||
reset_hour: int = 8,
|
||||
segment_key: str = "all",
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""返回 (payload, json_str);失败时 (None, None),供模板安全内嵌."""
|
||||
try:
|
||||
payload = build_initial_stats_calendar(
|
||||
pnls,
|
||||
now_dt,
|
||||
row_matches_fn,
|
||||
reset_hour=reset_hour,
|
||||
segment_key=segment_key,
|
||||
)
|
||||
return payload, json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
except Exception:
|
||||
return None, None
|
||||
Reference in New Issue
Block a user