refactor: 将共用代码迁入 lib/ 模块化目录

统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:23:09 +08:00
parent 4742a0bb9d
commit 5797d49d8a
190 changed files with 27946 additions and 27499 deletions
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+845
View File
@@ -0,0 +1,845 @@
"""账户冷静期 / 日冻结风控(四所实例共用)。"""
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 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.strategy.strategy_trade_labels 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,
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
)"""
)
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_frozen) VALUES (1, '', 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_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_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_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)
status = STATUS_NORMAL
reason = ""
if daily_frozen:
status = STATUS_DAILY
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
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_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)
+140
View File
@@ -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)}"
+136
View File
@@ -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,
)
+241
View File
@@ -0,0 +1,241 @@
"""实时持仓展示:开仓快照盈亏比、交易所止损是否已保本。"""
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 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 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,
) -> dict[str, Any]:
disp_sl, disp_tp, _, _ = resolve_live_tpsl_prices(stop_loss, take_profit, exchange_tpsl)
payload["rr_ratio"] = snapshot_rr(
calc_rr_ratio_fn,
direction,
entry_price,
initial_stop_loss,
stop_loss,
take_profit,
)
payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl(
direction, entry_price, 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 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 ""
)
return payload
+136
View File
@@ -0,0 +1,136 @@
"""
四所共用:计仓模式 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"
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(
{OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_FIB, OPEN_SOURCE_TREND, OPEN_SOURCE_ROLL}
)
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
+150
View File
@@ -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
+229
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
"""交易结果展示与入库时的语义归一化。"""
_WIN_EPS = 1e-9
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")))
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
+115
View File
@@ -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