Normalize fullwidth punctuation to ASCII across codebase.

Add scripts/normalize_ambiguous_unicode.py; fix corrupted patch_instance_theme_templates.py. Preserves curly quotes in string literals; removes Git homoglyph warnings on .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-08 23:42:26 +08:00
parent aaa72c7961
commit b733e551a0
392 changed files with 71522 additions and 71369 deletions
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -1,16 +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)
"""开仓后挂 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)
+140 -140
View File
@@ -1,140 +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)}"
"""单日开仓次数:软提醒阈值 + 硬上限(三所实例共用)."""
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)})"
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -1,4 +1,4 @@
"""整点强制清仓FORCE_CLOSE_*):UI 标识与持仓倒计时"""
"""整点强制清仓(FORCE_CLOSE_*):UI 标识与持仓倒计时."""
from __future__ import annotations
import os
@@ -45,7 +45,7 @@ def is_force_close_active_hour(
tz_name: Optional[str] = None,
grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES,
) -> bool:
"""当前是否处于整点强制清仓执行窗口整点起 grace 分钟内)。"""
"""当前是否处于整点强制清仓执行窗口(整点起 grace 分钟内)."""
return is_force_close_executing(
bj_hour,
now_ms=now_ms,
@@ -96,7 +96,7 @@ def is_close_at_force_close_window(
grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES,
tz_name: Optional[str] = None,
) -> bool:
"""平仓时刻是否落在北京时间整点强制清仓窗口内"""
"""平仓时刻是否落在北京时间整点强制清仓窗口内."""
dt = parse_closed_at_dt(closed_at, tz_name=tz_name)
if dt is None:
return False
@@ -133,7 +133,7 @@ def coerce_force_close_result(
grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES,
tz_name: Optional[str] = None,
) -> tuple[str, str]:
"""同步平仓归类整点窗口内优先记为强制清仓"""
"""同步平仓归类:整点窗口内优先记为强制清仓."""
res = (result or "").strip()
note = (miss_reason or "").strip()
if res == FORCE_CLOSE_RESULT:
@@ -161,7 +161,7 @@ def apply_force_close_display_result(
grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES,
tz_name: Optional[str] = None,
) -> str:
"""展示层外部平仓/手动平仓若落在整点窗口显示为强制清仓"""
"""展示层:外部平仓/手动平仓若落在整点窗口,显示为强制清仓."""
res = (result or "").strip()
if res == FORCE_CLOSE_RESULT:
return res
@@ -183,7 +183,7 @@ def compute_next_force_close_at_ms(
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
) -> Optional[int]:
"""下一次强制清仓时刻北京时间整点的 epoch 毫秒"""
"""下一次强制清仓时刻(北京时间整点)的 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)
@@ -226,7 +226,7 @@ def build_force_close_state(
tz_name: Optional[str] = None,
has_active_positions: Optional[bool] = None,
) -> dict[str, Any]:
"""实例级强制清仓状态模板 / API 共用)。"""
"""实例级强制清仓状态(模板 / API 共用)."""
if not enabled:
return {
"enabled": False,
@@ -282,7 +282,7 @@ def apply_force_close_to_payload(
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
) -> None:
"""为 active 持仓 JSON 附加整点强制清仓倒计时"""
"""为 active 持仓 JSON 附加整点强制清仓倒计时."""
state = build_force_close_state(
enabled,
bj_hour,
+6 -6
View File
@@ -1,4 +1,4 @@
"""实盘人工下单止盈止损模式价格 / 百分比 / 固定盈亏比)。"""
"""实盘人工下单:止盈止损模式(价格 / 百分比 / 固定盈亏比)."""
from __future__ import annotations
from typing import Any, Optional, Tuple
@@ -52,11 +52,11 @@ def calc_tp_from_fixed_rr(
if side == "short":
risk = sl - entry
if risk <= 0:
raise ValueError("止损方向不合法做空时止损须高于入场价")
raise ValueError("止损方向不合法:做空时止损须高于入场价")
return entry - risk * rr
risk = entry - sl
if risk <= 0:
raise ValueError("止损方向不合法做多时止损须低于入场价")
raise ValueError("止损方向不合法:做多时止损须低于入场价")
return entry + risk * rr
@@ -93,7 +93,7 @@ def _resolve_price_sltp(
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 "请填写止盈价格或保留原计划止盈")
raise ValueError("止盈止损价格须大于 0" if fallback_tp is None else "请填写止盈价格,或保留原计划止盈")
return stop_loss, take_profit
@@ -103,7 +103,7 @@ def resolve_open_sltp_prices(
sltp_mode: Optional[str],
data: dict[str, Any],
) -> Tuple[float, float]:
"""新开仓 /add_order支持 pricepctfixed_rr"""
"""新开仓 /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)
@@ -124,7 +124,7 @@ def resolve_entrust_sltp_prices(
fallback_sl: Optional[float] = None,
fallback_tp: Optional[float] = None,
) -> Tuple[float, float]:
"""持仓委托弹窗仅 price / pct不校验盈亏比"""
"""持仓委托弹窗:仅 price / pct,不校验盈亏比."""
mode = normalize_entrust_sltp_mode(sltp_mode)
if mode == SLTP_MODE_PCT:
return _resolve_pct_sltp(direction, live_price, data)
+15 -15
View File
@@ -1,4 +1,4 @@
"""实时持仓展示开仓快照盈亏比交易所止损是否已保本"""
"""实时持仓展示:开仓快照盈亏比,交易所止损是否已保本."""
from __future__ import annotations
from typing import Any, Callable, Optional
@@ -13,7 +13,7 @@ def _positive_float(value: Any) -> Optional[float]:
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
@@ -21,7 +21,7 @@ def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float
def monitor_open_stop_loss(row: Any) -> Optional[float]:
"""从 order_monitors 行取开仓止损快照"""
"""从 order_monitors 行取开仓止损快照."""
try:
keys = row.keys() if hasattr(row, "keys") else ()
except Exception:
@@ -62,8 +62,8 @@ def tpsl_slot_trigger_price(slot: Any) -> Optional[float]:
def stop_is_profit_protecting(direction: str, entry_price: Any, stop_loss: Any) -> bool:
"""
止损是否已在盈利侧保本/锁盈不再适用开仓盈亏比风控
做空止损 < 成交价做多止损 > 成交价
止损是否已在盈利侧(保本/锁盈),不再适用开仓盈亏比风控.
做空:止损 < 成交价;做多:止损 > 成交价.
"""
entry = _positive_float(entry_price)
sl = _positive_float(stop_loss)
@@ -83,20 +83,20 @@ def tpsl_update_passes_rr_gate(
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盈利侧保本止损不受此限"
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)
@@ -140,7 +140,7 @@ def apply_order_live_price_display(
exchange_mark_price: Any,
format_price_fn: Callable[[Any, Any], str],
) -> dict[str, Any]:
"""标记价/现价展示与交易所 price_to_precision 对齐避免前端 toFixed(8)"""
"""标记价/现价展示:与交易所 price_to_precision 对齐,避免前端 toFixed(8)."""
px_for_fmt = ticker_price
mark_raw = exchange_mark_price
if mark_raw is not None:
@@ -165,7 +165,7 @@ def resolve_live_tpsl_prices(
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"))
@@ -176,7 +176,7 @@ def resolve_live_tpsl_prices(
def calc_risk_fraction(direction: str, entry_price: Any, stop_loss: Any) -> Optional[float]:
"""|入场-止损|/入场盈利侧止损返回 0"""
"""|入场-止损|/入场;盈利侧止损返回 0."""
entry = _positive_float(entry_price)
sl = _positive_float(stop_loss)
if entry is None or sl is None:
@@ -204,7 +204,7 @@ def calc_latest_risk_amount(
mark_price: Any = None,
funds_decimals: int = 2,
) -> Optional[float]:
"""按当前止损与持仓名义价值估算最新风险U)。"""
"""按当前止损与持仓名义价值估算最新风险(U)."""
rf = calc_risk_fraction(direction, entry_price, stop_loss)
if rf is None:
return None
@@ -242,7 +242,7 @@ def order_monitor_tpsl_needs_sync(
*,
eps: float = 1e-12,
) -> tuple[Optional[float], Optional[float], bool]:
"""若交易所 TP/SL 与库中不一致返回应写回的 (sl, tp) 及是否需更新"""
"""若交易所 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)
@@ -380,7 +380,7 @@ def enrich_active_monitor_tpsl_json(
symbol: Any = None,
funds_decimals: int = 2,
) -> dict[str, Any]:
"""place_tpsl 响应展示用 TP/SL最新风险当前盈亏比"""
"""place_tpsl 响应:展示用 TP/SL,最新风险,当前盈亏比."""
def _row_val(key: str, default=None):
try:
if hasattr(row, "keys") and key in row.keys():
+136 -136
View File
@@ -1,136 +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
"""
三所共用:计仓模式 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
+2 -2
View File
@@ -1,4 +1,4 @@
"""持仓时间平仓开仓后按 1h/2h/4h 定时市价平仓"""
"""持仓时间平仓:开仓后按 1h/2h/4h 定时市价平仓."""
from __future__ import annotations
import time
@@ -47,7 +47,7 @@ def _row_val(row: Any, key: str, default=None):
def time_close_settings_from_row(row: Any) -> tuple[int, Optional[int], Optional[int]]:
"""返回 (enabled, hours, close_at_ms)"""
"""返回 (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")
+229 -229
View File
@@ -1,229 +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
"""平仓交易:交易所口径双边成交额与手续费(三所共用聚合逻辑)."""
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
+52 -52
View File
@@ -1,52 +1,52 @@
"""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 "BTC/USDT").strip() or "BTC/USDT"
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
from lib.trade.trade_policy_lib import symbol_base_coin
base = symbol_base_coin(d)
if base not in policy.symbol_whitelist:
return f"{policy.symbol_whitelist[0]}/USDT"
return d
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
)
"""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 "BTC/USDT").strip() or "BTC/USDT"
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
from lib.trade.trade_policy_lib import symbol_base_coin
base = symbol_base_coin(d)
if base not in policy.symbol_whitelist:
return f"{policy.symbol_whitelist[0]}/USDT"
return d
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
)
+205 -205
View File
@@ -1,205 +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:USDTBTC/USDTBTCbtc -> 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, ""
"""
三所共用:账户级方向 / 币种白名单(.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, ""
+7 -7
View File
@@ -1,10 +1,10 @@
"""交易结果展示与入库时的语义归一化"""
"""交易结果展示与入库时的语义归一化."""
_WIN_EPS = 1e-9
def normalize_display_result(result):
"""展示用外部平仓一律视为手动平仓"""
"""展示用:外部平仓一律视为手动平仓."""
res = (result or "").strip()
if res == "外部平仓" or res.startswith("外部平仓"):
return "手动平仓"
@@ -12,7 +12,7 @@ def normalize_display_result(result):
def is_winning_pnl(pnl_amount) -> bool:
"""胜率统计盈亏为正即计为盈利单"""
"""胜率统计:盈亏为正即计为盈利单."""
try:
return float(pnl_amount or 0) > _WIN_EPS
except (TypeError, ValueError):
@@ -20,7 +20,7 @@ def is_winning_pnl(pnl_amount) -> bool:
def sql_effective_pnl_expr() -> str:
"""与 to_effective_trade_dict / hub_trades_lib 一致的盈亏 SQL 表达式"""
"""与 to_effective_trade_dict / hub_trades_lib 一致的盈亏 SQL 表达式."""
return "COALESCE(reviewed_pnl_amount, exchange_realized_pnl, pnl_amount, 0)"
@@ -36,7 +36,7 @@ def is_miss_trade_result(result) -> bool:
def filter_trade_records_excluding_miss(records):
"""列表/统计不展示不计入「错过」类交易记录"""
"""列表/统计:不展示,不计入「错过」类交易记录."""
return [
r
for r in (records or [])
@@ -46,8 +46,8 @@ def filter_trade_records_excluding_miss(records):
def normalize_result_with_pnl(result, pnl_amount):
"""
非手动平仓且实际盈利时不应记为止损
程序触发的止损类平仓若盈亏为正归类为移动止盈
非手动平仓且实际盈利时,不应记为止损.
程序触发的止损类平仓若盈亏为正,归类为移动止盈.
"""
res = normalize_display_result(result)
if res == "手动平仓":
+4 -4
View File
@@ -1,4 +1,4 @@
"""按交易日聚合实例 trade_records 盈亏供统计分析页日历 API 使用"""
"""按交易日聚合实例 trade_records 盈亏,供统计分析页日历 API 使用."""
from __future__ import annotations
import json
@@ -15,7 +15,7 @@ def build_trade_stats_calendar(
*,
reset_hour: int = 8,
) -> dict[str, Any]:
"""pnls: _load_completed_trade_pnls 返回值 (pnl, close_dt, trading_day, row)"""
"""pnls: _load_completed_trade_pnls 返回值 (pnl, close_dt, trading_day, row)."""
y = int(year)
m = int(month)
if m < 1 or m > 12:
@@ -82,7 +82,7 @@ def build_initial_stats_calendar(
reset_hour: int = 8,
segment_key: str = "all",
) -> dict[str, Any]:
"""统计页首屏内嵌日历当前自然月默认品类)。"""
"""统计页首屏内嵌日历(当前自然月,默认品类)."""
return build_trade_stats_calendar(
pnls,
now_dt.year,
@@ -101,7 +101,7 @@ def build_stats_calendar_bootstrap(
reset_hour: int = 8,
segment_key: str = "all",
) -> tuple[dict[str, Any] | None, str | None]:
"""返回 (payload, json_str)失败时 (None, None)供模板安全内嵌"""
"""返回 (payload, json_str);失败时 (None, None),供模板安全内嵌."""
try:
payload = build_initial_stats_calendar(
pnls,