Initialize crypto_monitor_user (user edition) from monitor codebase.
Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -0,0 +1,187 @@
|
||||
"""实盘/关键位放大 K 线:订单元数据与交易所浮盈,价格展示精度."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.hub.hub_ohlcv_lib import (
|
||||
normalize_price_tick,
|
||||
price_tick_from_market,
|
||||
round_ohlcv_bars_to_tick,
|
||||
)
|
||||
from lib.trade.order_monitor_display_lib import (
|
||||
apply_order_live_price_display,
|
||||
apply_order_price_display_fields,
|
||||
)
|
||||
|
||||
|
||||
def resolve_kline_price_tick(
|
||||
exchange: Any,
|
||||
exchange_symbol: str,
|
||||
*,
|
||||
ensure_markets_fn: Callable[[], None],
|
||||
) -> Optional[float]:
|
||||
"""交易所最小价格变动单位,供 lightweight-charts 右侧刻度与标记线对齐."""
|
||||
if not exchange_symbol:
|
||||
return None
|
||||
try:
|
||||
ensure_markets_fn()
|
||||
return normalize_price_tick(price_tick_from_market(exchange, exchange_symbol))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def align_candles_to_price_tick(
|
||||
candles: list[dict[str, Any]],
|
||||
price_tick: Optional[float],
|
||||
) -> None:
|
||||
if price_tick is not None and candles:
|
||||
round_ohlcv_bars_to_tick(candles, price_tick)
|
||||
|
||||
|
||||
def kline_api_price_fields(
|
||||
exchange: Any,
|
||||
exchange_symbol: str,
|
||||
candles: list[dict[str, Any]],
|
||||
*,
|
||||
ensure_markets_fn: Callable[[], None],
|
||||
) -> dict[str, Any]:
|
||||
tick = resolve_kline_price_tick(
|
||||
exchange, exchange_symbol, ensure_markets_fn=ensure_markets_fn
|
||||
)
|
||||
align_candles_to_price_tick(candles, tick)
|
||||
return {"price_tick": tick}
|
||||
|
||||
|
||||
def load_swap_positions_for_order_kline(
|
||||
exchange: Any,
|
||||
*,
|
||||
private_configured: bool,
|
||||
ensure_markets_fn: Callable[[], None],
|
||||
settle: str = "usdt",
|
||||
) -> list:
|
||||
if not private_configured:
|
||||
return []
|
||||
try:
|
||||
ensure_markets_fn()
|
||||
try:
|
||||
return exchange.fetch_positions(None, {"settle": settle}) or []
|
||||
except Exception:
|
||||
return exchange.fetch_positions() or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def metrics_for_order_item(
|
||||
order_item: dict[str, Any],
|
||||
positions: list,
|
||||
*,
|
||||
resolve_ex_sym_fn: Callable[[Any], str],
|
||||
select_live_fn: Callable[[list, str, str], Any],
|
||||
parse_metrics_fn: Callable[..., Optional[dict]],
|
||||
) -> Optional[dict]:
|
||||
if not positions:
|
||||
return None
|
||||
ex_sym = resolve_ex_sym_fn(order_item)
|
||||
direction = order_item.get("direction") or "long"
|
||||
prow = select_live_fn(positions, ex_sym, direction)
|
||||
if not prow:
|
||||
return None
|
||||
lev = order_item.get("leverage")
|
||||
return parse_metrics_fn(prow, order_leverage=lev)
|
||||
|
||||
|
||||
def build_order_kline_order_payload(
|
||||
order_item: dict[str, Any],
|
||||
*,
|
||||
ticker_price: Any,
|
||||
format_price_fn: Callable[[Any, Any], str],
|
||||
calc_pnl_fn: Callable[..., float],
|
||||
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
||||
ex_metrics: Optional[dict] = None,
|
||||
) -> dict[str, Any]:
|
||||
sym = order_item.get("symbol") or ""
|
||||
direction = order_item.get("direction") or "long"
|
||||
margin = float(order_item.get("margin_capital") or 0)
|
||||
leverage = float(order_item.get("leverage") or 0)
|
||||
entry = float(order_item.get("trigger_price") or 0)
|
||||
|
||||
float_pnl = 0.0
|
||||
float_pct = 0.0
|
||||
if ticker_price and entry > 0:
|
||||
float_pnl = float(
|
||||
calc_pnl_fn(direction, entry, ticker_price, margin, leverage)
|
||||
)
|
||||
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0.0
|
||||
|
||||
px_for_fmt = ticker_price
|
||||
mark_raw = None
|
||||
if ex_metrics and ex_metrics.get("mark_price") is not None:
|
||||
mark_raw = ex_metrics["mark_price"]
|
||||
try:
|
||||
px_for_fmt = float(mark_raw)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if ex_metrics and ex_metrics.get("unrealized_pnl") is not None:
|
||||
float_pnl = round(float(ex_metrics["unrealized_pnl"]), 2)
|
||||
denom = ex_metrics.get("initial_margin") or margin
|
||||
float_pct = (
|
||||
round((float_pnl / float(denom)) * 100, 4)
|
||||
if denom and float(denom) > 0
|
||||
else float_pct
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"id": order_item["id"],
|
||||
"symbol": sym,
|
||||
"direction": direction,
|
||||
"trigger_price": order_item.get("trigger_price"),
|
||||
"stop_loss": order_item.get("stop_loss"),
|
||||
"take_profit": order_item.get("take_profit"),
|
||||
"trigger_price_display": format_price_fn(sym, order_item.get("trigger_price")),
|
||||
"stop_loss_display": format_price_fn(sym, order_item.get("stop_loss")),
|
||||
"take_profit_display": format_price_fn(sym, order_item.get("take_profit")),
|
||||
"margin_capital": order_item.get("margin_capital"),
|
||||
"leverage": order_item.get("leverage"),
|
||||
"position_ratio": order_item.get("position_ratio"),
|
||||
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
|
||||
"current_price": round(float(px_for_fmt), 8) if px_for_fmt is not None else None,
|
||||
"float_pnl": round(float(float_pnl), 2),
|
||||
"float_pct": float_pct,
|
||||
}
|
||||
apply_order_price_display_fields(
|
||||
payload,
|
||||
direction=direction,
|
||||
entry_price=order_item.get("trigger_price"),
|
||||
initial_stop_loss=order_item.get("initial_stop_loss"),
|
||||
stop_loss=order_item.get("stop_loss"),
|
||||
take_profit=order_item.get("take_profit"),
|
||||
calc_rr_ratio_fn=calc_rr_ratio_fn,
|
||||
)
|
||||
apply_order_live_price_display(
|
||||
payload,
|
||||
sym,
|
||||
ticker_price,
|
||||
mark_raw,
|
||||
format_price_fn,
|
||||
)
|
||||
payload["current_price_display"] = payload.get("price_display") or (
|
||||
format_price_fn(sym, px_for_fmt) if px_for_fmt is not None else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def enrich_key_kline_response(
|
||||
*,
|
||||
symbol: str,
|
||||
current_price: Any,
|
||||
key_info: Optional[dict[str, Any]],
|
||||
format_price_fn: Callable[[Any, Any], str],
|
||||
) -> tuple[Any, Optional[dict[str, Any]]]:
|
||||
price_display = format_price_fn(symbol, current_price) if current_price is not None else None
|
||||
if key_info is None:
|
||||
return price_display, None
|
||||
enriched = dict(key_info)
|
||||
enriched["upper_display"] = format_price_fn(symbol, key_info.get("upper"))
|
||||
enriched["lower_display"] = format_price_fn(symbol, key_info.get("lower"))
|
||||
return price_display, enriched
|
||||
@@ -0,0 +1,394 @@
|
||||
"""实例数据看板:本户活跃监控 / 持仓只读聚合."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
def _row_dict(row: Any) -> dict[str, Any]:
|
||||
if row is None:
|
||||
return {}
|
||||
if isinstance(row, dict):
|
||||
return dict(row)
|
||||
try:
|
||||
return dict(row)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _dir_label(direction: Any) -> str:
|
||||
d = str(direction or "").strip().lower()
|
||||
if d == "short":
|
||||
return "做空"
|
||||
if d == "long":
|
||||
return "做多"
|
||||
return str(direction or "-")
|
||||
|
||||
|
||||
def _format_order_item(od: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
from lib.strategy.strategy_trade_labels import apply_order_monitor_source_labels
|
||||
|
||||
od = apply_order_monitor_source_labels(od)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.trade.entry_model_lib import enrich_entry_model_display
|
||||
|
||||
enrich_entry_model_display(od)
|
||||
except Exception:
|
||||
pass
|
||||
sym = od.get("exchange_symbol") or od.get("symbol") or "-"
|
||||
direction = str(od.get("direction") or "long").lower()
|
||||
mt = od.get("monitor_type_display") or od.get("monitor_type") or ""
|
||||
kst = od.get("key_signal_type") or ""
|
||||
title = f"{sym} {_dir_label(direction)}"
|
||||
bits = [x for x in (mt, kst) if x]
|
||||
subtitle = " · ".join(bits) if bits else ""
|
||||
entry = _safe_float(od.get("trigger_price"))
|
||||
sl = _safe_float(od.get("stop_loss"))
|
||||
tp = _safe_float(od.get("take_profit"))
|
||||
return {
|
||||
"id": od.get("id"),
|
||||
"kind": "order",
|
||||
"tab": "trade",
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"symbol": sym,
|
||||
"direction": direction,
|
||||
"direction_label": _dir_label(direction),
|
||||
"entry": entry,
|
||||
"mark_price": None,
|
||||
"contracts": _safe_float(od.get("order_amount")),
|
||||
"tp_profit": None,
|
||||
"float_pnl": None,
|
||||
"stop_loss": sl,
|
||||
"take_profit": tp,
|
||||
"status": od.get("status") or "active",
|
||||
}
|
||||
|
||||
|
||||
OPTIONS_SOURCE_LABELS = {
|
||||
"option": "纯期权",
|
||||
"perp_options": "永期对冲",
|
||||
"options_options": "期期对冲",
|
||||
}
|
||||
|
||||
HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"})
|
||||
|
||||
|
||||
def _resolve_options_source(conn, inst_id: str) -> tuple[str, str]:
|
||||
"""根据进行中对冲计划腿判定来源;默认纯期权."""
|
||||
if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"):
|
||||
return "option", OPTIONS_SOURCE_LABELS["option"]
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT p.plan_type
|
||||
FROM hedge_plans p
|
||||
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
||||
WHERE p.status IN ('opening', 'active', 'partial')
|
||||
AND l.status = 'open'
|
||||
AND l.inst_id = ?
|
||||
ORDER BY p.id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
except Exception:
|
||||
return "option", OPTIONS_SOURCE_LABELS["option"]
|
||||
if not row:
|
||||
return "option", OPTIONS_SOURCE_LABELS["option"]
|
||||
pt = str((_row_dict(row).get("plan_type") if isinstance(row, dict) else row[0]) or "").strip()
|
||||
if pt in OPTIONS_SOURCE_LABELS:
|
||||
return pt, OPTIONS_SOURCE_LABELS[pt]
|
||||
return "option", OPTIONS_SOURCE_LABELS["option"]
|
||||
|
||||
|
||||
def _format_options_target(p: dict[str, Any]) -> str:
|
||||
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
|
||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||
if hedge:
|
||||
ot = str(hedge.get("opt_type") or opt_type).upper()
|
||||
side = "Put ≤" if ot == "P" else "Call ≥"
|
||||
tgt = _safe_float(hedge.get("target_index"))
|
||||
pid = hedge.get("plan_id")
|
||||
if tgt is not None:
|
||||
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
|
||||
tgt = _safe_float(p.get("target_index"))
|
||||
if tgt is not None and tgt > 0:
|
||||
side = "Put ≤" if opt_type == "P" else "Call ≥"
|
||||
return f"{side} {tgt:g}"
|
||||
return "—"
|
||||
|
||||
|
||||
def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
|
||||
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
|
||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
|
||||
upl = _safe_float(p.get("upl"))
|
||||
net = None
|
||||
try:
|
||||
from lib.options.options_positions_lib import net_pnl_from_display_row
|
||||
|
||||
net = net_pnl_from_display_row(p)
|
||||
except Exception:
|
||||
net = None
|
||||
pnl = net if net is not None else upl
|
||||
pos = _safe_float(p.get("pos"))
|
||||
exp_ms = p.get("exp_time_ms")
|
||||
if exp_ms is None:
|
||||
exp_ms = p.get("exp_time")
|
||||
try:
|
||||
exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
exp_ms = None
|
||||
source_key, source_label = (
|
||||
_resolve_options_source(conn, inst) if conn is not None else ("option", OPTIONS_SOURCE_LABELS["option"])
|
||||
)
|
||||
return {
|
||||
"id": inst,
|
||||
"kind": "options",
|
||||
"tab": "options",
|
||||
"title": f"{inst} {label}",
|
||||
"subtitle": f"张数 {pos if pos is not None else '-'}",
|
||||
"inst_id": inst,
|
||||
"opt_type": opt_type,
|
||||
"opt_type_label": label,
|
||||
"source": source_key,
|
||||
"source_label": source_label,
|
||||
"pos": pos,
|
||||
"exp_time_ms": exp_ms,
|
||||
"target_monitor": _format_options_target(p),
|
||||
"pnl": round(pnl, 4) if pnl is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]:
|
||||
pid = plan.get("id")
|
||||
underlying = plan.get("underlying") or "-"
|
||||
plan_type = plan.get("plan_type") or ""
|
||||
status = str(plan.get("status") or "")
|
||||
summary = plan.get("contracts_summary") or ""
|
||||
plan_type_label = OPTIONS_SOURCE_LABELS.get(plan_type, plan_type)
|
||||
active = status in HEDGE_ACTIVE_STATUSES
|
||||
status_label = "进行中" if active else (status or "—")
|
||||
return {
|
||||
"id": pid,
|
||||
"kind": "hedge_plan",
|
||||
"tab": "hedge_plan",
|
||||
"title": f"对冲 #{pid} {underlying}",
|
||||
"subtitle": " · ".join(x for x in (plan_type_label, status_label, summary) if x),
|
||||
"underlying": underlying,
|
||||
"plan_type": plan_type,
|
||||
"plan_type_label": plan_type_label,
|
||||
"status": status,
|
||||
"status_label": status_label,
|
||||
"status_active": active,
|
||||
"contracts_summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]:
|
||||
sym = kd.get("exchange_symbol") or kd.get("symbol") or "-"
|
||||
direction = str(kd.get("direction") or "long").lower()
|
||||
signal = kd.get("signal_type") or kd.get("key_signal_type") or kd.get("monitor_type") or ""
|
||||
upper = _safe_float(kd.get("upper"))
|
||||
lower = _safe_float(kd.get("lower"))
|
||||
subtitle_parts = []
|
||||
if signal:
|
||||
subtitle_parts.append(str(signal))
|
||||
if upper is not None or lower is not None:
|
||||
subtitle_parts.append(
|
||||
f"上{upper if upper is not None else '-'} / 下{lower if lower is not None else '-'}"
|
||||
)
|
||||
return {
|
||||
"id": kd.get("id"),
|
||||
"kind": "key",
|
||||
"tab": "key_monitor",
|
||||
"title": f"{sym} {_dir_label(direction)}",
|
||||
"subtitle": " · ".join(subtitle_parts),
|
||||
"symbol": sym,
|
||||
"direction": direction,
|
||||
"direction_label": _dir_label(direction),
|
||||
"upper": upper,
|
||||
"lower": lower,
|
||||
"status": kd.get("status") or "active",
|
||||
}
|
||||
|
||||
|
||||
def _format_trend_item(td: dict[str, Any]) -> dict[str, Any]:
|
||||
sym = td.get("exchange_symbol") or td.get("symbol") or "-"
|
||||
direction = str(td.get("direction") or "long").lower()
|
||||
status = td.get("status") or "active"
|
||||
entry = _safe_float(td.get("entry_price") or td.get("trigger_price"))
|
||||
return {
|
||||
"id": td.get("id"),
|
||||
"kind": "trend",
|
||||
"tab": "strategy",
|
||||
"title": f"趋势回调 {sym} {_dir_label(direction)}",
|
||||
"subtitle": f"状态 {status}",
|
||||
"symbol": sym,
|
||||
"direction": direction,
|
||||
"direction_label": _dir_label(direction),
|
||||
"entry": entry,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]:
|
||||
sym = rd.get("exchange_symbol") or rd.get("symbol") or "-"
|
||||
direction = str(rd.get("direction") or "long").lower()
|
||||
status = rd.get("status") or "active"
|
||||
return {
|
||||
"id": rd.get("id"),
|
||||
"kind": "roll",
|
||||
"tab": "strategy",
|
||||
"title": f"顺势加仓 {sym} {_dir_label(direction)}",
|
||||
"subtitle": f"状态 {status}",
|
||||
"symbol": sym,
|
||||
"direction": direction,
|
||||
"direction_label": _dir_label(direction),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(conn, name: str) -> bool:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
||||
(name,),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def collect_orders(conn) -> list[dict[str, Any]]:
|
||||
if not _table_exists(conn, "order_monitors"):
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
return [_format_order_item(_row_dict(r)) for r in rows]
|
||||
|
||||
|
||||
def collect_keys(conn) -> list[dict[str, Any]]:
|
||||
if not _table_exists(conn, "key_monitors"):
|
||||
return []
|
||||
rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall()
|
||||
return [_format_key_item(_row_dict(r)) for r in rows]
|
||||
|
||||
|
||||
def collect_trends(conn) -> list[dict[str, Any]]:
|
||||
if not _table_exists(conn, "trend_pullback_plans"):
|
||||
return []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
return [_format_trend_item(_row_dict(r)) for r in rows]
|
||||
|
||||
|
||||
def collect_rolls(conn) -> list[dict[str, Any]]:
|
||||
if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"):
|
||||
return []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT g.* FROM roll_groups g
|
||||
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
|
||||
WHERE g.status='active' ORDER BY g.id DESC"""
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
return [_format_roll_item(_row_dict(r)) for r in rows]
|
||||
|
||||
|
||||
def collect_hedge_plans(conn) -> list[dict[str, Any]]:
|
||||
if not _table_exists(conn, "hedge_plans"):
|
||||
return []
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for status in ("opening", "active", "partial"):
|
||||
rows.extend(list_plans(conn, status=status, limit=80))
|
||||
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
|
||||
plans = attach_legs_to_plans(conn, rows)
|
||||
return [_format_hedge_item(p) for p in plans]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def collect_options_items(
|
||||
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
|
||||
*,
|
||||
conn=None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not callable(fetch_options_positions):
|
||||
return []
|
||||
try:
|
||||
raw = fetch_options_positions() or []
|
||||
except Exception:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in raw:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
out.append(_format_options_item(p, conn=conn))
|
||||
return out
|
||||
|
||||
|
||||
def build_instance_dashboard_payload(
|
||||
conn,
|
||||
*,
|
||||
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
|
||||
hedge_enabled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
orders = collect_orders(conn)
|
||||
keys = collect_keys(conn)
|
||||
trends = collect_trends(conn)
|
||||
rolls = collect_rolls(conn)
|
||||
strategy_items = trends + rolls
|
||||
options_items = collect_options_items(fetch_options_positions, conn=conn)
|
||||
hedge_items = collect_hedge_plans(conn) if hedge_enabled else []
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return {
|
||||
"ok": True,
|
||||
"updated_at": now,
|
||||
"orders": {"title": "实盘下单", "count": len(orders), "items": orders, "tab": "trade"},
|
||||
"keys": {"title": "关键位监控", "count": len(keys), "items": keys, "tab": "key_monitor"},
|
||||
"strategy": {
|
||||
"title": "策略交易",
|
||||
"count": len(strategy_items),
|
||||
"items": strategy_items,
|
||||
"trends": trends,
|
||||
"rolls": rolls,
|
||||
"tab": "strategy",
|
||||
},
|
||||
"options": {
|
||||
"title": "期权持仓",
|
||||
"count": len(options_items),
|
||||
"items": options_items,
|
||||
"visible": len(options_items) > 0,
|
||||
"tab": "options",
|
||||
},
|
||||
"hedge_plan": {
|
||||
"title": "对冲计划",
|
||||
"count": len(hedge_items),
|
||||
"items": hedge_items,
|
||||
"visible": len(hedge_items) > 0,
|
||||
"tab": "hedge_plan",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""注册 GET /api/instance/dashboard(三所共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from flask import Flask, jsonify
|
||||
|
||||
|
||||
def register_instance_dashboard_routes(
|
||||
app: Flask,
|
||||
*,
|
||||
login_required: Callable,
|
||||
get_db: Callable,
|
||||
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
|
||||
hedge_enabled: bool = False,
|
||||
) -> None:
|
||||
from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload
|
||||
|
||||
@app.route("/api/instance/dashboard")
|
||||
@login_required
|
||||
def api_instance_dashboard():
|
||||
conn = get_db()
|
||||
try:
|
||||
payload = build_instance_dashboard_payload(
|
||||
conn,
|
||||
fetch_options_positions=fetch_options_positions,
|
||||
hedge_enabled=bool(hedge_enabled),
|
||||
)
|
||||
return jsonify(payload)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db
|
||||
|
||||
DISPLAY_RUNTIME_PREFIX = "display."
|
||||
|
||||
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||
"show_nav_dashboard": False,
|
||||
"show_nav_strategy": True,
|
||||
"show_nav_strategy_records": True,
|
||||
"show_nav_records": True,
|
||||
"show_nav_stats": True,
|
||||
"show_nav_risk_policy": True,
|
||||
"show_nav_env_config": True,
|
||||
"show_nav_options": True,
|
||||
"show_nav_options_review": True,
|
||||
"show_nav_hedge_plan": True,
|
||||
"show_settings_transfer": True,
|
||||
"show_settings_export": True,
|
||||
"show_settings_password": True,
|
||||
"show_settings_options_swap": True,
|
||||
"show_settings_options_transfer": True,
|
||||
}
|
||||
|
||||
DISPLAY_LABELS: dict[str, str] = {
|
||||
"show_nav_dashboard": "数据看板",
|
||||
"show_nav_strategy": "策略交易",
|
||||
"show_nav_strategy_records": "策略交易记录",
|
||||
"show_nav_records": "交易记录与复盘",
|
||||
"show_nav_stats": "统计分析",
|
||||
"show_nav_risk_policy": "风控说明",
|
||||
"show_nav_env_config": "env配置",
|
||||
"show_nav_options": "期权",
|
||||
"show_nav_options_review": "期权复盘",
|
||||
"show_nav_hedge_plan": "对冲计划",
|
||||
"show_settings_transfer": "资金划转",
|
||||
"show_settings_export": "数据导出",
|
||||
"show_settings_password": "账户密码修改",
|
||||
"show_settings_options_swap": "期权币种兑换",
|
||||
"show_settings_options_transfer": "期权资金划转",
|
||||
}
|
||||
|
||||
NAV_TAB_ALLOWED: dict[str, str] = {
|
||||
"dashboard": "show_nav_dashboard",
|
||||
"strategy": "show_nav_strategy",
|
||||
"strategy_records": "show_nav_strategy_records",
|
||||
"records": "show_nav_records",
|
||||
"stats": "show_nav_stats",
|
||||
"risk_policy": "show_nav_risk_policy",
|
||||
"env_config": "show_nav_env_config",
|
||||
"options": "show_nav_options",
|
||||
"options_review": "show_nav_options_review",
|
||||
"hedge_plan": "show_nav_hedge_plan",
|
||||
}
|
||||
|
||||
|
||||
def normalize_display_prefs(raw: dict | None) -> dict[str, bool]:
|
||||
out = dict(DEFAULT_INSTANCE_DISPLAY)
|
||||
if isinstance(raw, dict):
|
||||
for key in DEFAULT_INSTANCE_DISPLAY:
|
||||
if key in raw:
|
||||
out[key] = bool(raw[key])
|
||||
return out
|
||||
|
||||
|
||||
def _load_from_conn(conn) -> dict[str, bool]:
|
||||
stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX)
|
||||
merged: dict[str, Any] = {}
|
||||
for key in DEFAULT_INSTANCE_DISPLAY:
|
||||
sk = key
|
||||
if sk in stored:
|
||||
merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on")
|
||||
return normalize_display_prefs(merged)
|
||||
|
||||
|
||||
def get_display_prefs(get_db: Callable) -> dict[str, bool]:
|
||||
return with_db(get_db, _load_from_conn)
|
||||
|
||||
|
||||
def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]:
|
||||
normalized = normalize_display_prefs(prefs)
|
||||
|
||||
def _save(conn):
|
||||
mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()}
|
||||
runtime_set_many(conn, mapping)
|
||||
return normalized
|
||||
|
||||
return with_db(get_db, _save)
|
||||
|
||||
|
||||
def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
|
||||
prefs = get_display_prefs(get_db)
|
||||
return {"display": prefs, "display_meta": display_meta_for_ui()}
|
||||
|
||||
|
||||
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
||||
prefs = normalize_display_prefs(display or {})
|
||||
key = NAV_TAB_ALLOWED.get((tab or "").strip())
|
||||
if not key:
|
||||
return True
|
||||
return bool(prefs.get(key, True))
|
||||
|
||||
|
||||
def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||
nav_keys = [
|
||||
"show_nav_dashboard",
|
||||
"show_nav_strategy",
|
||||
"show_nav_strategy_records",
|
||||
"show_nav_records",
|
||||
"show_nav_stats",
|
||||
"show_nav_risk_policy",
|
||||
"show_nav_env_config",
|
||||
"show_nav_options",
|
||||
"show_nav_options_review",
|
||||
"show_nav_hedge_plan",
|
||||
]
|
||||
settings_keys = [
|
||||
"show_settings_transfer",
|
||||
"show_settings_export",
|
||||
"show_settings_password",
|
||||
"show_settings_options_swap",
|
||||
"show_settings_options_transfer",
|
||||
]
|
||||
return [
|
||||
{"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]},
|
||||
{"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]},
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll", "strategy_records"})
|
||||
|
||||
_WIN_EPS = 1e-9
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbedRenderPlan:
|
||||
exchange_capitals: bool
|
||||
records_rows: bool
|
||||
records_summary: bool
|
||||
key_history: bool
|
||||
key_list: bool
|
||||
orders: bool
|
||||
stats_bundle: bool
|
||||
strategy: bool
|
||||
orphan_live: bool
|
||||
|
||||
|
||||
def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
|
||||
if embed_mode not in ("fragment", "shell"):
|
||||
return EmbedRenderPlan(
|
||||
exchange_capitals=True,
|
||||
records_rows=True,
|
||||
records_summary=False,
|
||||
key_history=True,
|
||||
key_list=True,
|
||||
orders=True,
|
||||
stats_bundle=True,
|
||||
strategy=True,
|
||||
orphan_live=True,
|
||||
)
|
||||
is_shell = embed_mode == "shell"
|
||||
is_strategy = page in EMBED_STRATEGY_PAGES
|
||||
return EmbedRenderPlan(
|
||||
exchange_capitals=is_shell,
|
||||
records_rows=page == "records",
|
||||
# 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏
|
||||
records_summary=is_shell and page != "records",
|
||||
key_history=page == "key_monitor",
|
||||
key_list=page in ("key_monitor", "trade") or is_strategy,
|
||||
orders=page == "trade" or is_strategy,
|
||||
stats_bundle=page == "stats",
|
||||
strategy=is_strategy,
|
||||
orphan_live=page == "trade" and is_shell,
|
||||
)
|
||||
|
||||
|
||||
def profit_loss_ratio_from_averages(avg_win: float | None, avg_loss: float | None) -> float | None:
|
||||
"""盈亏比 = 平均盈利 / |平均亏损|."""
|
||||
if avg_win is None or avg_loss is None:
|
||||
return None
|
||||
try:
|
||||
aw = float(avg_win)
|
||||
al = float(avg_loss)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if al == 0:
|
||||
return None
|
||||
return round(aw / abs(al), 2)
|
||||
|
||||
|
||||
def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float | None:
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
for row in trades or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
pnl = float(row.get("effective_pnl_amount") or row.get("pnl_amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if pnl > _WIN_EPS:
|
||||
wins.append(pnl)
|
||||
elif pnl < -_WIN_EPS:
|
||||
losses.append(pnl)
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
return profit_loss_ratio_from_averages(avg_win, avg_loss)
|
||||
|
||||
|
||||
def options_funding_label(
|
||||
funding_usdc: float | None,
|
||||
funding_usdt: float | None = None,
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if funding_usdc is not None and float(funding_usdc) > 0:
|
||||
parts.append(f"{float(funding_usdc):.2f} USDC")
|
||||
if funding_usdt is not None and float(funding_usdt) > 0:
|
||||
parts.append(f"{float(funding_usdt):.2f} USDT")
|
||||
return " · ".join(parts) if parts else "—"
|
||||
|
||||
|
||||
def total_funds_usdt(
|
||||
funding_usdt: float | None,
|
||||
trading_usdt: float | None,
|
||||
options_trading_usdc: float | None = None,
|
||||
options_funding_usdc: float | None = None,
|
||||
options_funding_usdt: float | None = None,
|
||||
options_trading_usdt: float | None = None,
|
||||
) -> float | None:
|
||||
parts = [
|
||||
funding_usdt,
|
||||
trading_usdt,
|
||||
options_funding_usdc,
|
||||
options_funding_usdt,
|
||||
options_trading_usdc,
|
||||
options_trading_usdt,
|
||||
]
|
||||
if all(v is None for v in parts):
|
||||
return None
|
||||
try:
|
||||
total = 0.0
|
||||
for v in parts:
|
||||
if v is not None:
|
||||
total += float(v)
|
||||
return round(total, 2)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]:
|
||||
"""顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录."""
|
||||
from lib.trade.trade_result_lib import sql_effective_pnl_expr
|
||||
|
||||
pnl_sql = sql_effective_pnl_expr()
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins,
|
||||
AVG(CASE WHEN {pnl_sql} > 0 THEN {pnl_sql} END) AS avg_win,
|
||||
AVG(CASE WHEN {pnl_sql} < 0 THEN {pnl_sql} END) AS avg_loss
|
||||
FROM trade_records
|
||||
WHERE {tr_ts} >= ? AND {tr_ts} <= ?
|
||||
AND COALESCE(result, '') != '错过'
|
||||
AND COALESCE(reviewed_result, '') != '错过'
|
||||
""",
|
||||
(start_bj, end_bj),
|
||||
).fetchone()
|
||||
total = int(row["total"] or 0) if row else 0
|
||||
wins = int(row["wins"] or 0) if row else 0
|
||||
rate = round(wins / total * 100, 2) if total else 0
|
||||
avg_win = float(row["avg_win"]) if row and row["avg_win"] is not None else None
|
||||
avg_loss = float(row["avg_loss"]) if row and row["avg_loss"] is not None else None
|
||||
return {
|
||||
"records": [],
|
||||
"total": total,
|
||||
"rate": rate,
|
||||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||||
}
|
||||
|
||||
|
||||
def header_trade_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]:
|
||||
"""account_snapshot / 顶栏刷新:按当前列表窗返回总交易/胜率/盈亏比."""
|
||||
from lib.common.history_window_lib import sql_list_time_field, utc_window_to_bj_sql_strings
|
||||
|
||||
start_bj, end_bj = utc_window_to_bj_sql_strings(
|
||||
list_window["start_utc"], list_window["end_utc"], app_tz
|
||||
)
|
||||
tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at")
|
||||
summary = trade_records_summary(conn, start_bj, end_bj, tr_ts)
|
||||
return {
|
||||
"total": summary["total"],
|
||||
"rate": summary["rate"],
|
||||
"profit_loss_ratio": summary.get("profit_loss_ratio"),
|
||||
}
|
||||
|
||||
|
||||
def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]:
|
||||
return {"stats_reset_hour": reset_hour, "segments": []}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""中控 iframe:壳常驻 + tab 内容 API(/embed,/api/embed/page/<tab>)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from lib.paths import embed_templates_dir
|
||||
|
||||
import os
|
||||
from typing import Callable
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||
|
||||
from flask import Flask, Response, jsonify, redirect, request, session
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
|
||||
EMBED_TABS: tuple[str, ...] = (
|
||||
"dashboard",
|
||||
"key_monitor",
|
||||
"trade",
|
||||
"strategy",
|
||||
"strategy_records",
|
||||
"options",
|
||||
"options_review",
|
||||
"hedge_plan",
|
||||
"records",
|
||||
"stats",
|
||||
"risk_policy",
|
||||
"env_config",
|
||||
"settings",
|
||||
)
|
||||
|
||||
PATH_TO_EMBED_TAB: dict[str, str] = {
|
||||
"/": "trade",
|
||||
"/trade": "trade",
|
||||
"/dashboard": "dashboard",
|
||||
"/key_monitor": "key_monitor",
|
||||
"/strategy": "strategy",
|
||||
"/strategy/trend": "strategy",
|
||||
"/strategy/roll": "strategy",
|
||||
"/strategy/records": "strategy_records",
|
||||
"/options": "options",
|
||||
"/options/review": "options_review",
|
||||
"/hedge-plan": "hedge_plan",
|
||||
"/records": "records",
|
||||
"/stats": "stats",
|
||||
"/risk_policy": "risk_policy",
|
||||
"/env_config": "env_config",
|
||||
"/settings": "settings",
|
||||
}
|
||||
|
||||
ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = {
|
||||
"gate": "order_monitor_rule_tips_gate.html",
|
||||
"binance": "order_monitor_rule_tips_binance.html",
|
||||
"okx": "order_monitor_rule_tips_okx.html",
|
||||
}
|
||||
|
||||
|
||||
def order_rule_tips_template(exchange_key: str) -> str:
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
return ORDER_RULE_TIPS_BY_EXCHANGE.get(ex, "order_monitor_rule_tips_gate.html")
|
||||
|
||||
|
||||
def include_transfer_block(exchange_key: str) -> bool:
|
||||
"""三所 standalone / embed 壳均在顶栏展示划转区块."""
|
||||
return (exchange_key or "").strip().lower() in ORDER_RULE_TIPS_BY_EXCHANGE
|
||||
|
||||
|
||||
def ui_open_guard_enabled(exchange_key: str) -> bool:
|
||||
return (exchange_key or "").strip().lower() == "okx"
|
||||
|
||||
|
||||
def ui_orphan_recovery_enabled(exchange_key: str) -> bool:
|
||||
return (exchange_key or "").strip().lower() == "binance"
|
||||
|
||||
|
||||
def path_to_embed_tab(path: str) -> str | None:
|
||||
p = (path or "/").strip()
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
base = urlsplit(p).path.rstrip("/") or "/"
|
||||
return PATH_TO_EMBED_TAB.get(base)
|
||||
|
||||
|
||||
def embed_shell_enabled() -> bool:
|
||||
return (os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def redirect_to_embed_shell_if_enabled(page: str):
|
||||
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换)."""
|
||||
if not embed_shell_enabled():
|
||||
return None
|
||||
if (request.args.get("embed") or "").strip() == "1":
|
||||
return None
|
||||
if (request.path or "").rstrip("/") == "/embed":
|
||||
return None
|
||||
q = {k: v for k, v in request.args.items()}
|
||||
q["tab"] = page
|
||||
q["embed"] = "1"
|
||||
return redirect("/embed?" + urlencode(q))
|
||||
|
||||
|
||||
def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str:
|
||||
"""embed=1 打开时:/trade → /embed?tab=trade&embed=1"""
|
||||
if not embed_shell_enabled():
|
||||
split = urlsplit(path or "/")
|
||||
q = dict(parse_qsl(split.query, keep_blank_values=True))
|
||||
q["embed"] = "1"
|
||||
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
|
||||
if ht in ("light", "dark"):
|
||||
q["hub_theme"] = ht
|
||||
dest = split.path or "/"
|
||||
if q:
|
||||
return f"{dest}?{urlencode(q)}"
|
||||
return dest + "?embed=1"
|
||||
split = urlsplit(path or "/")
|
||||
tab = path_to_embed_tab(split.path)
|
||||
q = dict(parse_qsl(split.query, keep_blank_values=True))
|
||||
if tab:
|
||||
q["tab"] = tab
|
||||
q["embed"] = "1"
|
||||
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
|
||||
if ht in ("light", "dark"):
|
||||
q["hub_theme"] = ht
|
||||
return f"/embed?{urlencode(q)}"
|
||||
q["embed"] = "1"
|
||||
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
|
||||
if ht in ("light", "dark"):
|
||||
q["hub_theme"] = ht
|
||||
dest = split.path or "/"
|
||||
if split.query:
|
||||
dest += "?" + split.query
|
||||
if "embed=1" not in dest:
|
||||
sep = "&" if "?" in dest else "?"
|
||||
dest += f"{sep}embed=1"
|
||||
if ht in ("light", "dark") and "hub_theme=" not in dest:
|
||||
sep = "&" if "?" in dest else "?"
|
||||
dest += f"{sep}hub_theme={ht}"
|
||||
return dest
|
||||
|
||||
|
||||
def attach_embed_templates(app: Flask, repo_root: str) -> None:
|
||||
embed_dir = embed_templates_dir(repo_root)
|
||||
if not os.path.isdir(embed_dir):
|
||||
return
|
||||
existing = app.jinja_loader
|
||||
loaders = [FileSystemLoader(embed_dir)]
|
||||
if existing is not None:
|
||||
if isinstance(existing, ChoiceLoader):
|
||||
loaders = list(existing.loaders) + loaders
|
||||
else:
|
||||
loaders.insert(0, existing)
|
||||
app.jinja_loader = ChoiceLoader(loaders)
|
||||
|
||||
|
||||
def register_embed_routes(
|
||||
app: Flask,
|
||||
login_required: Callable,
|
||||
render_main_page_fn: Callable,
|
||||
) -> None:
|
||||
from lib.instance.instance_live_push_lib import register_instance_live_routes
|
||||
|
||||
app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn
|
||||
register_instance_live_routes(app, login_required)
|
||||
|
||||
@login_required
|
||||
@app.route("/embed")
|
||||
def embed_shell_page():
|
||||
tab = (request.args.get("tab") or "trade").strip()
|
||||
if tab not in EMBED_TABS:
|
||||
tab = "trade"
|
||||
session["hub_embed_shell"] = True
|
||||
return render_main_page_fn(tab, embed_mode="shell")
|
||||
|
||||
@login_required
|
||||
@app.route("/api/embed/page/<tab>")
|
||||
def api_embed_page(tab: str):
|
||||
tab = (tab or "").strip()
|
||||
if tab not in EMBED_TABS:
|
||||
return jsonify({"ok": False, "msg": "unknown tab"}), 404
|
||||
allowed_fn = app.config.get("INSTANCE_TAB_ALLOWED_FN")
|
||||
if callable(allowed_fn) and not allowed_fn(tab):
|
||||
return jsonify({"ok": False, "msg": "tab disabled"}), 403
|
||||
html = render_main_page_fn(tab, embed_mode="fragment")
|
||||
if isinstance(html, Response):
|
||||
html = html.get_data(as_text=True)
|
||||
return jsonify({"ok": True, "page": tab, "html": html})
|
||||
|
||||
|
||||
def pwa_app_name(exchange_key: str) -> str:
|
||||
"""安装 App / 主屏幕显示名(各所独立标识)."""
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
return {
|
||||
"binance": "Binance 交易系统",
|
||||
"okx": "OKX 交易系统",
|
||||
"gate": "Gate 交易系统",
|
||||
}.get(ex, "交易系统")
|
||||
|
||||
|
||||
def embed_context_extras(exchange_key: str) -> dict:
|
||||
return {
|
||||
"order_rule_tips_tpl": order_rule_tips_template(exchange_key),
|
||||
"include_transfer_block": include_transfer_block(exchange_key),
|
||||
"ui_open_guard_enabled": ui_open_guard_enabled(exchange_key),
|
||||
"ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key),
|
||||
"pwa_app_name": pwa_app_name(exchange_key),
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""实例页:持仓未实现盈亏(实时盈亏)汇总."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from lib.hub.hub_position_metrics import parse_position_unrealized_pnl
|
||||
|
||||
|
||||
def position_row_contracts(pos: dict[str, Any]) -> float:
|
||||
"""持仓张数:与三所 app 内 _position_row_effective_contracts 规则一致."""
|
||||
if not isinstance(pos, dict):
|
||||
return 0.0
|
||||
info = pos.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
for val in (
|
||||
pos.get("contracts"),
|
||||
info.get("positionAmt"),
|
||||
info.get("size"),
|
||||
info.get("pos"),
|
||||
info.get("availPos"),
|
||||
):
|
||||
if val is None or val == "":
|
||||
continue
|
||||
try:
|
||||
x = abs(float(val))
|
||||
if x > 0:
|
||||
return x
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
|
||||
def sum_unrealized_pnl_from_positions(positions: list[dict[str, Any]] | None) -> float | None:
|
||||
total = 0.0
|
||||
found = False
|
||||
for p in positions or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if position_row_contracts(p) <= 1e-12:
|
||||
continue
|
||||
upnl = parse_position_unrealized_pnl(p)
|
||||
if upnl is None:
|
||||
continue
|
||||
found = True
|
||||
total += float(upnl)
|
||||
return round(total, 2) if found else None
|
||||
|
||||
|
||||
def _row_field(row: Any, key: str, default: str = "") -> str:
|
||||
if row is None:
|
||||
return default
|
||||
try:
|
||||
if hasattr(row, "keys") and key in row.keys():
|
||||
val = row[key]
|
||||
elif isinstance(row, dict):
|
||||
val = row.get(key)
|
||||
else:
|
||||
val = None
|
||||
except Exception:
|
||||
val = None
|
||||
return str(val or default).strip()
|
||||
|
||||
|
||||
def sum_unrealized_pnl_from_metrics(
|
||||
rows: list[dict[str, Any]] | list[Any],
|
||||
get_metrics_fn: Callable[[str, str], dict[str, Any] | None],
|
||||
) -> float | None:
|
||||
"""按活跃监控单逐笔拉交易所 metrics 汇总(与持仓卡浮盈亏一致)."""
|
||||
total = 0.0
|
||||
found = False
|
||||
for row in rows or []:
|
||||
ex_sym = _row_field(row, "exchange_symbol")
|
||||
sym = _row_field(row, "symbol")
|
||||
direction = _row_field(row, "direction", "long").lower() or "long"
|
||||
target = ex_sym or sym
|
||||
if not target:
|
||||
continue
|
||||
metrics = get_metrics_fn(target, direction)
|
||||
if not isinstance(metrics, dict):
|
||||
continue
|
||||
upnl = metrics.get("unrealized_pnl")
|
||||
if upnl is None:
|
||||
continue
|
||||
try:
|
||||
total += float(upnl)
|
||||
found = True
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return round(total, 2) if found else None
|
||||
|
||||
|
||||
def fetch_unrealized_pnl(fetch_positions_fn: Callable[[], list[dict[str, Any]] | None]) -> float | None:
|
||||
try:
|
||||
return sum_unrealized_pnl_from_positions(fetch_positions_fn() or [])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_instance_unrealized_pnl(
|
||||
fetch_positions_fn: Callable[[], list[dict[str, Any]] | None],
|
||||
active_rows: list[Any] | None,
|
||||
get_metrics_fn: Callable[[str, str], dict[str, Any] | None] | None,
|
||||
) -> float | None:
|
||||
"""先全量持仓汇总,失败或无数据时回退到活跃监控单 metrics."""
|
||||
total = fetch_unrealized_pnl(fetch_positions_fn)
|
||||
if total is not None:
|
||||
return total
|
||||
if active_rows and get_metrics_fn:
|
||||
return sum_unrealized_pnl_from_metrics(active_rows, get_metrics_fn)
|
||||
return None
|
||||
|
||||
|
||||
def merge_unrealized_pnl_components(*parts: float | None) -> float | None:
|
||||
"""合并永续与期权等多路未实现盈亏(任一路有值即参与合计)."""
|
||||
total = 0.0
|
||||
found = False
|
||||
for part in parts:
|
||||
if part is None:
|
||||
continue
|
||||
try:
|
||||
total += float(part)
|
||||
found = True
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return round(total, 2) if found else None
|
||||
@@ -0,0 +1,122 @@
|
||||
"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, Response, stream_with_context
|
||||
|
||||
INSTANCE_LIVE_TICK_SEC = float(os.getenv("INSTANCE_LIVE_TICK_SEC", "5"))
|
||||
INSTANCE_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_SSE_HEARTBEAT_SEC", "25"))
|
||||
|
||||
|
||||
class InstanceLivePush:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.version = 0
|
||||
self._subscribers: list[queue.Queue[str | None]] = []
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True, name="instance-live-push")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._broadcast(close=True)
|
||||
|
||||
def tick(self, reason: str = "poll") -> int:
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
ver = self.version
|
||||
payload = json.dumps({"live_version": ver, "reason": reason}, ensure_ascii=False)
|
||||
self._broadcast(payload)
|
||||
return ver
|
||||
|
||||
def event_dict(self) -> dict[str, Any]:
|
||||
return {"live_version": self.version, "tick_sec": INSTANCE_LIVE_TICK_SEC}
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
self.tick("poll")
|
||||
if self._stop.wait(INSTANCE_LIVE_TICK_SEC):
|
||||
break
|
||||
|
||||
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
|
||||
with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
dead: list[queue.Queue[str | None]] = []
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(None if close else event)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
if dead:
|
||||
with self._lock:
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def _subscribe(self) -> queue.Queue[str | None]:
|
||||
q: queue.Queue[str | None] = queue.Queue(maxsize=16)
|
||||
with self._lock:
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
|
||||
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
|
||||
with self._lock:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def iter_sse(self) -> Iterator[str]:
|
||||
q = self._subscribe()
|
||||
try:
|
||||
yield self._format_event(self.event_dict() | {"reason": "connect"})
|
||||
while True:
|
||||
try:
|
||||
raw = q.get(timeout=INSTANCE_SSE_HEARTBEAT_SEC)
|
||||
except queue.Empty:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
yield f"event: live\ndata: {raw}\n\n"
|
||||
finally:
|
||||
self._unsubscribe(q)
|
||||
|
||||
@staticmethod
|
||||
def _format_event(data: dict[str, Any]) -> str:
|
||||
return "event: live\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n"
|
||||
|
||||
|
||||
instance_live_push = InstanceLivePush()
|
||||
|
||||
|
||||
def notify_instance_balance_changed() -> int:
|
||||
"""划转/兑换后通知 embed 壳拉最新资金快照."""
|
||||
return instance_live_push.tick("balance")
|
||||
|
||||
|
||||
def register_instance_live_routes(app: Flask, login_required: Callable) -> None:
|
||||
instance_live_push.start()
|
||||
|
||||
@login_required
|
||||
@app.route("/api/instance/live/stream")
|
||||
def api_instance_live_stream():
|
||||
return Response(
|
||||
stream_with_context(instance_live_push.iter_sse()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""中控 iframe 内软导航:服务端跳过重型同步,避免切 tab 等待数秒."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Request
|
||||
|
||||
|
||||
def request_is_hub_soft_nav(req: Request | None = None) -> bool:
|
||||
"""embed=1 且带 X-Instance-Soft-Nav 头:实例页内 fetch 换页,非整页刷新."""
|
||||
try:
|
||||
from flask import request as flask_request
|
||||
|
||||
r = req or flask_request
|
||||
if str(r.args.get("embed") or "").strip() != "1":
|
||||
return False
|
||||
flag = (r.headers.get("X-Instance-Soft-Nav") or "").strip().lower()
|
||||
return flag in ("1", "true", "yes")
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,74 @@
|
||||
"""PM2 重启当前实例(仅 Linux 部署环境)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def default_pm2_app_name(exchange_key: str) -> str:
|
||||
mapping = {
|
||||
"okx": "crypto_okx",
|
||||
"binance": "crypto_binance",
|
||||
"gate": "crypto_gate",
|
||||
}
|
||||
return mapping.get((exchange_key or "").strip().lower(), "crypto_okx")
|
||||
|
||||
|
||||
def resolve_pm2_app_name(exchange_key: str) -> str:
|
||||
explicit = (os.getenv("PM2_APP_NAME") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
return default_pm2_app_name(exchange_key)
|
||||
|
||||
|
||||
def schedule_pm2_restart(app_name: str, *, delay_seconds: float = 1.0) -> dict[str, Any]:
|
||||
"""延迟触发 PM2 重启,便于 HTTP 响应先返回(避免重启当前进程导致请求中断)."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": app_name}
|
||||
if not (app_name or "").strip():
|
||||
return {"ok": False, "msg": "未指定 PM2 应用名", "app": app_name}
|
||||
app_name = app_name.strip()
|
||||
try:
|
||||
cmd = f"sleep {delay_seconds} && exec pm2 restart {shlex.quote(app_name)} --update-env"
|
||||
subprocess.Popen(
|
||||
["bash", "-c", cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return {"ok": True, "app": app_name, "msg": "重启已触发", "deferred": True}
|
||||
except FileNotFoundError:
|
||||
return {"ok": False, "msg": "未找到 bash 或 pm2 命令", "app": app_name}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e), "app": app_name}
|
||||
|
||||
|
||||
def restart_instance_pm2(exchange_key: str, *, defer: bool = False) -> dict[str, Any]:
|
||||
if not sys.platform.startswith("linux"):
|
||||
return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": None}
|
||||
app_name = resolve_pm2_app_name(exchange_key)
|
||||
if defer:
|
||||
return schedule_pm2_restart(app_name)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["pm2", "restart", app_name, "--update-env"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
ok = proc.returncode == 0
|
||||
return {
|
||||
"ok": ok,
|
||||
"app": app_name,
|
||||
"msg": (proc.stdout or proc.stderr or "").strip()[:500],
|
||||
"returncode": proc.returncode,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {"ok": False, "msg": "未找到 pm2 命令", "app": app_name}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "msg": "pm2 restart 超时", "app": app_name}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e), "app": app_name}
|
||||
@@ -0,0 +1,218 @@
|
||||
"""实例「系统设置」页:从 .env 汇总风控说明(三所共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.key_monitor.key_auto_order_lib import load_key_auto_order_enabled
|
||||
from lib.trade.account_risk_lib import (
|
||||
cooling_hours_manual,
|
||||
cooling_hours_manual_journal,
|
||||
manual_close_daily_limit,
|
||||
max_active_positions_from_env,
|
||||
mood_issues_daily_freeze_enabled,
|
||||
risk_control_enabled,
|
||||
)
|
||||
from lib.trade.position_sizing_lib import is_full_margin_mode, load_position_sizing_mode, mode_label_zh
|
||||
from lib.trade.trade_policy_lib import TradePolicy
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool = False) -> bool:
|
||||
raw = (os.getenv(key) or "").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _env_float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _row(label: str, value: str, note: str = "") -> dict[str, str]:
|
||||
return {"label": label, "value": value, "note": note}
|
||||
|
||||
|
||||
def _on_off(enabled: bool) -> str:
|
||||
return "开启" if enabled else "关闭"
|
||||
|
||||
|
||||
def build_instance_settings_view(
|
||||
*,
|
||||
exchange_key: str,
|
||||
exchange_display: str,
|
||||
risk_status: Optional[dict[str, Any]] = None,
|
||||
trade_policy: Optional[TradePolicy] = None,
|
||||
data_export_version: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
rs = risk_status or {}
|
||||
sizing_mode = load_position_sizing_mode()
|
||||
key_auto = load_key_auto_order_enabled()
|
||||
reset_hour = _env_int("TRADING_DAY_RESET_HOUR", 8)
|
||||
hard_limit = _env_int("DAILY_OPEN_HARD_LIMIT", 0)
|
||||
alert_threshold = _env_int("DAILY_OPEN_ALERT_THRESHOLD", 5)
|
||||
force_close_on = _env_bool("FORCE_CLOSE_ENABLED", False)
|
||||
force_close_hour = _env_int("FORCE_CLOSE_BJ_HOUR", 0)
|
||||
auto_transfer_on = _env_bool("AUTO_TRANSFER_ENABLED", False)
|
||||
|
||||
sections: list[dict[str, Any]] = []
|
||||
|
||||
sections.append(
|
||||
{
|
||||
"title": "交易执行",
|
||||
"rows": [
|
||||
_row("最大同时持仓", str(max_active_positions_from_env())),
|
||||
_row("计仓模式", mode_label_zh(sizing_mode)),
|
||||
_row("以损定仓风险%", f"{_env_float('RISK_PERCENT', 2):g}%"),
|
||||
_row("人工最低盈亏比", f">= {_env_float('MANUAL_MIN_PLANNED_RR', 1.4):g}:1"),
|
||||
_row(
|
||||
"交易日切点",
|
||||
f"北京时间 {reset_hour}:00",
|
||||
"新交易日统计与部分开仓限制以此为准",
|
||||
),
|
||||
_row(
|
||||
"单日开仓提醒",
|
||||
f"第 {alert_threshold} 次",
|
||||
"达次数推送企业微信,不拦单",
|
||||
),
|
||||
_row(
|
||||
"单日开仓硬上限",
|
||||
str(hard_limit) if hard_limit > 0 else "未启用",
|
||||
"达上限后禁止一切新开仓直至下一交易日" if hard_limit > 0 else "",
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
sections.append(
|
||||
{
|
||||
"title": "账户冷静期",
|
||||
"rows": [
|
||||
_row("风控总开关", _on_off(risk_control_enabled())),
|
||||
_row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"),
|
||||
_row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"),
|
||||
_row("日手动平仓上限", f"{manual_close_daily_limit()} 次", "超限当日冻结"),
|
||||
_row(
|
||||
"复盘情绪日冻结",
|
||||
_on_off(mood_issues_daily_freeze_enabled()),
|
||||
"复盘勾选心态标签可触发当日冻结",
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
key_rows = [
|
||||
_row("关键位自动单", _on_off(key_auto)),
|
||||
_row("关键位最低盈亏比", f"> {_env_float('KEY_AUTO_MIN_PLANNED_RR', 1.5):g}:1"),
|
||||
]
|
||||
if is_full_margin_mode(sizing_mode):
|
||||
key_rows.append(
|
||||
_row(
|
||||
"全仓模式",
|
||||
"仅触价类自动单",
|
||||
"箱体/斐波等自动开仓在全仓下禁用",
|
||||
)
|
||||
)
|
||||
sections.append({"title": "关键位与自动单", "rows": key_rows})
|
||||
|
||||
if force_close_on or (exchange_key or "").strip().lower() == "gate":
|
||||
sections.append(
|
||||
{
|
||||
"title": "整点强制清仓",
|
||||
"rows": [
|
||||
_row("强制清仓", _on_off(force_close_on)),
|
||||
_row("执行时刻", f"北京时间 {force_close_hour}:00 起 15 分钟内"),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if (exchange_key or "").strip().lower() == "okx" and _env_bool("OKX_OPTIONS_ENABLED", False):
|
||||
opt_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip()
|
||||
sections.append(
|
||||
{
|
||||
"title": "期权设置",
|
||||
"rows": [
|
||||
_row("期权模块", "已启用"),
|
||||
_row(
|
||||
"期权 API",
|
||||
f"已配置(…{opt_key[-4:]})" if len(opt_key) >= 4 else "未配置",
|
||||
),
|
||||
_row(
|
||||
"子账户",
|
||||
(os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip() or "未配置 OKX_SUB_ACCOUNT_NAME",
|
||||
"主/子账户划转用",
|
||||
),
|
||||
_row(
|
||||
"说明",
|
||||
"币种兑换与账户划转到右侧「期权设置」卡片操作",
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
policy_note = ""
|
||||
if trade_policy and getattr(trade_policy, "badge_text", ""):
|
||||
policy_note = str(trade_policy.badge_text)
|
||||
|
||||
return {
|
||||
"exchange_display": exchange_display,
|
||||
"risk_status_label": str(rs.get("status_label") or "正常"),
|
||||
"risk_status_reason": str(rs.get("reason") or "").strip(),
|
||||
"can_trade": bool(rs.get("can_trade", True)),
|
||||
"trade_policy_note": policy_note,
|
||||
"sections": sections,
|
||||
"data_export_version": int(data_export_version),
|
||||
"show_transfer": (exchange_key or "").strip().lower() in ("gate", "binance", "okx"),
|
||||
"options_settings_enabled": (exchange_key or "").strip().lower() == "okx"
|
||||
and _env_bool("OKX_OPTIONS_ENABLED", False),
|
||||
"options_sub_account": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(),
|
||||
"auto_transfer_enabled": auto_transfer_on,
|
||||
"auto_transfer_bj_hour": _env_int("AUTO_TRANSFER_BJ_HOUR", 8),
|
||||
"auto_transfer_amount": _env_float("AUTO_TRANSFER_AMOUNT", 30),
|
||||
"auto_transfer_from": (os.getenv("AUTO_TRANSFER_FROM") or "funding").strip(),
|
||||
"auto_transfer_to": (os.getenv("AUTO_TRANSFER_TO") or "swap").strip(),
|
||||
}
|
||||
|
||||
|
||||
def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[str, Any]) -> list[dict[str, str]]:
|
||||
disp = display or {}
|
||||
inst = instance_settings or {}
|
||||
tabs: list[dict[str, str]] = [{"key": "nav", "title": "导航显示"}]
|
||||
if disp.get("show_settings_password", True):
|
||||
tabs.append({"key": "password", "title": "账户密码"})
|
||||
if inst.get("show_transfer") and disp.get("show_settings_transfer", True):
|
||||
tabs.append({"key": "transfer", "title": "永续划转"})
|
||||
if disp.get("show_settings_export", True):
|
||||
tabs.append({"key": "export", "title": "数据导出"})
|
||||
if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True):
|
||||
tabs.append({"key": "options_swap", "title": "币种兑换"})
|
||||
if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True):
|
||||
tabs.append({"key": "options_transfer", "title": "期权划转"})
|
||||
return tabs
|
||||
|
||||
|
||||
def settings_page_context(page: str, *, instance_base_dir: str | None = None, **kwargs: Any) -> dict[str, Any]:
|
||||
p = (page or "").strip()
|
||||
if p not in ("settings", "risk_policy", "env_config"):
|
||||
return {}
|
||||
display = kwargs.pop("display", None)
|
||||
ctx: dict[str, Any] = {"instance_settings": build_instance_settings_view(**kwargs)}
|
||||
if p == "settings":
|
||||
ctx["settings_tabs"] = build_settings_tabs(display, ctx["instance_settings"])
|
||||
if p == "env_config" and instance_base_dir:
|
||||
from lib.env.env_ui_manifest import build_env_ui_payload
|
||||
|
||||
exchange_key = str(kwargs.get("exchange_key") or "")
|
||||
env_path = os.path.join(instance_base_dir, ".env")
|
||||
example_path = os.path.join(instance_base_dir, ".env.example")
|
||||
ctx["env_config_groups"] = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return ctx
|
||||
@@ -0,0 +1,174 @@
|
||||
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import jsonify, request, session
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
|
||||
from lib.env.env_ui_manifest import (
|
||||
build_env_ui_payload,
|
||||
filter_updates_for_ui,
|
||||
validate_env_ui_updates,
|
||||
)
|
||||
from lib.env.env_schema import parse_env_example_schema
|
||||
from lib.instance.instance_display_prefs_lib import (
|
||||
display_meta_for_ui,
|
||||
get_display_prefs,
|
||||
normalize_display_prefs,
|
||||
save_display_prefs,
|
||||
tab_allowed,
|
||||
)
|
||||
from lib.instance.instance_pm2_lib import restart_instance_pm2
|
||||
from lib.instance.runtime_config_lib import apply_env_reload
|
||||
|
||||
|
||||
def _api_login_required(hub_token_write_allowed: bool = False):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
from lib.hub.hub_auth import request_allowed as hub_request_allowed
|
||||
|
||||
logged_in = bool(session.get("logged_in"))
|
||||
auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
hub_hdr = (request.headers.get("X-Hub-Token") or "").strip()
|
||||
bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
|
||||
if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed:
|
||||
return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403
|
||||
if hub_request_allowed(logged_in, auth_disabled):
|
||||
return f(*args, **kwargs)
|
||||
return jsonify({"ok": False, "msg": "未登录"}), 401
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_instance_settings_routes(
|
||||
app,
|
||||
*,
|
||||
get_db: Callable,
|
||||
login_required_fn: Callable,
|
||||
base_dir: str,
|
||||
exchange_key: str,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
env_path = os.path.join(base_dir, ".env")
|
||||
example_path = os.path.join(base_dir, ".env.example")
|
||||
api_auth = _api_login_required()
|
||||
|
||||
@app.route("/api/settings/display", methods=["GET", "POST"])
|
||||
@api_auth
|
||||
def api_settings_display():
|
||||
if request.method == "GET":
|
||||
prefs = get_display_prefs(get_db)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"display": prefs,
|
||||
"meta": display_meta_for_ui(),
|
||||
}
|
||||
)
|
||||
body = request.get_json(silent=True) or {}
|
||||
raw = body.get("display") if isinstance(body.get("display"), dict) else body
|
||||
saved = save_display_prefs(get_db, raw)
|
||||
return jsonify({"ok": True, "display": saved})
|
||||
|
||||
@app.route("/api/settings/env/meta", methods=["GET"])
|
||||
@api_auth
|
||||
def api_env_meta():
|
||||
groups = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return jsonify({"ok": True, "groups": groups})
|
||||
|
||||
@app.route("/api/settings/env", methods=["GET", "POST"])
|
||||
@api_auth
|
||||
def api_settings_env():
|
||||
if request.method == "GET":
|
||||
groups = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return jsonify({"ok": True, "groups": groups})
|
||||
body = request.get_json(silent=True) or {}
|
||||
updates = body.get("values") if isinstance(body.get("values"), dict) else body
|
||||
if not isinstance(updates, dict):
|
||||
return jsonify({"ok": False, "msg": "无效请求体"}), 400
|
||||
updates = filter_updates_for_ui(exchange_key, updates)
|
||||
clean, errors = validate_env_ui_updates(exchange_key, example_path, updates)
|
||||
if errors:
|
||||
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
|
||||
if not clean:
|
||||
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
|
||||
changed = apply_env_updates(env_path, clean)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
reload_info = apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"changed_keys": changed,
|
||||
"restart_required": reload_info.get("restart_required", False),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/settings/password", methods=["POST"])
|
||||
@api_auth
|
||||
def api_change_password():
|
||||
body = request.get_json(silent=True) or {}
|
||||
old_password = str(body.get("old_password") or "")
|
||||
new_username = str(body.get("new_username") or "").strip()
|
||||
new_password = str(body.get("new_password") or "")
|
||||
confirm = str(body.get("confirm_password") or "")
|
||||
if not old_password or old_password != password:
|
||||
return jsonify({"ok": False, "msg": "当前密码错误"}), 400
|
||||
if len(new_password) < 6:
|
||||
return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400
|
||||
if new_password != confirm:
|
||||
return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400
|
||||
updates: dict[str, str] = {"APP_PASSWORD": new_password}
|
||||
if new_username:
|
||||
updates["APP_USERNAME"] = new_username
|
||||
changed = apply_env_updates(env_path, updates)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify({"ok": True, "restart_required": True, "changed_keys": changed})
|
||||
|
||||
@app.route("/api/admin/restart", methods=["POST"])
|
||||
@api_auth
|
||||
def api_admin_restart():
|
||||
result = restart_instance_pm2(exchange_key, defer=True)
|
||||
code = 200 if result.get("ok") else 500
|
||||
return jsonify({"ok": bool(result.get("ok")), **result}), code
|
||||
|
||||
@app.route("/api/admin/health", methods=["GET"])
|
||||
def api_admin_health():
|
||||
return jsonify({"ok": True, "status": "up"})
|
||||
|
||||
def tab_allowed_fn(tab: str) -> bool:
|
||||
prefs = get_display_prefs(get_db)
|
||||
return tab_allowed(tab, prefs)
|
||||
|
||||
app.config["INSTANCE_GET_DB"] = get_db
|
||||
app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn
|
||||
|
||||
@app.route("/api/embed/tab_allowed/<tab>", methods=["GET"])
|
||||
@api_auth
|
||||
def api_tab_allowed(tab: str):
|
||||
prefs = get_display_prefs(get_db)
|
||||
return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)})
|
||||
|
||||
|
||||
def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]:
|
||||
from lib.instance.instance_settings_lib import settings_page_context
|
||||
|
||||
prefs = get_display_prefs(get_db)
|
||||
ctx = {
|
||||
"display": prefs,
|
||||
"display_meta": display_meta_for_ui(),
|
||||
**settings_page_context(page, display=prefs, **settings_kwargs),
|
||||
}
|
||||
return ctx
|
||||
@@ -0,0 +1,452 @@
|
||||
"""交易复盘 / 订单 K 线拼图(Binance / Gate / OKX 共用)."""
|
||||
|
||||
import math
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
Image = None # type: ignore
|
||||
ImageDraw = None # type: ignore
|
||||
ImageFont = None # type: ignore
|
||||
|
||||
JOURNAL_CHART_TF_CHOICES = ("1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1d")
|
||||
JOURNAL_CHART_DEFAULT_TF1 = "15m"
|
||||
JOURNAL_CHART_DEFAULT_TF2 = "1h"
|
||||
JOURNAL_CHART_DEFAULT_LIMIT = 300
|
||||
JOURNAL_CHART_LIMIT_MIN = 50
|
||||
JOURNAL_CHART_LIMIT_MAX = 500
|
||||
JOURNAL_CHART_ANCHOR_CLOSE = "close"
|
||||
JOURNAL_CHART_ANCHOR_NOW = "now"
|
||||
JOURNAL_CHART_DEFAULT_ANCHOR = JOURNAL_CHART_ANCHOR_CLOSE
|
||||
|
||||
|
||||
def _load_font(size):
|
||||
if not ImageFont:
|
||||
return None
|
||||
for name in ("msyh.ttc", "Microsoft YaHei.ttf", "arial.ttf", "Arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(name, size)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
return ImageFont.load_default()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ohlcv_to_rows(ohlcv):
|
||||
rows = []
|
||||
for bar in ohlcv or []:
|
||||
if not bar or len(bar) < 6:
|
||||
continue
|
||||
try:
|
||||
rows.append(
|
||||
{
|
||||
"ts": int(bar[0]),
|
||||
"o": float(bar[1]),
|
||||
"h": float(bar[2]),
|
||||
"l": float(bar[3]),
|
||||
"c": float(bar[4]),
|
||||
"v": float(bar[5]),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def marker_tag_label(tag):
|
||||
t = str(tag or "").strip().upper()
|
||||
if t == "ENTRY":
|
||||
return "开仓"
|
||||
if t == "EXIT":
|
||||
return "平仓"
|
||||
if t == "STOP":
|
||||
return "止损"
|
||||
return str(tag or "")
|
||||
|
||||
|
||||
def pick_marker_point(rows, target_ts_ms, target_price=None):
|
||||
if not rows or target_ts_ms is None:
|
||||
return None, None
|
||||
idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms)))
|
||||
if target_price is not None:
|
||||
try:
|
||||
p = float(target_price)
|
||||
if p > 0:
|
||||
return idx, p
|
||||
except Exception:
|
||||
pass
|
||||
return idx, float(rows[idx]["c"])
|
||||
|
||||
|
||||
def parse_positive_price(raw):
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
p = float(s)
|
||||
return p if p > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_journal_chart_anchor(raw):
|
||||
s = str(raw or "").strip().lower()
|
||||
if s in (JOURNAL_CHART_ANCHOR_NOW, "current", "当前", "当前时间"):
|
||||
return JOURNAL_CHART_ANCHOR_NOW
|
||||
return JOURNAL_CHART_ANCHOR_CLOSE
|
||||
|
||||
|
||||
def parse_journal_chart_limit(raw, fallback=None):
|
||||
fb = int(fallback if fallback is not None else JOURNAL_CHART_DEFAULT_LIMIT)
|
||||
try:
|
||||
n = int(str(raw or "").strip() or fb)
|
||||
except (TypeError, ValueError):
|
||||
n = fb
|
||||
return max(JOURNAL_CHART_LIMIT_MIN, min(JOURNAL_CHART_LIMIT_MAX, n))
|
||||
|
||||
|
||||
def normalize_chart_timeframe(raw):
|
||||
tf = str(raw or "").strip().lower()
|
||||
if tf in JOURNAL_CHART_TF_CHOICES:
|
||||
return tf
|
||||
return ""
|
||||
|
||||
|
||||
def timeframe_period_ms(tf):
|
||||
s = (tf or "").strip().lower()
|
||||
if s.endswith("m"):
|
||||
try:
|
||||
return int(s[:-1]) * 60 * 1000
|
||||
except ValueError:
|
||||
pass
|
||||
if s.endswith("h"):
|
||||
try:
|
||||
return int(s[:-1]) * 3600 * 1000
|
||||
except ValueError:
|
||||
pass
|
||||
if s.endswith("d"):
|
||||
try:
|
||||
return int(s[:-1]) * 86400 * 1000
|
||||
except ValueError:
|
||||
pass
|
||||
return 300000
|
||||
|
||||
|
||||
def _to_int_ms(value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
v = int(value)
|
||||
return v if v > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def trade_review_fetch_window(entry_ts_ms, exit_ts_ms, timeframe, limit, anchor=None, now_ms=None):
|
||||
"""
|
||||
复盘 K 线窗口(anchor=close):
|
||||
- 有开/平仓:从开仓前若干根起,到平仓 K 线止(覆盖整笔交易 + 入场前背景)
|
||||
- 仅开仓:以开仓时间为终点向前 limit 根
|
||||
- 仅平仓:以平仓时间为终点向前 limit 根
|
||||
anchor=now:以当前时间为终点向前 limit 根(可看平仓后走势)
|
||||
"""
|
||||
period = timeframe_period_ms(timeframe)
|
||||
lim = max(2, int(limit))
|
||||
entry_ms = _to_int_ms(entry_ts_ms)
|
||||
exit_ms = _to_int_ms(exit_ts_ms)
|
||||
anch = (anchor or JOURNAL_CHART_DEFAULT_ANCHOR).strip().lower()
|
||||
|
||||
if anch == JOURNAL_CHART_ANCHOR_NOW:
|
||||
end_ms = _to_int_ms(now_ms)
|
||||
if not end_ms:
|
||||
return None
|
||||
since_ms = end_ms - period * (lim + 10)
|
||||
return {
|
||||
"since_ms": since_ms,
|
||||
"end_ms": end_ms,
|
||||
"window_start_ms": since_ms,
|
||||
"fetch_limit": lim + 20,
|
||||
"display_limit": lim,
|
||||
}
|
||||
|
||||
if entry_ms and exit_ms:
|
||||
if exit_ms < entry_ms:
|
||||
entry_ms, exit_ms = exit_ms, entry_ms
|
||||
span_bars = max(1, (exit_ms - entry_ms) // period + 1)
|
||||
pre_bars = max(40, min(120, lim // 3))
|
||||
need = span_bars + pre_bars
|
||||
fetch_limit = min(JOURNAL_CHART_LIMIT_MAX, max(lim, need + 15))
|
||||
since_ms = entry_ms - period * pre_bars
|
||||
return {
|
||||
"since_ms": since_ms,
|
||||
"end_ms": exit_ms,
|
||||
"window_start_ms": since_ms,
|
||||
"fetch_limit": fetch_limit,
|
||||
"display_limit": lim,
|
||||
}
|
||||
if entry_ms:
|
||||
end_ms = entry_ms
|
||||
since_ms = end_ms - period * (lim + 10)
|
||||
return {
|
||||
"since_ms": since_ms,
|
||||
"end_ms": end_ms,
|
||||
"window_start_ms": since_ms,
|
||||
"fetch_limit": lim + 20,
|
||||
"display_limit": lim,
|
||||
}
|
||||
if exit_ms:
|
||||
end_ms = exit_ms
|
||||
since_ms = end_ms - period * (lim + 10)
|
||||
return {
|
||||
"since_ms": since_ms,
|
||||
"end_ms": end_ms,
|
||||
"window_start_ms": since_ms,
|
||||
"fetch_limit": lim + 20,
|
||||
"display_limit": lim,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def trim_rows_for_trade_review(rows, window):
|
||||
if not window:
|
||||
return list(rows or [])
|
||||
start_ms = int(window["window_start_ms"])
|
||||
end_ms = int(window["end_ms"])
|
||||
lim = int(window["display_limit"])
|
||||
filt = [r for r in (rows or []) if start_ms <= int(r["ts"]) <= end_ms]
|
||||
if len(filt) > lim:
|
||||
filt = filt[-lim:]
|
||||
return filt
|
||||
|
||||
|
||||
def parse_journal_chart_timeframes(tf1, tf2, fallback_tfs=None):
|
||||
"""复盘表单:最多两个周期,去重保序."""
|
||||
out = []
|
||||
for raw in (tf1, tf2):
|
||||
tf = normalize_chart_timeframe(raw)
|
||||
if tf and tf not in out:
|
||||
out.append(tf)
|
||||
if out:
|
||||
return out[:2]
|
||||
fb = [normalize_chart_timeframe(x) for x in (fallback_tfs or (JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2))]
|
||||
fb = [x for x in fb if x]
|
||||
return fb[:2] if fb else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
|
||||
|
||||
|
||||
def marker_points_for_timeframe(rows, marker_payload):
|
||||
points = []
|
||||
if not marker_payload or not rows:
|
||||
return points
|
||||
entry_idx, entry_price = pick_marker_point(
|
||||
rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")
|
||||
)
|
||||
exit_idx, exit_price = pick_marker_point(
|
||||
rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")
|
||||
)
|
||||
if entry_idx is not None and entry_price is not None:
|
||||
points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"})
|
||||
if exit_idx is not None and exit_price is not None:
|
||||
points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"})
|
||||
return points
|
||||
|
||||
|
||||
def price_levels_from_marker_payload(marker_payload):
|
||||
levels = []
|
||||
if not marker_payload:
|
||||
return levels
|
||||
sl = parse_positive_price(marker_payload.get("stop_loss_price"))
|
||||
if sl is not None:
|
||||
levels.append({"price": sl, "label": "止损", "color": (255, 152, 0)})
|
||||
return levels
|
||||
|
||||
|
||||
def render_candles_subplot(
|
||||
rows,
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
bg_rgb=(255, 255, 255),
|
||||
marker_points=None,
|
||||
price_levels=None,
|
||||
):
|
||||
if not Image or not ImageDraw:
|
||||
raise RuntimeError("缺少依赖:Pillow(pip install Pillow)")
|
||||
img = Image.new("RGB", (width, height), bg_rgb)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = _load_font(14)
|
||||
small = _load_font(12)
|
||||
|
||||
pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28
|
||||
plot_w = max(10, width - pad_l - pad_r)
|
||||
plot_h = max(10, height - pad_t - pad_b)
|
||||
|
||||
header_bg = (245, 247, 250)
|
||||
draw.rectangle((0, 0, width, pad_t), fill=header_bg)
|
||||
if font:
|
||||
draw.text((10, 6), title, fill=(25, 35, 60), font=font)
|
||||
else:
|
||||
draw.text((10, 6), title, fill=(25, 35, 60))
|
||||
|
||||
if not rows:
|
||||
if small:
|
||||
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small)
|
||||
else:
|
||||
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120))
|
||||
return img
|
||||
|
||||
lo = min(r["l"] for r in rows)
|
||||
hi = max(r["h"] for r in rows)
|
||||
for pl in price_levels or []:
|
||||
try:
|
||||
p = float(pl.get("price"))
|
||||
if p > 0:
|
||||
lo = min(lo, p)
|
||||
hi = max(hi, p)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if hi <= lo:
|
||||
hi = lo + 1e-12
|
||||
|
||||
n = len(rows)
|
||||
marker_by_idx = {}
|
||||
for mp in marker_points or []:
|
||||
try:
|
||||
idx = int(mp.get("idx"))
|
||||
except Exception:
|
||||
continue
|
||||
if idx < 0 or idx >= n:
|
||||
continue
|
||||
marker_by_idx.setdefault(idx, []).append(mp)
|
||||
|
||||
x0 = pad_l
|
||||
for i, r in enumerate(rows):
|
||||
x1 = pad_l + int((i + 1) * plot_w / n)
|
||||
x_mid = (x0 + x1) // 2
|
||||
wick_x = x_mid
|
||||
y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h)
|
||||
y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h)
|
||||
y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h)
|
||||
y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h)
|
||||
top = min(y_open, y_close)
|
||||
bot = max(y_open, y_close)
|
||||
up = r["c"] >= r["o"]
|
||||
wick_color = (120, 120, 120)
|
||||
edge_color = (20, 20, 20)
|
||||
draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color)
|
||||
body_w = max(1, (x1 - x0) - 2)
|
||||
left = x0 + 1
|
||||
if bot - top < 2:
|
||||
mid = (top + bot) // 2
|
||||
draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color)
|
||||
else:
|
||||
if up:
|
||||
draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1)
|
||||
else:
|
||||
draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1)
|
||||
for j, mp in enumerate(marker_by_idx.get(i, [])):
|
||||
tag = str(mp.get("tag") or "")
|
||||
label = marker_tag_label(tag)
|
||||
m_price = float(mp.get("price") or r["c"])
|
||||
y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h)
|
||||
y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m))
|
||||
x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14
|
||||
x_draw = int(x_mid + x_off)
|
||||
if tag == "ENTRY":
|
||||
m_color = (0, 195, 95)
|
||||
tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)]
|
||||
text_y = y_m - 36
|
||||
else:
|
||||
m_color = (235, 65, 65)
|
||||
tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)]
|
||||
text_y = y_m + 12
|
||||
draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1)
|
||||
draw.polygon(tri, fill=m_color)
|
||||
draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3)
|
||||
if font:
|
||||
draw.text((x_draw + 8, text_y), label, fill=m_color, font=font)
|
||||
else:
|
||||
draw.text((x_draw + 8, text_y), label, fill=m_color)
|
||||
x0 = x1
|
||||
|
||||
x_right = pad_l + plot_w
|
||||
for pl in price_levels or []:
|
||||
try:
|
||||
p = float(pl.get("price"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if p <= 0:
|
||||
continue
|
||||
y_sl = pad_t + int((hi - p) / (hi - lo) * plot_h)
|
||||
color = tuple(pl.get("color") or (255, 152, 0))
|
||||
label = str(pl.get("label") or "止损")
|
||||
for xx in range(pad_l, x_right, 10):
|
||||
draw.line((xx, y_sl, min(xx + 6, x_right), y_sl), fill=color, width=2)
|
||||
if font:
|
||||
draw.text((x_right - 72, y_sl - 18), label, fill=color, font=small or font)
|
||||
else:
|
||||
draw.text((x_right - 72, y_sl - 18), label, fill=color)
|
||||
|
||||
if len(marker_points or []) >= 2:
|
||||
try:
|
||||
entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None)
|
||||
exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None)
|
||||
if entry is not None and exitp is not None:
|
||||
ex_i, ex_p = int(entry["idx"]), float(entry["price"])
|
||||
xx_i, xx_p = int(exitp["idx"]), float(exitp["price"])
|
||||
x_ex = pad_l + int((ex_i + 0.5) * plot_w / n)
|
||||
x_xx = pad_l + int((xx_i + 0.5) * plot_w / n)
|
||||
y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h)
|
||||
y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h)
|
||||
draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if small:
|
||||
draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small)
|
||||
return img
|
||||
|
||||
|
||||
def compose_chart_panels(panels, layout="grid", cell_w=980, cell_h=520, gap=10):
|
||||
if not panels or not Image:
|
||||
return None
|
||||
if layout == "vertical":
|
||||
cols = 1
|
||||
rows_n = len(panels)
|
||||
else:
|
||||
cols = 2
|
||||
rows_n = int(math.ceil(len(panels) / cols))
|
||||
w = cols * cell_w + (cols - 1) * gap
|
||||
h = rows_n * cell_h + (rows_n - 1) * gap
|
||||
out = Image.new("RGB", (w, h), (255, 255, 255))
|
||||
idx = 0
|
||||
for r in range(rows_n):
|
||||
for c in range(cols):
|
||||
if idx >= len(panels):
|
||||
break
|
||||
x = c * (cell_w + gap)
|
||||
y = r * (cell_h + gap)
|
||||
out.paste(panels[idx], (x, y))
|
||||
idx += 1
|
||||
|
||||
if ImageDraw and layout != "vertical" and rows_n >= 1:
|
||||
draw_out = ImageDraw.Draw(out)
|
||||
line_col = (220, 225, 232)
|
||||
x_mid = cell_w + gap // 2
|
||||
if w > x_mid >= 0:
|
||||
draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2)
|
||||
for rr in range(1, rows_n):
|
||||
y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
|
||||
if 0 <= y_mid <= h:
|
||||
draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
|
||||
elif ImageDraw and layout == "vertical" and rows_n >= 2:
|
||||
draw_out = ImageDraw.Draw(out)
|
||||
line_col = (220, 225, 232)
|
||||
for rr in range(1, rows_n):
|
||||
y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
|
||||
if 0 <= y_mid <= h:
|
||||
draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
|
||||
return out
|
||||
@@ -0,0 +1,54 @@
|
||||
"""复盘表单:下单类型与开仓类型校验(三所共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
from lib.strategy.strategy_trade_labels import (
|
||||
JOURNAL_ORDER_TYPE_OPTIONS,
|
||||
STRATEGY_ENTRY_REASON_OPTIONS,
|
||||
normalize_journal_order_type,
|
||||
)
|
||||
from lib.trade.entry_model_lib import (
|
||||
TRADE_STYLE_FALLBACK_ENTRY_REASONS,
|
||||
normalize_review_entry_reason,
|
||||
)
|
||||
|
||||
_LEGACY_JOURNAL_ENTRY_REASONS: Tuple[str, ...] = (
|
||||
*TRADE_STYLE_FALLBACK_ENTRY_REASONS,
|
||||
*STRATEGY_ENTRY_REASON_OPTIONS,
|
||||
)
|
||||
|
||||
|
||||
def normalize_journal_entry_reason(
|
||||
raw: Optional[str],
|
||||
allowed: Sequence[str],
|
||||
*,
|
||||
allow_legacy: bool = False,
|
||||
) -> str:
|
||||
s = normalize_review_entry_reason(raw, allowed)
|
||||
if s:
|
||||
return s
|
||||
if not allow_legacy:
|
||||
return ""
|
||||
legacy = (raw or "").strip()
|
||||
if legacy in _LEGACY_JOURNAL_ENTRY_REASONS:
|
||||
return legacy
|
||||
return ""
|
||||
|
||||
|
||||
def journal_entry_reason_valid(raw: Optional[str], allowed: Sequence[str]) -> bool:
|
||||
return bool(normalize_journal_entry_reason(raw, allowed, allow_legacy=False))
|
||||
|
||||
|
||||
def journal_order_type_valid(raw: Optional[str]) -> bool:
|
||||
return bool(normalize_journal_order_type(raw))
|
||||
|
||||
|
||||
def normalize_journal_direction(raw: Optional[str]) -> str:
|
||||
s = (raw or "").strip().lower()
|
||||
if s in ("long", "buy", "多", "做多"):
|
||||
return "long"
|
||||
if s in ("short", "sell", "空", "做空"):
|
||||
return "short"
|
||||
# 兼容旧隐藏字段 direction_hint
|
||||
return ""
|
||||
@@ -0,0 +1,208 @@
|
||||
"""复盘记录:多周期截图上传,存储与读取(三所共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h")
|
||||
JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"})
|
||||
_JOURNAL_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$")
|
||||
_JOURNAL_SLOT_FILE_RE = re.compile(
|
||||
r"^journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def journal_upload_field_name(tf: str) -> str:
|
||||
return f"screenshot_{tf}"
|
||||
|
||||
|
||||
def uploaded_screenshot_field_name(tf: str) -> str:
|
||||
return f"uploaded_screenshot_{tf}"
|
||||
|
||||
|
||||
def normalize_journal_draft_id(raw: Any) -> Optional[str]:
|
||||
s = str(raw or "").strip().lower()
|
||||
if _JOURNAL_DRAFT_ID_RE.match(s):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _safe_ext(filename: str) -> str:
|
||||
ext = os.path.splitext(str(filename or ""))[1].lower()
|
||||
return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png"
|
||||
|
||||
|
||||
def build_journal_slot_filename(
|
||||
entry_id: str,
|
||||
tf: str,
|
||||
ext: str,
|
||||
*,
|
||||
secure_filename_fn: Callable[[str], str],
|
||||
) -> str:
|
||||
ext = ext if ext.startswith(".") else f".{ext}"
|
||||
ext = _safe_ext(f"x{ext}")
|
||||
fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}")
|
||||
return fname or ""
|
||||
|
||||
|
||||
def is_valid_preuploaded_journal_file(filename: str, entry_id: str, tf: str) -> bool:
|
||||
fn = os.path.basename(str(filename or "").strip())
|
||||
if not fn or fn != str(filename or "").strip():
|
||||
return False
|
||||
m = _JOURNAL_SLOT_FILE_RE.match(fn)
|
||||
if not m:
|
||||
return False
|
||||
return m.group(1) == entry_id.lower() and m.group(2) == tf
|
||||
|
||||
|
||||
def save_journal_slot_file(
|
||||
file,
|
||||
entry_id: str,
|
||||
tf: str,
|
||||
upload_folder: str,
|
||||
*,
|
||||
secure_filename_fn: Callable[[str], str],
|
||||
) -> Optional[Dict[str, str]]:
|
||||
if tf not in JOURNAL_UPLOAD_TFS or not entry_id or not upload_folder:
|
||||
return None
|
||||
if not file or not getattr(file, "filename", None):
|
||||
return None
|
||||
ext = _safe_ext(file.filename)
|
||||
fname = build_journal_slot_filename(
|
||||
entry_id, tf, ext, secure_filename_fn=secure_filename_fn
|
||||
)
|
||||
if not fname:
|
||||
return None
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
path = os.path.join(upload_folder, fname)
|
||||
file.save(path)
|
||||
return {"tf": tf, "file": fname}
|
||||
|
||||
|
||||
def collect_journal_slot_images(
|
||||
form,
|
||||
files,
|
||||
entry_id: str,
|
||||
upload_folder: str,
|
||||
*,
|
||||
secure_filename_fn: Callable[[str], str],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""优先使用即时上传 hidden 字段;否则回退到表单 multipart."""
|
||||
saved: List[Dict[str, str]] = []
|
||||
if not entry_id or not upload_folder:
|
||||
return saved
|
||||
for tf in JOURNAL_UPLOAD_TFS:
|
||||
pre = ""
|
||||
if form is not None:
|
||||
pre = str(form.get(uploaded_screenshot_field_name(tf)) or "").strip()
|
||||
if pre and is_valid_preuploaded_journal_file(pre, entry_id, tf):
|
||||
path = os.path.join(upload_folder, os.path.basename(pre))
|
||||
if os.path.isfile(path):
|
||||
saved.append({"tf": tf, "file": os.path.basename(pre)})
|
||||
continue
|
||||
f = files.get(journal_upload_field_name(tf)) if files else None
|
||||
item = save_journal_slot_file(
|
||||
f,
|
||||
entry_id,
|
||||
tf,
|
||||
upload_folder,
|
||||
secure_filename_fn=secure_filename_fn,
|
||||
)
|
||||
if item:
|
||||
saved.append(item)
|
||||
return saved
|
||||
|
||||
|
||||
def save_journal_slot_uploads(
|
||||
files,
|
||||
entry_id: str,
|
||||
upload_folder: str,
|
||||
*,
|
||||
secure_filename_fn: Callable[[str], str],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...]."""
|
||||
return collect_journal_slot_images(
|
||||
None,
|
||||
files,
|
||||
entry_id,
|
||||
upload_folder,
|
||||
secure_filename_fn=secure_filename_fn,
|
||||
)
|
||||
|
||||
|
||||
def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
|
||||
if not items:
|
||||
return None
|
||||
return json.dumps(list(items), ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def parse_images_json(raw: Any) -> List[Dict[str, str]]:
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
data = raw
|
||||
else:
|
||||
try:
|
||||
data = json.loads(str(raw))
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out: List[Dict[str, str]] = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tf = str(item.get("tf") or "").strip()
|
||||
file = str(item.get("file") or "").strip()
|
||||
if file:
|
||||
out.append({"tf": tf, "file": file})
|
||||
return out
|
||||
|
||||
|
||||
def primary_journal_image(
|
||||
manual_images: Sequence[Mapping[str, str]],
|
||||
*,
|
||||
fallback: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if manual_images:
|
||||
return str(manual_images[0].get("file") or "").strip() or None
|
||||
return fallback
|
||||
|
||||
|
||||
def enrich_journal_api_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""API 输出:解析 images_json,兼容旧单图 image 字段."""
|
||||
images = parse_images_json(item.get("images_json"))
|
||||
if not images and item.get("image"):
|
||||
images = [{"tf": "", "file": str(item["image"]).strip()}]
|
||||
item["images"] = images
|
||||
return item
|
||||
|
||||
|
||||
def journal_image_paths(row: Any, upload_folder: str) -> List[str]:
|
||||
"""删除 / AI 附图:收集本条复盘所有本地图片路径(去重)."""
|
||||
upload_folder = os.path.abspath(upload_folder or "")
|
||||
paths: List[str] = []
|
||||
seen = set()
|
||||
|
||||
def _add(name: Optional[str]) -> None:
|
||||
if not name:
|
||||
return
|
||||
p = os.path.abspath(os.path.join(upload_folder, str(name).strip()))
|
||||
if os.path.isfile(p) and p not in seen:
|
||||
seen.add(p)
|
||||
paths.append(p)
|
||||
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else ()
|
||||
except Exception:
|
||||
keys = ()
|
||||
|
||||
if "images_json" in keys and row["images_json"]:
|
||||
for img in parse_images_json(row["images_json"]):
|
||||
_add(img.get("file"))
|
||||
if "image" in keys:
|
||||
_add(row["image"])
|
||||
return paths
|
||||
@@ -0,0 +1,43 @@
|
||||
"""复盘截图即时上传 API(三所共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Tuple
|
||||
|
||||
from lib.instance.journal_images_lib import (
|
||||
JOURNAL_UPLOAD_TFS,
|
||||
normalize_journal_draft_id,
|
||||
save_journal_slot_file,
|
||||
)
|
||||
|
||||
|
||||
def handle_journal_upload_slot(
|
||||
request: Any,
|
||||
*,
|
||||
upload_folder: str,
|
||||
secure_filename_fn: Callable[[str], str],
|
||||
) -> Tuple[Dict[str, Any], int]:
|
||||
"""POST multipart: journal_draft_id, tf, file → {ok, file}."""
|
||||
draft_id = normalize_journal_draft_id(
|
||||
request.form.get("journal_draft_id") if request.form else None
|
||||
)
|
||||
tf = str((request.form.get("tf") if request.form else None) or "").strip()
|
||||
if not draft_id:
|
||||
return {"ok": False, "error": "invalid draft_id"}, 400
|
||||
if tf not in JOURNAL_UPLOAD_TFS:
|
||||
return {"ok": False, "error": "invalid tf"}, 400
|
||||
|
||||
f = request.files.get("file") if request.files else None
|
||||
if not f or not getattr(f, "filename", None):
|
||||
return {"ok": False, "error": "no file"}, 400
|
||||
|
||||
item = save_journal_slot_file(
|
||||
f,
|
||||
draft_id,
|
||||
tf,
|
||||
upload_folder,
|
||||
secure_filename_fn=secure_filename_fn,
|
||||
)
|
||||
if not item:
|
||||
return {"ok": False, "error": "save failed"}, 500
|
||||
|
||||
return {"ok": True, "tf": tf, "file": item["file"]}, 200
|
||||
@@ -0,0 +1,54 @@
|
||||
"""注册 /api/trade_records(三所共用)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
|
||||
def register_trade_records_api(
|
||||
app: Flask,
|
||||
*,
|
||||
login_required: Callable,
|
||||
get_db: Callable,
|
||||
list_window_from_request: Callable[[], dict[str, Any]],
|
||||
utc_window_to_bj_sql_strings: Callable[..., tuple[str, str]],
|
||||
sql_list_time_field: Callable[..., str],
|
||||
to_effective_trade_dict: Callable[[Any], dict[str, Any]],
|
||||
filter_trade_records_excluding_miss: Callable[[list], list],
|
||||
app_tz: Any,
|
||||
) -> None:
|
||||
from lib.instance.records_list_lib import list_trade_records_page
|
||||
|
||||
@app.route("/api/trade_records")
|
||||
@login_required
|
||||
def api_trade_records():
|
||||
win = list_window_from_request()
|
||||
start_bj, end_bj = utc_window_to_bj_sql_strings(
|
||||
win["start_utc"], win["end_utc"], app_tz
|
||||
)
|
||||
tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at")
|
||||
try:
|
||||
limit = int(request.args.get("limit") or 5)
|
||||
except (TypeError, ValueError):
|
||||
limit = 5
|
||||
try:
|
||||
offset = int(request.args.get("offset") or 0)
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
conn = get_db()
|
||||
try:
|
||||
payload = list_trade_records_page(
|
||||
conn,
|
||||
start_bj,
|
||||
end_bj,
|
||||
tr_ts=tr_ts,
|
||||
to_effective_fn=to_effective_trade_dict,
|
||||
filter_fn=filter_trade_records_excluding_miss,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return jsonify(payload)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""交易记录列表分页(三所 /records 共用)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def list_trade_records_page(
|
||||
conn: Any,
|
||||
start_bj: str,
|
||||
end_bj: str,
|
||||
*,
|
||||
tr_ts: str,
|
||||
to_effective_fn: Callable[[Any], dict[str, Any]],
|
||||
filter_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]],
|
||||
limit: int = 5,
|
||||
offset: int = 0,
|
||||
fetch_cap: int = 1000,
|
||||
) -> dict[str, Any]:
|
||||
"""按列表窗拉取、enrich、过滤「错过」后分页."""
|
||||
limit = max(1, min(100, int(limit or 5)))
|
||||
offset = max(0, int(offset or 0))
|
||||
raw_records = conn.execute(
|
||||
f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? "
|
||||
f"ORDER BY id DESC LIMIT ?",
|
||||
(start_bj, end_bj, int(fetch_cap)),
|
||||
).fetchall()
|
||||
records = filter_fn([to_effective_fn(r) for r in raw_records])
|
||||
total = len(records)
|
||||
pages = max(1, (total + limit - 1) // limit) if total else 1
|
||||
page = (offset // limit) + 1 if limit else 1
|
||||
if page > pages:
|
||||
page = pages
|
||||
offset = (page - 1) * limit
|
||||
items = records[offset : offset + limit]
|
||||
return {
|
||||
"ok": True,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""env 运行时覆盖:热生效项优先读 SQLite,再回退 os.environ."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
from lib.env.env_file_lib import load_env_file_into_environ
|
||||
from lib.instance.runtime_settings_lib import runtime_get, with_db
|
||||
|
||||
ENV_OVERRIDE_PREFIX = "env."
|
||||
|
||||
|
||||
def runtime_env_key(name: str) -> str:
|
||||
return ENV_OVERRIDE_PREFIX + name
|
||||
|
||||
|
||||
def get_config(key: str, get_db: Callable, default: Optional[str] = None) -> Optional[str]:
|
||||
def _read(conn):
|
||||
v = runtime_get(conn, runtime_env_key(key))
|
||||
return v
|
||||
|
||||
try:
|
||||
v = with_db(get_db, _read)
|
||||
if v is not None:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
raw = os.getenv(key)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return raw
|
||||
|
||||
|
||||
def set_config_overrides(get_db: Callable, mapping: dict[str, str]) -> None:
|
||||
from lib.instance.runtime_settings_lib import runtime_set_many
|
||||
|
||||
def _write(conn):
|
||||
payload = {runtime_env_key(k): str(v) for k, v in mapping.items()}
|
||||
runtime_set_many(conn, payload)
|
||||
|
||||
with_db(get_db, _write)
|
||||
|
||||
|
||||
def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], groups: list[dict]) -> dict[str, bool]:
|
||||
"""写盘后同步 os.environ,并将可热生效项写入 runtime 覆盖."""
|
||||
load_env_file_into_environ(env_path)
|
||||
hot: dict[str, str] = {}
|
||||
field_map = {}
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
field_map[field["key"]] = field
|
||||
for key in changed_keys:
|
||||
meta = field_map.get(key) or {}
|
||||
if meta.get("hot_reload") and not meta.get("restart_required"):
|
||||
val = os.getenv(key)
|
||||
if val is not None:
|
||||
hot[key] = val
|
||||
if hot:
|
||||
set_config_overrides(get_db, hot)
|
||||
from lib.env.env_schema import updates_need_restart
|
||||
|
||||
return {"restart_required": updates_need_restart(groups, changed_keys)}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""实例 SQLite 运行时配置(导航开关,env 热覆盖等)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
RUNTIME_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS app_runtime_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def ensure_runtime_settings_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(RUNTIME_TABLE_SQL)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def runtime_get(conn: sqlite3.Connection, key: str) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_runtime_settings WHERE key=?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
val = row["value"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
return None if val is None else str(val)
|
||||
|
||||
|
||||
def runtime_set(conn: sqlite3.Connection, key: str, value: str) -> None:
|
||||
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||||
(key, value, now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def runtime_get_prefix(conn: sqlite3.Connection, prefix: str) -> dict[str, str]:
|
||||
rows = conn.execute(
|
||||
"SELECT key, value FROM app_runtime_settings WHERE key LIKE ?",
|
||||
(prefix + "%",),
|
||||
).fetchall()
|
||||
out: dict[str, str] = {}
|
||||
for row in rows:
|
||||
k = row["key"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
v = row["value"] if isinstance(row, sqlite3.Row) else row[1]
|
||||
if k.startswith(prefix):
|
||||
out[k[len(prefix) :]] = v if v is not None else ""
|
||||
return out
|
||||
|
||||
|
||||
def runtime_set_many(conn: sqlite3.Connection, mapping: dict[str, str]) -> None:
|
||||
for key, value in mapping.items():
|
||||
runtime_set(conn, key, value)
|
||||
|
||||
|
||||
def with_db(
|
||||
get_db: Callable[[], sqlite3.Connection],
|
||||
fn: Callable[[sqlite3.Connection], Any],
|
||||
) -> Any:
|
||||
conn = get_db()
|
||||
try:
|
||||
ensure_runtime_settings_table(conn)
|
||||
return fn(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,15 @@
|
||||
{# 实例数据看板:只读活跃监控总览 #}
|
||||
<div class="card full inst-dash-card" id="instance-dashboard" data-inst-dashboard="1">
|
||||
<div class="inst-dash-head">
|
||||
<div>
|
||||
<h2 style="margin-bottom:4px">数据看板</h2>
|
||||
<p class="muted inst-dash-desc">本户活跃监控总览 · 只读 · 无数据的区块不显示 · 有数据按表格展示</p>
|
||||
</div>
|
||||
<div class="inst-dash-head-actions">
|
||||
<span class="muted inst-dash-updated" id="inst-dash-updated">—</span>
|
||||
<button type="button" class="btn-sm" id="inst-dash-refresh">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="inst-dash-status muted" id="inst-dash-status"></p>
|
||||
<div class="inst-dash-sections" id="inst-dash-sections"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
{# 系统设置 · 导航显示开关(SSR 预渲染,保存仍走 API) #}
|
||||
<div class="settings-tab-inner" id="display-prefs-card">
|
||||
<h2>导航显示</h2>
|
||||
<p class="settings-env-hint">以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效.关键位监控,实盘下单,系统设置为固定项.</p>
|
||||
<div id="display-prefs-form" class="display-prefs-form" data-prefs-ssr="1">
|
||||
{% if display_meta %}
|
||||
{% for group in display_meta %}
|
||||
<div class="display-prefs-group">
|
||||
<h3 class="settings-subcard-title">{{ group.group }}</h3>
|
||||
<div class="display-prefs-checks">
|
||||
{% for item in group.entries %}
|
||||
<label class="chk-label">
|
||||
<input type="checkbox" data-pref-key="{{ item.key }}"{% if display.get(item.key, true) %} checked{% endif %}>
|
||||
{{ item.label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="display-prefs-loading muted">加载中…</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="settings-actions-row">
|
||||
<button type="button" class="btn-primary btn-sm" id="display-prefs-save">保存导航设置</button>
|
||||
<span class="settings-status-line" id="display-prefs-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
{# Hub iframe tab fragment — shared via embed_templates #}
|
||||
{% macro period_stats_pane(period_key, s) %}
|
||||
{% set win_pct = s.win_rate_pct if s.win_rate_pct is not none else 0 %}
|
||||
{% set profit_sum = (s.net_pnl_u + s.loss_sum_u) if s.closed_count else 0 %}
|
||||
{% set loss_sum = s.loss_sum_u %}
|
||||
{% set pnl_total = profit_sum + loss_sum %}
|
||||
{% set profit_bar_w = (profit_sum / pnl_total * 100) if pnl_total > 0 else 0 %}
|
||||
{% set loss_bar_w = (loss_sum / pnl_total * 100) if pnl_total > 0 else 0 %}
|
||||
{% set net_cls = 'pos-pnl-profit' if s.net_pnl_u > 0 else ('pos-pnl-loss' if s.net_pnl_u < 0 else '') %}
|
||||
<div class="stats-period-pane" data-stats-period="{{ period_key }}" role="tabpanel"{% if period_key != 'day' %} hidden{% endif %}>
|
||||
<div class="stats-period-range">{{ s.range_label }}</div>
|
||||
<div class="inst-stats-viz">
|
||||
{% if s.closed_count %}
|
||||
<div class="inst-stats-kpis">
|
||||
<div class="inst-stats-kpi inst-stats-kpi--pnl">
|
||||
<span class="inst-stats-kpi-val {{ net_cls }}">{% if s.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(s.net_pnl_u) }}U</span>
|
||||
<span class="inst-stats-kpi-lbl">净盈亏</span>
|
||||
</div>
|
||||
<div class="inst-stats-kpi inst-stats-kpi--win">
|
||||
<div class="inst-stats-ring" style="--win-pct: {{ win_pct }}">
|
||||
<span class="inst-stats-ring-label">{% if s.win_rate_pct is not none %}{{ win_pct|round(0)|int }}%{% else %}—{% endif %}</span>
|
||||
</div>
|
||||
<span class="inst-stats-kpi-lbl">{{ s.win_count }}胜 {{ s.loss_count }}负</span>
|
||||
</div>
|
||||
<div class="inst-stats-kpi inst-stats-kpi--trades">
|
||||
<span class="inst-stats-kpi-val">{{ s.opens_count }} / {{ s.closed_count }}</span>
|
||||
<span class="inst-stats-kpi-lbl">开单 / 平仓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inst-stats-block">
|
||||
<div class="inst-stats-block-title">盈亏构成</div>
|
||||
<div class="inst-stats-stacked-bar">
|
||||
<div class="inst-stats-stacked-fill inst-stats-stacked-fill--profit" style="width: {{ '%.1f'|format(profit_bar_w) }}%"></div>
|
||||
<div class="inst-stats-stacked-fill inst-stats-stacked-fill--loss" style="width: {{ '%.1f'|format(loss_bar_w) }}%"></div>
|
||||
</div>
|
||||
<div class="inst-stats-bar-labels">
|
||||
<span class="pos-pnl-profit">盈利 {{ funds_fmt(profit_sum) }}U</span>
|
||||
<span class="pos-pnl-loss">亏损 {{ funds_fmt(loss_sum) }}U</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inst-stats-block inst-stats-block--risk">
|
||||
<div class="inst-stats-risk-grid">
|
||||
<div class="inst-stats-risk-item">
|
||||
<span class="k">最大回撤</span>
|
||||
<span class="v pos-pnl-loss">{{ funds_fmt(s.max_drawdown_u) }}U</span>
|
||||
</div>
|
||||
<div class="inst-stats-risk-item">
|
||||
<span class="k">连续亏损</span>
|
||||
<span class="v">{{ s.consecutive_losses }} 笔</span>
|
||||
</div>
|
||||
<div class="inst-stats-risk-item">
|
||||
<span class="k">最长连亏日</span>
|
||||
<span class="v">{{ s.max_loss_streak_days }} 天</span>
|
||||
</div>
|
||||
<div class="inst-stats-risk-item">
|
||||
<span class="k">最大亏损日</span>
|
||||
<span class="v">{% if s.worst_day %}{{ s.worst_day }} ({{ funds_fmt(s.worst_day_pnl) }}U){% else %}—{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="inst-stats-empty">当前区间暂无平仓数据</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<details class="inst-stats-details" open>
|
||||
<summary>详细指标</summary>
|
||||
<div class="stats-detail">
|
||||
<div class="stat-item"><div class="label">开单次数</div><div class="value">{{ s.opens_count }}</div></div>
|
||||
<div class="stat-item"><div class="label">平仓笔数</div><div class="value">{{ s.closed_count }}</div></div>
|
||||
<div class="stat-item"><div class="label">胜率</div><div class="value">{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}</div></div>
|
||||
<div class="stat-item"><div class="label">净盈亏(U)</div><div class="value">{{ funds_fmt(s.net_pnl_u) }}</div></div>
|
||||
<div class="stat-item"><div class="label">亏损额合计(U)</div><div class="value">{{ funds_fmt(s.loss_sum_u) }}</div></div>
|
||||
<div class="stat-item"><div class="label">单笔最大亏损(U)</div><div class="value">{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}</div></div>
|
||||
<div class="stat-item"><div class="label">单笔最大盈利(U)</div><div class="value">{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}</div></div>
|
||||
<div class="stat-item"><div class="label">最大回撤(U)</div><div class="value">{{ funds_fmt(s.max_drawdown_u) }}</div></div>
|
||||
<div class="stat-item"><div class="label">当前连续亏损笔数</div><div class="value">{{ s.consecutive_losses }}</div></div>
|
||||
<div class="stat-item"><div class="label">最长连续亏损(交易日)</div><div class="value">{{ s.max_loss_streak_days }} 天</div></div>
|
||||
<div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}</div></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
<div class="grid">
|
||||
{% if page == 'dashboard' %}
|
||||
{% include 'dashboard_panel.html' %}
|
||||
{% elif page == 'key_monitor' %}
|
||||
{% include 'key_monitor_panel.html' %}
|
||||
{% elif page == 'trade' %}
|
||||
<div class="dual-panel-grid" style="grid-column:1/-1">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||
<h2 style="margin-bottom:0">实盘下单监控</h2>
|
||||
{% if focus_order_id %}
|
||||
<a href="/order_focus?order_id={{ focus_order_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
|
||||
{% else %}
|
||||
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include order_rule_tips_tpl %}
|
||||
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
|
||||
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
|
||||
{{ trade_policy_symbol('symbol', 'order-symbol') }}
|
||||
{{ trade_policy_direction('direction', 'order-direction') }}
|
||||
<select id="sltp-mode" name="sltp_mode">
|
||||
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
|
||||
<option value="price">止盈止损:价格模式</option>
|
||||
<option value="pct">止盈止损:百分比模式</option>
|
||||
</select>
|
||||
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
|
||||
{{ order_entry_type_fields() }}
|
||||
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
|
||||
{{ order_leverage_fields() }}
|
||||
{% if not intraday_discipline %}
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
|
||||
</label>
|
||||
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
|
||||
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
|
||||
</label>
|
||||
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
|
||||
<option value="1">1h</option>
|
||||
<option value="2">2h</option>
|
||||
<option value="4" selected>4h</option>
|
||||
</select>
|
||||
</span>
|
||||
{% else %}
|
||||
<input type="hidden" name="breakeven_enabled" value="0">
|
||||
{% endif %}
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
|
||||
</label>
|
||||
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
|
||||
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
|
||||
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
|
||||
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
|
||||
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
|
||||
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
|
||||
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
|
||||
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||
<button type="submit">{{ open_position_button_label }}</button>
|
||||
</form>
|
||||
{% include 'order_plan_preview_bar.html' %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||
{% if ui_orphan_recovery_enabled %}
|
||||
{% if not order and orphan_live_positions %}
|
||||
{% set o = orphan_live_positions[0] %}
|
||||
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:block;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8">
|
||||
检测到交易所仍有 <strong>{{ o.symbol }}</strong> {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录).
|
||||
{% if o.recoverable_monitor_id %}
|
||||
<button type="button" class="pos-entrust-btn" onclick="recoverLivePosition({{ o.recoverable_monitor_id }})">恢复监控{% if o.plan_stop_loss and o.plan_take_profit %}并挂止盈止损{% endif %}</button>
|
||||
{% else %}
|
||||
未找到可恢复的监控记录,需在服务器数据库处理.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:none;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8"></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="panel-scroll pos-list pos-list-live">
|
||||
{% for o in order %}
|
||||
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||
data-monitor-id="{{ o.id }}"
|
||||
data-symbol="{{ o.symbol }}"
|
||||
data-direction="{{ o.direction }}"
|
||||
data-plan-sl="{% if o.stop_loss %}{{ price_fmt(o.symbol, o.stop_loss) }}{% endif %}"
|
||||
data-plan-tp="{% if o.take_profit %}{{ price_fmt(o.symbol, o.take_profit) }}{% endif %}"
|
||||
data-entry="{% if o.trigger_price %}{{ price_fmt(o.symbol, o.trigger_price) }}{% endif %}">
|
||||
<div class="pos-card-head">
|
||||
<div class="pos-card-symbol">
|
||||
<strong>{{ o.exchange_symbol or o.symbol }}</strong>
|
||||
{% if o.time_close_enabled %}
|
||||
<span class="pos-symbol-time-close pos-meta-on pos-time-close-meta" id="order-time-close-wrap-{{ o.id }}"
|
||||
data-close-at-ms="{{ o.time_close_at_ms or '' }}">
|
||||
<span class="pos-time-close-label">时间平仓 {{ o.time_close_hours or '' }}h</span>
|
||||
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_order_badge.html' %}
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
|
||||
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pos-meta">
|
||||
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||
<span class="pos-meta-item">{% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %}</span>
|
||||
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
|
||||
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
|
||||
<span class="pos-meta-item {% if not intraday_discipline %}{% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}{% endif %}">
|
||||
{% if intraday_discipline %}
|
||||
{% elif o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
|
||||
</span>
|
||||
<span class="pos-meta-item" id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
|
||||
</div>
|
||||
<div class="pos-grid">
|
||||
<div class="pos-cell">
|
||||
<span class="pos-label">成交价</span>
|
||||
<span class="pos-value">{{ price_fmt(o.symbol, o.trigger_price) }}</span>
|
||||
</div>
|
||||
<div class="pos-cell">
|
||||
<span class="pos-label">止损</span>
|
||||
<span class="pos-value" id="order-plan-sl-{{ o.id }}">{{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }}</span>
|
||||
</div>
|
||||
<div class="pos-cell">
|
||||
<span class="pos-label">止盈</span>
|
||||
<span class="pos-value" id="order-plan-tp-{{ o.id }}">{{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }}</span>
|
||||
</div>
|
||||
<div class="pos-cell">
|
||||
<span class="pos-label">盈亏比</span>
|
||||
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
|
||||
</div>
|
||||
<div class="pos-cell">
|
||||
<span class="pos-label">张数</span>
|
||||
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %}</span>
|
||||
</div>
|
||||
<div class="pos-cell">
|
||||
<span class="pos-label">盈利金额</span>
|
||||
<span class="pos-value pos-tp-profit" id="order-tp-profit-{{ o.id }}">—</span>
|
||||
</div>
|
||||
<div class="pos-cell">
|
||||
<span class="pos-label">标记价</span>
|
||||
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
|
||||
</div>
|
||||
<div class="pos-cell">
|
||||
<span class="pos-label">浮盈亏</span>
|
||||
<span class="pos-value" id="order-pnl-{{ o.id }}">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pos-footer">
|
||||
<span>保证金: <span id="order-ex-margin-{{ o.id }}">-</span></span>
|
||||
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
|
||||
<span>杠杆: {{ o.leverage or '-' }}x</span>
|
||||
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
|
||||
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
|
||||
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}">—</span></span>
|
||||
</div>
|
||||
<div class="pos-ex-orders">
|
||||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||||
<div class="pos-ex-order-row">
|
||||
<span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span>
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pos-ex-order-row">
|
||||
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
|
||||
{% if not intraday_discipline %}
|
||||
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pos-empty">暂无持仓</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tpsl-modal" class="tpsl-modal-backdrop" onclick="if(event.target===this)closeTpslEntrustModal()">
|
||||
<div class="tpsl-modal" onclick="event.stopPropagation()">
|
||||
<h3 id="tpsl-modal-title">挂止盈止损</h3>
|
||||
<p style="font-size:.78rem;color:#8892b0;margin:0 0 10px">将先撤销该合约已有 TP/SL,再按下列价格重挂.</p>
|
||||
<div class="form-row">
|
||||
<select id="tpsl-modal-mode" onchange="toggleTpslModalMode()">
|
||||
<option value="price">价格模式</option>
|
||||
<option value="pct">百分比模式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input id="tpsl-modal-sl" step="any" placeholder="止损价格">
|
||||
<input id="tpsl-modal-tp" step="any" placeholder="止盈价格">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input id="tpsl-modal-sl-pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
|
||||
<input id="tpsl-modal-tp-pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
|
||||
</div>
|
||||
<div class="tpsl-modal-actions">
|
||||
<button type="button" class="tpsl-modal-cancel" onclick="closeTpslEntrustModal()">取消</button>
|
||||
<button type="button" class="tpsl-modal-submit" onclick="submitTpslEntrust()">先撤后挂</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
|
||||
{% include 'strategy_trading_page.html' %}
|
||||
{% elif page == 'strategy_records' %}
|
||||
{% include 'strategy_records_page.html' %}
|
||||
{% elif page == 'options' %}
|
||||
{% include 'options_panel.html' %}
|
||||
{% elif page == 'options_review' %}
|
||||
{% include 'options_review_panel.html' %}
|
||||
{% elif page == 'hedge_plan' %}
|
||||
{% include 'hedge_plan_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
{% if page == 'records' %}
|
||||
{% include 'records_panel.html' %}
|
||||
{% endif %}
|
||||
{% if page == 'env_config' %}
|
||||
{% include 'env_config_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'risk_policy' %}
|
||||
{% include 'risk_policy_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'settings' %}
|
||||
{% include 'settings_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'stats' %}
|
||||
<div class="card stats-card full" id="stats-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
|
||||
<h2 style="margin-bottom:0">数据统计</h2>
|
||||
<button type="button" class="stats-toggle" id="stats-toggle-btn" onclick="toggleStatsCard()">折叠</button>
|
||||
</div>
|
||||
<div class="stats-content" id="stats-content">
|
||||
<div class="sub" style="margin-bottom:12px;color:#8892b0;font-size:.82rem">
|
||||
统计分析按<strong>北京时间 {{ stats_bundle.stats_reset_hour }}:00</strong>切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计):
|
||||
<strong style="color:#cfd3ef">{{ stats_bundle.total_opens_all }}</strong> 次
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:14px;align-items:center">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;color:#cfd3ef">
|
||||
统计品类
|
||||
<select id="stats-segment-select" onchange="switchStatsSegment()" style="min-width:200px">
|
||||
{% for seg in stats_bundle.segments %}
|
||||
<option value="{{ seg.key }}">{{ seg.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{% for seg in stats_bundle.segments %}
|
||||
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
|
||||
<div class="stats-period-tabs" role="tablist" aria-label="统计周期">
|
||||
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
|
||||
</div>
|
||||
{{ period_stats_pane("day", seg.day) }}
|
||||
{{ period_stats_pane("week", seg.week) }}
|
||||
{{ period_stats_pane("month", seg.month) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<script src="/static/instance_theme.js?v=50"></script>
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=9">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
|
||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<title>{{ pwa_app_name }}</title>
|
||||
</head>
|
||||
<body
|
||||
data-embed-shell="1"
|
||||
data-intraday-discipline="{% if intraday_discipline %}1{% else %}0{% endif %}"
|
||||
data-risk-percent="{{ risk_percent }}"
|
||||
data-page="{{ initial_tab }}"
|
||||
data-position-sizing-mode="{{ position_sizing_mode }}"
|
||||
data-btc-leverage="{{ btc_leverage }}"
|
||||
data-alt-leverage="{{ alt_leverage }}"
|
||||
data-full-margin-buffer="{{ full_margin_buffer_ratio }}"
|
||||
data-balance-refresh-ms="{{ balance_refresh_seconds * 1000 }}"
|
||||
data-price-refresh-ms="{{ price_refresh_seconds * 1000 }}"
|
||||
>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||
</div>
|
||||
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
||||
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}">实盘下单</a>
|
||||
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||
<a href="/strategy" data-embed-tab="strategy" class="{% if initial_tab == 'strategy' %}active{% endif %}">策略交易</a>
|
||||
{% endif %}
|
||||
{% if not intraday_discipline and display.show_nav_strategy_records %}
|
||||
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if initial_tab == 'strategy_records' %}active{% endif %}">策略交易记录</a>
|
||||
{% endif %}
|
||||
{% if display.show_nav_records %}
|
||||
<a href="/records" data-embed-tab="records" class="{% if initial_tab == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||
{% endif %}
|
||||
{% if display.show_nav_stats %}
|
||||
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}">统计分析</a>
|
||||
{% endif %}
|
||||
{% if options_nav_visible and display.show_nav_options %}
|
||||
<a href="/options" data-embed-tab="options" class="{% if initial_tab == 'options' %}active{% endif %}">期权</a>
|
||||
{% endif %}
|
||||
{% if options_nav_visible and display.show_nav_options_review %}
|
||||
<a href="/options/review" data-embed-tab="options_review" class="{% if initial_tab == 'options_review' %}active{% endif %}">期权复盘</a>
|
||||
{% endif %}
|
||||
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
||||
<a href="/hedge-plan" data-embed-tab="hedge_plan" class="{% if initial_tab == 'hedge_plan' %}active{% endif %}">对冲计划</a>
|
||||
{% endif %}
|
||||
{% if display.show_nav_risk_policy %}
|
||||
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
|
||||
{% endif %}
|
||||
{% if display.show_nav_env_config %}
|
||||
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
|
||||
{% endif %}
|
||||
<a href="/settings" data-embed-tab="settings" class="{% if initial_tab == 'settings' %}active{% endif %}">系统设置</a>
|
||||
</nav>
|
||||
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
||||
|
||||
{% include 'instance_header_panel.html' %}
|
||||
{% if initial_tab not in ('settings', 'risk_policy', 'env_config') and include_transfer_block %}
|
||||
{% include 'instance_top_bar.html' %}
|
||||
{% endif %}
|
||||
|
||||
<div id="embed-page-root">
|
||||
{% include 'embed_page_fragment.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="imgModal" onclick="closeModal()">
|
||||
<img id="bigImg" src="" alt="screenshot">
|
||||
</div>
|
||||
<div class="detail-modal" id="detailModal" onclick="closeDetailModal(event)">
|
||||
<div class="panel" onclick="event.stopPropagation()">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title" id="detailTitle">详情</div>
|
||||
<div class="panel-actions">
|
||||
<button type="button" class="panel-fs" onclick="expandDetailToFullscreen()">全屏</button>
|
||||
<button type="button" class="panel-close" onclick="forceCloseDetailModal()">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body" id="detailBody"></div>
|
||||
<div id="detailImages" class="journal-detail-images" style="display:none"></div>
|
||||
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/instance_ui.js?v=10"></script>
|
||||
<script src="/static/journal_upload_slots.js?v=3"></script>
|
||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=3"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||
<script>
|
||||
const ORDER_ENTRY_MODEL_TRADE_STYLE = {{ entry_model_trade_style_map | tojson }};
|
||||
const ORDER_ENTRY_MODEL_CATEGORIES = {{ entry_model_categories | tojson }};
|
||||
const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | tojson }};
|
||||
</script>
|
||||
<script src="/static/order_entry_model.js?v=5"></script>
|
||||
<script src="/static/manual_order_rr_preview.js?v=5"></script>
|
||||
<script src="/static/symbol_live_price.js?v=2"></script>
|
||||
<script src="/static/strategy_roll.js?v=6"></script>
|
||||
<script src="/static/key_monitor_form.js?v=2"></script>
|
||||
<script src="/static/instance_stats.js?v=4"></script>
|
||||
{% include 'embed_boot_scripts.html' %}
|
||||
<script src="/static/records_review_page.js?v=2"></script>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/instance_dashboard.js?v=3"></script>
|
||||
<script>
|
||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||
</script>
|
||||
<script src="/static/instance_settings_prefs.js?v=14"></script>
|
||||
<script src="/static/instance_live.js?v=6"></script>
|
||||
<script src="/static/instance_embed.js?v=25"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
{# env配置:CSS Tab(无需 JS)+ 双列表单 #}
|
||||
<div class="env-config-page full">
|
||||
<div class="env-config-head card">
|
||||
<div class="env-config-head-row">
|
||||
<div>
|
||||
<h2>env 配置</h2>
|
||||
<p class="settings-env-hint env-config-head-hint">按分类修改,改完点保存.含「需重启」的项请用「保存并重启」.<strong>AI 配置</strong>请在中控 → 系统设置 → AI 配置统一维护.</p>
|
||||
</div>
|
||||
<div class="env-config-toolbar">
|
||||
<button type="button" class="btn-primary btn-sm" id="env-config-save">保存</button>
|
||||
<button type="button" class="btn-secondary btn-sm" id="env-config-save-restart">保存并重启</button>
|
||||
<button type="button" class="btn-secondary btn-sm" id="env-config-reload">重新加载</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="settings-status-line" id="env-config-status"></span>
|
||||
</div>
|
||||
|
||||
{% if env_config_groups %}
|
||||
<div class="env-config-body card" data-env-ssr="1" id="env-config-body">
|
||||
{% for group in env_config_groups %}
|
||||
<input type="radio" name="env-section" id="env-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}>
|
||||
{% endfor %}
|
||||
<div class="env-config-tabs" role="tablist" aria-label="配置分类">
|
||||
{% for group in env_config_groups %}
|
||||
<label for="env-sec-{{ loop.index0 }}" class="env-tab-btn" role="tab">{{ group.title }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="env-config-panels" id="env-config-grid">
|
||||
{% for group in env_config_groups %}
|
||||
<section class="env-panel env-panel--{{ loop.index0 }}" role="tabpanel">
|
||||
{% if group.has_restart %}
|
||||
<p class="env-panel-hint">本组含需重启项,修改后请点「保存并重启」.</p>
|
||||
{% endif %}
|
||||
<div class="env-form-grid">
|
||||
{% for field in group.fields %}
|
||||
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}">
|
||||
<label class="env-field-label" for="env-f-{{ field.key }}">
|
||||
{{ field.label or field.key }}
|
||||
{% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %}
|
||||
</label>
|
||||
{% if field.note %}
|
||||
<div class="env-field-note muted">{{ field.note }}</div>
|
||||
{% endif %}
|
||||
{% if field.type == 'bool' %}
|
||||
<select class="env-field-input" id="env-f-{{ field.key }}" data-env-key="{{ field.key }}">
|
||||
{% set cur = (field.current or field.default or 'false')|lower %}
|
||||
<option value="true"{% if cur in ('true', '1', 'yes', 'on') %} selected{% endif %}>开启</option>
|
||||
<option value="false"{% if cur not in ('true', '1', 'yes', 'on') %} selected{% endif %}>关闭</option>
|
||||
</select>
|
||||
{% elif field.sensitive %}
|
||||
{% if field.has_value %}
|
||||
<div class="env-sensitive-current muted">已配置 <span class="env-masked-value">{{ field.masked }}</span></div>
|
||||
{% endif %}
|
||||
<input
|
||||
class="env-field-input"
|
||||
id="env-f-{{ field.key }}"
|
||||
type="password"
|
||||
data-env-key="{{ field.key }}"
|
||||
placeholder="{% if field.has_value %}修改时填写新值,留空不修改{% else %}请输入{% endif %}"
|
||||
autocomplete="off"
|
||||
>
|
||||
{% else %}
|
||||
<input
|
||||
class="env-field-input"
|
||||
id="env-f-{{ field.key }}"
|
||||
type="text"
|
||||
data-env-key="{{ field.key }}"
|
||||
value="{{ field.current or field.default or '' }}"
|
||||
>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="env-config-grid" class="env-config-loading-wrap card">
|
||||
<div class="env-config-loading muted">加载配置中…</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% if force_close.enabled %}
|
||||
<span class="force-close-badge" id="force-close-header-badge" role="status"
|
||||
title="北京时间 {{ force_close.hour_label }} 整点未平仓将市价强制清仓(result=强制清仓)"
|
||||
data-force-close-at-ms="{{ force_close.next_at_ms or '' }}"
|
||||
data-force-close-active="{{ '1' if force_close.active else '0' }}">
|
||||
{{ force_close.label }} 已开启 · <span class="force-close-header-cd">{{ force_close.countdown or '--:--:--' }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% if force_close.enabled %}
|
||||
<span class="pos-symbol-force-close pos-force-close-meta" id="order-force-close-wrap-{{ o.id }}"
|
||||
data-force-close-at-ms="{{ o.force_close_at_ms or force_close.next_at_ms or '' }}"
|
||||
data-force-close-active="{{ '1' if (o.force_close_active or force_close.active) else '0' }}">
|
||||
<span class="pos-force-close-label">{{ o.force_close_label or force_close.label }}</span>
|
||||
· <span class="pos-force-close-cd" id="order-force-close-cd-{{ o.id }}">{{ o.force_close_countdown or force_close.countdown or '--:--:--' }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #}
|
||||
<div class="instance-header-panel card">
|
||||
<div class="instance-header-toolbar">
|
||||
<div class="instance-header-toolbar-filter">
|
||||
<span class="list-window-label" title="列表按 UTC 时间筛选,默认本月">UTC {{ list_window.label }}</span>
|
||||
<label class="list-window-preset">预设
|
||||
<select id="win-preset-select" onchange="toggleListWindowCustom()">
|
||||
<option value="utc_this_month" {% if list_window.preset == 'utc_this_month' %}selected{% endif %}>本月</option>
|
||||
<option value="utc_last3m" {% if list_window.preset == 'utc_last3m' %}selected{% endif %}>近3月</option>
|
||||
<option value="utc_last6m" {% if list_window.preset == 'utc_last6m' %}selected{% endif %}>近6月</option>
|
||||
<option value="all" {% if list_window.preset == 'all' %}selected{% endif %}>全部</option>
|
||||
<option value="utc_today" {% if list_window.preset == 'utc_today' %}selected{% endif %}>UTC 当日</option>
|
||||
<option value="utc_last24h" {% if list_window.preset == 'utc_last24h' %}selected{% endif %}>近 24 小时</option>
|
||||
<option value="utc_last7d" {% if list_window.preset == 'utc_last7d' %}selected{% endif %}>近 7 天</option>
|
||||
<option value="custom" {% if list_window.preset == 'custom' %}selected{% endif %}>自定义</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="win-custom-range" class="list-window-custom" style="{% if list_window.preset != 'custom' %}display:none{% endif %}">
|
||||
<label>起 <input type="datetime-local" id="win-from-utc" value="{{ list_window.start_utc.strftime('%Y-%m-%dT%H:%M') }}"></label>
|
||||
<label>止 <input type="datetime-local" id="win-to-utc" value="{{ list_window.end_utc.strftime('%Y-%m-%dT%H:%M') }}"></label>
|
||||
</span>
|
||||
<button type="button" class="list-window-apply" onclick="applyListWindow()">应用</button>
|
||||
<span class="list-window-hint" title="统计分析页按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切交易日">统计切日 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00</span>
|
||||
</div>
|
||||
<div class="instance-header-toolbar-end">
|
||||
<div class="instance-toolbar-status">
|
||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||
{% if trade_policy.badge_text %}
|
||||
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_header_badge.html' %}
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||
</div>
|
||||
{% include 'instance_theme_toggle.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="instance-header-stats-wrap instance-desktop-only">
|
||||
{% include 'instance_header_stats.html' %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
|
||||
<div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}">
|
||||
<div class="stat-strip-item stat-strip-item--primary">
|
||||
<div class="label">交易所</div>
|
||||
<div class="value">{{ exchange_display }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">交易日</div>
|
||||
<div class="value">{{ trading_day }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item stat-strip-item--primary">
|
||||
<div class="label">总交易</div>
|
||||
<div class="value" id="stat-total" data-funds-field="stat-total">{{ total }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">胜率</div>
|
||||
<div class="value" id="stat-rate" data-funds-field="stat-rate">{{ rate }}%</div>
|
||||
</div>
|
||||
<div class="stat-strip-item" title="平均盈利 ÷ 平均亏损(当前列表窗口)">
|
||||
<div class="label">盈亏比</div>
|
||||
<div class="value" id="stat-pl-ratio" data-funds-field="stat-pl-ratio">{% if profit_loss_ratio is not none %}{{ profit_loss_ratio }}{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">总资金</div>
|
||||
<div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">资金账户</div>
|
||||
<div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">交易账户</div>
|
||||
<div class="value" id="current-capital" data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</div>
|
||||
</div>
|
||||
{% if options_enabled %}
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">期权资金账户</div>
|
||||
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc, options_funding_usdt) }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">期权交易账户</div>
|
||||
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc, options_trading_usdt) }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-strip-item stat-strip-item--pnl">
|
||||
<div class="label">实时盈亏</div>
|
||||
<div class="value" id="realtime-pnl" data-funds-field="realtime-pnl">—</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
{# 三所统一顶栏:实时价 + 可选整点前开仓开关(划转已移至系统设置) #}
|
||||
<div class="rule-tip instance-price-bar">
|
||||
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8)
|
||||
</div>
|
||||
{% if ui_open_guard_enabled %}
|
||||
<div class="rule-tip" id="open-guard-bar" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:#cfd3ef">
|
||||
<input type="checkbox" id="allow-open-before-reset" {% if not open_guard_enabled %}checked{% endif %}>
|
||||
允许北京时间 {{ reset_hour }}:00 前开仓(斐波成交登记,人工下单)
|
||||
</label>
|
||||
<span id="open-guard-status" style="color:#8892b0;font-size:.75rem">
|
||||
{% if open_guard_enabled %}已限制:{{ reset_hour }}:00 前不可开仓{% else %}已放开:{{ reset_hour }}:00 前允许开仓{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{# 系统设置 · 资金划转(三所共用) #}
|
||||
<div class="settings-transfer-panel">
|
||||
<p class="rule-tip settings-transfer-auto">
|
||||
自动划转 <strong>{{ '开启' if auto_transfer_enabled else '关闭' }}</strong>:
|
||||
每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong> 起该整点小时内尝试;
|
||||
账簿按 <strong>UTC 自然日</strong> 去重;
|
||||
将 <code>{{ auto_transfer_to }}</code> 调整至 <strong>{{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U</strong>:
|
||||
不足从 <code>{{ auto_transfer_from }}</code> 划入,超出划回 <code>{{ auto_transfer_from }}</code>;
|
||||
<strong>持仓中不划转</strong>并微信通知.
|
||||
</p>
|
||||
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form">
|
||||
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required>
|
||||
<select name="from_account" aria-label="划出账户">
|
||||
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
|
||||
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
|
||||
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
|
||||
</select>
|
||||
<select name="to_account" aria-label="划入账户">
|
||||
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
|
||||
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
|
||||
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
|
||||
</select>
|
||||
<button type="submit">执行手动划转</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<script src="/static/instance_theme.js?v=4"></script>
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ pwa_app_name }}">
|
||||
<link rel="icon" href="/static/icons/favicon.ico" sizes="32x32">
|
||||
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||
<title>登录 · {{ pwa_app_name }}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: #0a0a10;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.login-theme-bar {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.login-box {
|
||||
background: #12121a;
|
||||
padding: 2.5rem;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border: 1px solid #242435;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
.login-box h2 {
|
||||
margin-bottom: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
background: linear-gradient(90deg, #4cc2ff, #7b42ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #a9a9ff;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2e2e45;
|
||||
background: #1a1a29;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: #4cc2ff;
|
||||
}
|
||||
.login-box > form > button {
|
||||
width: 100%;
|
||||
padding: 0.9rem;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: linear-gradient(90deg, #4285f4, #7b42ff);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.login-box > form > button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.flash {
|
||||
padding: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #331e24;
|
||||
color: #ff6666;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.exchange-line {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: #8892b0;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.exchange-line strong {
|
||||
color: #b8f5d0;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-theme-bar">
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-box">
|
||||
<h2>{{ pwa_app_name }}</h2>
|
||||
<p class="exchange-line">登录 · <strong>{{ exchange_display }}</strong></p>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="flash">{{ messages[0] }}</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label>账号</label>
|
||||
<input type="text" name="username" required placeholder="请输入账号" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" required placeholder="请输入密码" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
{# 趋势户:两级开仓类型 → 自动 trade_style;日内户:假破 / 结构突破 #}
|
||||
|
||||
{% macro order_entry_type_fields() -%}
|
||||
|
||||
{% if order_entry_profile == 'trend_div' %}
|
||||
|
||||
<div class="order-entry-model-row">
|
||||
|
||||
<select id="order-entry-category" class="order-entry-category" required title="反转 / 顺势 / 波段" aria-label="开仓性质">
|
||||
|
||||
<option value="">性质</option>
|
||||
|
||||
{% for cat in entry_model_categories %}
|
||||
|
||||
<option value="{{ cat.key }}">{{ cat.label }}</option>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
|
||||
<select name="entry_model" id="order-entry-model" class="order-entry-model-sub" required disabled title="启动A/B,大分歧A/B,小分歧" aria-label="开仓类型">
|
||||
|
||||
<option value="">类型</option>
|
||||
|
||||
{% for cat in entry_model_categories %}
|
||||
|
||||
{% for opt in cat.options %}
|
||||
|
||||
<option value="{{ opt.code }}" data-entry-category="{{ cat.key }}" data-trade-style="{{ opt.trade_style }}"{% if opt.help %} title="{{ opt.help }}"{% endif %} hidden disabled>{{ opt.label }}</option>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
|
||||
<input type="hidden" name="trade_style" id="order-trade-style-hidden" value="trend">
|
||||
|
||||
<span id="order-trade-style-hint" class="order-trade-style-hint" title="由开仓类型自动设定">趋势单</span>
|
||||
|
||||
</div>
|
||||
|
||||
{% elif order_entry_profile == 'intraday' %}
|
||||
|
||||
<select name="entry_model" id="order-entry-model" class="order-entry-intraday" required title="日内开仓类型" aria-label="开仓类型">
|
||||
|
||||
<option value="">开仓类型</option>
|
||||
|
||||
{% for opt in intraday_entry_model_options %}
|
||||
|
||||
<option value="{{ opt.code }}"{% if opt.help %} title="{{ opt.help }}"{% endif %}>{{ opt.label }}</option>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
|
||||
<input type="hidden" name="trade_style" value="trend">
|
||||
|
||||
{% else %}
|
||||
|
||||
<select name="trade_style" required>
|
||||
|
||||
<option value="trend">趋势单</option>
|
||||
|
||||
<option value="swing">波段单</option>
|
||||
|
||||
</select>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{# 以损定仓:杠杆按币种默认(BTC/ETH 10x,其它 5x),不可选手输 #}
|
||||
{% macro order_leverage_fields() -%}
|
||||
{% if position_sizing_mode != 'full_margin' %}
|
||||
<input type="hidden" id="order-leverage" name="leverage" value="">
|
||||
<span id="order-leverage-hint" class="order-leverage-hint" title="BTC/ETH 默认10x,其它默认5x">杠杆 —</span>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{# 系统设置 · 账户密码(外层 card 由 settings_panel 提供) #}
|
||||
<h2>账户密码修改</h2>
|
||||
<p class="settings-subcard-desc">修改网页登录账号密码,写入 <code>.env</code> 后需重启实例生效.</p>
|
||||
<div class="settings-password-form">
|
||||
<label>当前密码 <input type="password" id="pwd-old" autocomplete="current-password"></label>
|
||||
<label>新用户名(可选) <input type="text" id="pwd-new-username" autocomplete="username"></label>
|
||||
<label>新密码 <input type="password" id="pwd-new" autocomplete="new-password"></label>
|
||||
<label>确认新密码 <input type="password" id="pwd-confirm" autocomplete="new-password"></label>
|
||||
</div>
|
||||
<div class="settings-actions-row">
|
||||
<button type="button" class="btn-primary btn-sm" id="pwd-save-btn">保存密码</button>
|
||||
<span class="settings-status-line" id="pwd-save-status"></span>
|
||||
</div>
|
||||
@@ -0,0 +1,153 @@
|
||||
{# 三所共用:交易记录(5/页) → 填入复盘出表单 → 交易复盘记录 / AI历史复盘 #}
|
||||
<style>
|
||||
.records-panel-wrap{display:flex;flex-direction:column;gap:14px}
|
||||
.records-panel-wrap .rr-pager{display:flex;align-items:center;gap:8px;margin-top:8px;font-size:.74rem}
|
||||
.records-panel-wrap .rr-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
|
||||
.records-panel-wrap .rr-trades-wrap{min-height:9.5rem}
|
||||
.records-panel-wrap .journal-card.hidden{display:none!important}
|
||||
.records-panel-wrap .rr-hint{font-size:.72rem;color:#8892b0;margin:0 0 8px}
|
||||
</style>
|
||||
<div class="records-panel-wrap" id="records-panel-root" style="grid-column:1/-1">
|
||||
<div class="card full records-card">
|
||||
<h2>交易记录</h2>
|
||||
<p class="rr-hint">每页5条.点「填入复盘」打开下方复盘表单.</p>
|
||||
<div class="form-row" style="margin-bottom:10px;gap:8px">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||
<input id="review-mode-toggle" type="checkbox">
|
||||
修改/核对开关(开启后可编辑关键字段)
|
||||
</label>
|
||||
</div>
|
||||
<div class="table-wrap rr-trades-wrap" id="rr-trades-wrap">
|
||||
<table id="rr-trades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品种</th><th>下单类型</th><th>开仓类型</th><th>方向</th><th>成交</th>
|
||||
<th>止损(开仓)</th><th>止盈</th><th>基数</th><th>杠杆</th><th>持仓分钟</th>
|
||||
<th>开仓时间(北京)</th><th>平仓时间(北京)</th><th>盈亏U</th><th>结果</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rr-trades-tbody">
|
||||
<tr><td colspan="15" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rr-pager" id="rr-trades-pager">
|
||||
<button type="button" class="btn-secondary" id="rr-trades-prev" style="font-size:.72rem;padding:2px 8px">上一页</button>
|
||||
<span class="muted" id="rr-trades-page-label">第 1 / 1 页</span>
|
||||
<button type="button" class="btn-secondary" id="rr-trades-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card full journal-card hidden" id="journal-card">
|
||||
<div class="form-row" style="align-items:center;gap:8px;margin-bottom:6px">
|
||||
<h2 style="margin:0;margin-right:auto">交易复盘记录上传(含截图)</h2>
|
||||
<button type="button" class="btn-secondary" id="rr-journal-hide-btn" style="font-size:.76rem;padding:4px 10px">收起</button>
|
||||
</div>
|
||||
<p class="rr-hint" id="rr-journal-fill-hint" style="display:none">已从交易记录填入,请补充主观原因后保存.</p>
|
||||
<form id="journal-form" action="/add_journal" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
|
||||
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
|
||||
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
||||
<input type="hidden" name="exit_price_hint" id="exit-price-hint">
|
||||
<input type="hidden" name="direction_hint" id="direction-hint">
|
||||
{% from 'journal_form_fields.html' import journal_form_fields %}
|
||||
{{ journal_form_fields(entry_reason_options, order_type_options) }}
|
||||
{% from 'journal_upload_slots.html' import journal_upload_slots %}
|
||||
{{ journal_upload_slots() }}
|
||||
<div class="form-row journal-chart-options" style="flex-wrap:wrap;align-items:center">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||
<input type="checkbox" name="journal_exchange_chart" value="true">
|
||||
保存时自动生成 K 线图并作为截图
|
||||
</label>
|
||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||
<select name="journal_chart_tf1" style="min-width:72px">
|
||||
{% for tf in journal_chart_tf_choices %}
|
||||
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label style="font-size:.82rem;color:#9aa">周期2</label>
|
||||
<select name="journal_chart_tf2" style="min-width:72px">
|
||||
{% for tf in journal_chart_tf_choices %}
|
||||
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label style="font-size:.82rem;color:#9aa">K线数</label>
|
||||
<select name="journal_chart_limit" style="min-width:72px">
|
||||
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
|
||||
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label style="font-size:.82rem;color:#9aa">K线截止</label>
|
||||
<select name="journal_chart_anchor" id="journal-chart-anchor" style="min-width:96px" title="K线窗口右端对齐的时间">
|
||||
<option value="close" {% if journal_chart_default_anchor == 'close' %}selected{% endif %}>平仓时间</option>
|
||||
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:2px;margin-bottom:0">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓,平仓与止损位</div>
|
||||
<div class="mood-grid">
|
||||
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
||||
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
||||
<label><input type="checkbox" name="mood_issues" value="盈利飘了">盈利飘了</label>
|
||||
<label><input type="checkbox" name="mood_issues" value="拿不住单">拿不住单</label>
|
||||
<label><input type="checkbox" name="mood_issues" value="扛单">扛单</label>
|
||||
<label><input type="checkbox" name="mood_issues" value="重仓违规">重仓违规</label>
|
||||
</div>
|
||||
<textarea name="note" rows="2" placeholder="备注"></textarea>
|
||||
<button type="submit" style="margin-top:8px">保存复盘记录</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card full review-card" id="review-card">
|
||||
<div class="review-card-head">
|
||||
<h2>AI复盘(按交易记录)</h2>
|
||||
<button type="button" class="review-card-fs-btn" id="review-card-fs-btn" onclick="toggleReviewCardFullscreen()">全屏</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="date" id="day_date">
|
||||
<button type="button" id="gen-daily-btn" onclick="genDaily()">生成日复盘</button>
|
||||
<button type="button" onclick="exportDailyBundleMd()" style="background:#1f3a5a">导出当日日复盘MD</button>
|
||||
<input type="date" id="week_start">
|
||||
<input type="date" id="week_end">
|
||||
<button type="button" id="gen-weekly-btn" onclick="genWeekly()">生成周复盘</button>
|
||||
<button type="button" onclick="exportWeeklyBundleMd()" style="background:#1f3a5a">导出当周复盘MD</button>
|
||||
</div>
|
||||
<div class="ai-result-wrap" id="daily_result_wrap" style="display:none">
|
||||
<div id="daily_result" class="ai-result"></div>
|
||||
<div class="ai-result-toolbar">
|
||||
<button type="button" class="btn-fs" onclick="openAiInlineResultFullscreen('日复盘结果', 'daily_result')">全屏查看</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-result-wrap" id="weekly_result_wrap" style="display:none">
|
||||
<div id="weekly_result" class="ai-result"></div>
|
||||
<div class="ai-result-toolbar">
|
||||
<button type="button" class="btn-fs" onclick="openAiInlineResultFullscreen('周复盘结果', 'weekly_result')">全屏查看</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card full" id="rr-journals-card">
|
||||
<h3 style="margin-top:0">交易复盘记录</h3>
|
||||
<p class="rr-hint" id="rr-journals-hint">已保存的复盘(每页5条).</p>
|
||||
<div id="journal-list-wrap" class="rr-list-wrap rr-journals-wrap">
|
||||
<div id="journal-list"></div>
|
||||
</div>
|
||||
<div class="rr-pager" id="rr-journals-pager">
|
||||
<button type="button" class="btn-secondary" id="rr-journals-prev" style="font-size:.72rem;padding:2px 8px">上一页</button>
|
||||
<span class="muted" id="rr-journals-page-label">第 1 / 1 页</span>
|
||||
<button type="button" class="btn-secondary" id="rr-journals-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card full" id="rr-ai-history-card">
|
||||
<h3 style="margin-top:0">AI历史复盘</h3>
|
||||
<p class="rr-hint">日/周 AI 复盘历史(每页5条).</p>
|
||||
<div id="review-list-wrap" class="rr-list-wrap">
|
||||
<div id="review-list"></div>
|
||||
</div>
|
||||
<div class="rr-pager" id="rr-reviews-pager">
|
||||
<button type="button" class="btn-secondary" id="rr-reviews-prev" style="font-size:.72rem;padding:2px 8px">上一页</button>
|
||||
<span class="muted" id="rr-reviews-page-label">第 1 / 1 页</span>
|
||||
<button type="button" class="btn-secondary" id="rr-reviews-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
{# 风控说明:只读展示 .env 风控参数与当前账户状态 #}
|
||||
<div class="risk-policy-page full">
|
||||
<div class="card settings-card settings-card--risk">
|
||||
<h2>风控说明</h2>
|
||||
<p class="settings-live-status">
|
||||
当前账户状态:
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}">
|
||||
{{ instance_settings.risk_status_label }}
|
||||
</span>
|
||||
{% if instance_settings.risk_status_reason %}
|
||||
<span class="settings-status-reason">{{ instance_settings.risk_status_reason }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if instance_settings.trade_policy_note %}
|
||||
<p class="settings-policy-note">账户限制:{{ instance_settings.trade_policy_note }}</p>
|
||||
{% endif %}
|
||||
<p class="settings-env-hint">以下参数读取自本实例 <code>.env</code>,修改后需重启进程生效.</p>
|
||||
<div class="settings-risk-sections">
|
||||
{% for section in instance_settings.sections %}
|
||||
<div class="card settings-subcard">
|
||||
<h3 class="settings-subcard-title">{{ section.title }}</h3>
|
||||
<dl class="settings-kv settings-kv--compact">
|
||||
{% for row in section.rows %}
|
||||
<div class="settings-kv-row">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>
|
||||
<span class="settings-kv-value">{{ row.value }}</span>
|
||||
{% if row.note %}
|
||||
<span class="settings-kv-note">{{ row.note }}</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
{# 系统设置:CSS Tab(与 env 配置同方案) #}
|
||||
<div class="settings-page full">
|
||||
<div class="env-config-head card">
|
||||
<h2>系统设置</h2>
|
||||
<p class="settings-env-hint env-config-head-hint">各区块说明见 <code>docs/系统设置说明.md</code>.</p>
|
||||
</div>
|
||||
|
||||
{% if settings_tabs %}
|
||||
<div class="env-config-body card settings-config-body">
|
||||
{% for tab in settings_tabs %}
|
||||
<input type="radio" name="settings-section" id="settings-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}>
|
||||
{% endfor %}
|
||||
<div class="env-config-tabs" role="tablist" aria-label="系统设置分类">
|
||||
{% for tab in settings_tabs %}
|
||||
<label for="settings-sec-{{ loop.index0 }}" class="env-tab-btn" role="tab">{{ tab.title }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="env-config-panels">
|
||||
{% for tab in settings_tabs %}
|
||||
<section class="env-panel env-panel--{{ loop.index0 }} settings-tab-panel" role="tabpanel">
|
||||
{% if tab.key == 'nav' %}
|
||||
{% include 'display_prefs_panel.html' %}
|
||||
{% elif tab.key == 'password' %}
|
||||
{% include 'password_settings_panel.html' %}
|
||||
{% elif tab.key == 'transfer' %}
|
||||
<h2>永续资金划转</h2>
|
||||
<p class="settings-subcard-desc">子账户永续:资金账户与交易账户之间划转 USDT.</p>
|
||||
{% include 'instance_transfer_panel.html' %}
|
||||
{% elif tab.key == 'export' %}
|
||||
<h2>数据导出</h2>
|
||||
<p class="settings-subcard-desc muted">CSV · v{{ instance_settings.data_export_version }}</p>
|
||||
<div class="settings-export-links-inline settings-export-links-block">
|
||||
<a href="/export/trade_records">交易记录</a>
|
||||
<a href="/export/journal_entries">复盘记录</a>
|
||||
<a href="/export/key_monitors">关键位(当前)</a>
|
||||
<a href="/export/key_monitor_history">关键位历史</a>
|
||||
</div>
|
||||
{% elif tab.key == 'options_swap' %}
|
||||
<h2>币种兑换</h2>
|
||||
{% include 'options_settings_swap.html' %}
|
||||
{% elif tab.key == 'options_transfer' %}
|
||||
<h2>期权资金划转</h2>
|
||||
{% include 'options_settings_transfer.html' %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if instance_settings.options_settings_enabled %}
|
||||
{% include 'options_settings_panel.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
Reference in New Issue
Block a user