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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:23:09 +08:00
parent 4742a0bb9d
commit 5797d49d8a
190 changed files with 27946 additions and 27499 deletions
+1
View File
@@ -0,0 +1 @@
"""crypto_monitor shared libraries."""
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+490
View File
@@ -0,0 +1,490 @@
"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一。
配置从 os.environ 惰性读取:各实例 app.py 在 import 本模块后才 load_env_file(.env)
若在 import 时缓存变量会导致 OPENAI_API_KEY 始终为空。
"""
from __future__ import annotations
import base64
import os
import re
from typing import List, Optional, Sequence, Tuple
import requests
def _env_str(name: str, default: str = "") -> str:
v = os.getenv(name)
if v is None:
return default
return str(v).strip()
def _ai_timeout_seconds(*, image_count: int = 0, chat: bool = False) -> int:
if chat:
try:
return max(30, int(_env_str("CHAT_AI_TIMEOUT_SECONDS", "300") or "300"))
except ValueError:
return 300
if image_count > 0:
try:
return max(30, int(_env_str("AI_REVIEW_TIMEOUT_SECONDS", "300") or "300"))
except ValueError:
return 300
try:
return max(10, int(_env_str("AI_TIMEOUT_SECONDS", "120") or "120"))
except ValueError:
return 120
def _ai_provider() -> str:
return (_env_str("AI_PROVIDER", "openai") or "openai").lower()
def _openai_api_base() -> str:
base = _env_str("OPENAI_API_BASE", "https://op.bz121.com/v1") or "https://op.bz121.com/v1"
return base.rstrip("/")
def _openai_api_key() -> str:
return _env_str("OPENAI_API_KEY") or _env_str("AI_API_KEY")
def _openai_model() -> str:
return _env_str("OPENAI_MODEL", "gemma4:e4b") or "gemma4:e4b"
def _ollama_api() -> str:
return _env_str("OLLAMA_API", "http://127.0.0.1:11434/api/generate") or "http://127.0.0.1:11434/api/generate"
def _ollama_model() -> str:
return _env_str("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") or "huihui_ai/deepseek-r1-abliterated:latest"
def _use_openai() -> bool:
return _ai_provider() in ("openai", "openai_compatible", "gateway")
def _image_mime_for_path(path: str) -> str:
ext = os.path.splitext(str(path or ""))[1].lower()
if ext == ".png":
return "image/png"
if ext in (".jpg", ".jpeg"):
return "image/jpeg"
if ext == ".webp":
return "image/webp"
if ext == ".gif":
return "image/gif"
return "image/jpeg"
def _read_image_base64(image_path: str) -> Optional[tuple]:
try:
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return b64, _image_mime_for_path(image_path)
except Exception:
return None
def _collect_images(
image_paths: Optional[Sequence[str]] = None,
images_b64: Optional[Sequence[str]] = None,
) -> List[tuple]:
out: List[tuple] = []
for p in image_paths or []:
item = _read_image_base64(p)
if item:
out.append(item)
for b in images_b64 or []:
if b:
out.append((str(b), "image/jpeg"))
return out
def _openai_chat_url() -> str:
base = _openai_api_base()
if base.endswith("/chat/completions"):
return base
return f"{base}/chat/completions"
def _openai_message_text(msg: dict) -> str:
content = msg.get("content")
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
parts.append(str(part.get("text") or ""))
content = "".join(parts)
text = str(content or "").strip()
if not text:
text = str(msg.get("reasoning_content") or "").strip()
return text
def _apply_max_tokens(body: dict, max_tokens: int | None, *, chat: bool = False) -> None:
if max_tokens is not None and max_tokens > 0:
mt = int(max_tokens)
body["max_tokens"] = mt
if not chat:
body["max_completion_tokens"] = mt
def _openai_chat_completion(
messages: list[dict],
*,
temperature: float,
max_tokens: int | None = None,
image_count: int = 0,
chat: bool = False,
) -> Tuple[str, str]:
api_key = _openai_api_key()
if not api_key:
return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)", "error"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
body: dict = {
"model": _openai_model(),
"messages": messages,
"temperature": temperature,
"stream": False,
}
_apply_max_tokens(body, max_tokens, chat=chat)
r = requests.post(
_openai_chat_url(),
headers=headers,
json=body,
timeout=_ai_timeout_seconds(image_count=image_count, chat=chat),
)
r.raise_for_status()
data = r.json()
choices = data.get("choices") or []
if not choices:
return "AI 生成失败:响应无 choices", "error"
choice = choices[0] or {}
msg = choice.get("message") or {}
text = _openai_message_text(msg)
finish = str(choice.get("finish_reason") or "")
if not text and chat and max_tokens:
retry_body = dict(body)
retry_body.pop("max_completion_tokens", None)
r2 = requests.post(
_openai_chat_url(),
headers=headers,
json=retry_body,
timeout=_ai_timeout_seconds(image_count=image_count, chat=chat),
)
r2.raise_for_status()
data2 = r2.json()
choices2 = data2.get("choices") or []
if choices2:
msg2 = (choices2[0] or {}).get("message") or {}
text2 = _openai_message_text(msg2)
if text2:
return text2, str((choices2[0] or {}).get("finish_reason") or finish)
if not text:
return "AI 生成失败:空内容", finish or "error"
return text, finish
def _generate_openai(
prompt: str,
images: List[tuple],
temperature: float,
*,
max_tokens: int | None = None,
) -> str:
if images:
content: List[dict] = [{"type": "text", "text": prompt}]
for b64, mime in images:
content.append(
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
}
)
messages = [{"role": "user", "content": content}]
else:
messages = [{"role": "user", "content": prompt}]
text, _reason = _openai_chat_completion(
messages,
temperature=temperature,
max_tokens=max_tokens,
image_count=len(images),
)
return text
def _generate_ollama(
prompt: str,
images: List[tuple],
temperature: float,
*,
max_tokens: int | None = None,
chat: bool = False,
) -> Tuple[str, str]:
options: dict = {"temperature": temperature}
if max_tokens is not None and max_tokens > 0:
options["num_predict"] = int(max_tokens)
payload = {
"model": _ollama_model(),
"prompt": prompt,
"stream": False,
"options": options,
}
if images:
payload["images"] = [b64 for b64, _mime in images]
r = requests.post(
_ollama_api(),
json=payload,
timeout=_ai_timeout_seconds(image_count=len(images), chat=chat),
)
r.raise_for_status()
data = r.json()
text = (data.get("response") or "").strip() or "AI 生成失败"
return text, str(data.get("done_reason") or "")
def ai_generate(
prompt: str,
*,
image_paths: Optional[Sequence[str]] = None,
images_b64: Optional[Sequence[str]] = None,
temperature: float = 0.2,
max_tokens: int | None = None,
) -> str:
"""统一文本生成;失败时返回以「AI 调用失败」开头的说明。"""
images = _collect_images(image_paths, images_b64)
try:
if _use_openai():
return _generate_openai(prompt, images, temperature, max_tokens=max_tokens)
text, _reason = _generate_ollama(prompt, images, temperature, max_tokens=max_tokens)
return text
except requests.HTTPError as e:
detail = ""
try:
detail = (e.response.text or "")[:500]
except Exception:
pass
prov = "OpenAI" if _use_openai() else "Ollama"
return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}"
except Exception as e:
prov = "OpenAI" if _use_openai() else "Ollama"
return f"AI 调用失败({prov}):{str(e)}"
_CHAT_CONTINUE_USER = (
"你上一条回复在中途截断了。请从断点处继续写完,不要重复已写内容,"
"保持同一语气;编号列表每条单独一行。"
)
_CHAT_END_CHARS = "。!?.!?\"」』))>】"
_INCOMPLETE_TAIL_RE = re.compile(
r"(不会|不能|没有|会不会|是不是|够不够|能不能|要不要|如何|怎么|什么|哪里|多少|对吗|怎么样|"
r"这个\.\.\.|这个…|\.\.\.\d+\.|\d+\.)$"
)
def _looks_truncated(text: str) -> bool:
t = (text or "").rstrip()
if len(t) < 16:
return False
if t[-1] in _CHAT_END_CHARS:
return False
if _INCOMPLETE_TAIL_RE.search(t):
return True
if t.endswith("") or t.endswith("..."):
return True
if re.search(r"\d+\.\s*$", t):
return True
return t[-1] not in ",、,;:\n"
def _should_continue(reason: str, full_text: str) -> bool:
if reason in ("length", "max_tokens", "model_length"):
return True
return _looks_truncated(full_text)
def _chat_continue_message(full_text: str) -> str:
tail = full_text[-500:] if len(full_text) > 500 else full_text
return (
f"{_CHAT_CONTINUE_USER}\n\n"
f"已写到最后这几句:\n{tail}\n\n"
f"请从断点接着写完。不要重复前文;最后一句话必须以句号、问号或感叹号结束。"
)
def _chat_continue_system(system: str) -> str:
return (
f"{system.strip()}\n\n"
"【续写模式】只输出断点后的剩余内容,不要重复前文;"
"列表每条单独一行;必须以句号、问号或感叹号收尾。"
)
def ai_generate_chat(
*,
system: str,
user: str,
temperature: float = 0.5,
images_b64: Optional[Sequence[str]] = None,
max_tokens: int = 8192,
max_continuations: int = 4,
) -> str:
"""聊天专用:system/user 分消息;输出触顶时轻量续写(不重复巨型上下文)。"""
images = _collect_images(None, images_b64)
max_rounds = max(1, int(max_continuations) + 1)
try:
if _use_openai():
if images:
user_content: List[dict] | str = [{"type": "text", "text": user.strip()}]
for b64, mime in images:
user_content.append(
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
}
)
else:
user_content = user.strip()
base_user_msg = {"role": "user", "content": user_content}
messages: list[dict] = [
{"role": "system", "content": system.strip()},
base_user_msg,
]
parts: list[str] = []
for attempt in range(max_rounds):
chunk, reason = _openai_chat_completion(
messages,
temperature=temperature,
max_tokens=max_tokens,
image_count=len(images) if attempt == 0 else 0,
chat=True,
)
if chunk.startswith("AI 调用失败") or chunk.startswith("AI 生成失败"):
return chunk if not parts else "".join(parts).strip()
parts.append(chunk)
full = "".join(parts)
if not _should_continue(reason, full) or attempt >= max_rounds - 1:
break
messages = [
{"role": "system", "content": _chat_continue_system(system)},
{"role": "assistant", "content": full},
{"role": "user", "content": _chat_continue_message(full)},
]
return "".join(parts).strip() or "AI 生成失败:空内容"
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
parts: list[str] = []
for attempt in range(max_rounds):
if parts:
full = "".join(parts)
current_prompt = (
f"{_chat_continue_system(system)}\n\n"
f"【你已写道】\n{full}\n\n{_chat_continue_message(full)}"
)
else:
current_prompt = prompt
chunk, reason = _generate_ollama(
current_prompt,
images if not parts else [],
temperature,
max_tokens=max_tokens,
chat=True,
)
if chunk.startswith("AI 生成失败") and not parts:
return chunk
if chunk.startswith("AI 生成失败"):
break
parts.append(chunk)
full = "".join(parts)
if not _should_continue(reason, full) or attempt >= max_rounds - 1:
break
return "".join(parts).strip() or "AI 生成失败:空内容"
except requests.HTTPError as e:
detail = ""
try:
detail = (e.response.text or "")[:500]
except Exception:
pass
prov = "OpenAI" if _use_openai() else "Ollama"
return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}"
except Exception as e:
prov = "OpenAI" if _use_openai() else "Ollama"
return f"AI 调用失败({prov}):{str(e)}"
def ai_review(trades_text: str, period_title: str, image_paths=None) -> str:
n_img = len(image_paths or [])
period_label = "" if "" in str(period_title) else ""
attach_note = (
f"️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图),请结合附图分析第5节。】\n\n"
if n_img
else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」。】\n\n"
)
prompt = f"""
你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。
【硬性规则 — 必须遵守】
- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。
- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。
- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。
- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。
- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。
- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。
【输出格式 — Markdown,必须严格遵守】
- 第一行:**交易复盘报告({period_label}度)**
- 五个大节标题必须**完全一致**(含 emoji,不要用其它编号或改名):
**1. 📊 总体盈亏结构**
**2. 🧠 心态与执行**
**3. 🏷️ 行为标签**
**4. ✅ 改进建议**
**5. 📈 图表分析**
- 每节正文用 `- **子项名**:内容` 列表;第4节改进建议用有序列表 `1. 2. 3.`
- 第1节至少包含:**笔数/盈亏**、**风险回报比**、**总结**
- 第2节至少包含:**得分**(1–10)、**依据**(对应记录字段)
- 第5节至少包含:**趋势确认**、**执行路径**(记录不足则写明)
- 语气简洁,少形容词;不要输出代码块、不要表格
交易记录:
{trades_text}
""".strip()
return attach_note + ai_generate(prompt, image_paths=image_paths, temperature=0.2)
def ai_short_advice(prompt_text: str) -> str:
prompt = f"""
你是交易风控助理。请用中文给出**最多 3 条**提醒,要求:
- 每条不超过 25 个字
- 语气克制、具体、可执行
- 不要输出 Markdown,不要编号前缀以外的废话
场景:
{prompt_text}
""".strip()
return ai_generate(prompt, temperature=0.2)
def ai_provider_label() -> str:
if _use_openai():
return f"OpenAI 兼容 · {_openai_model()} @ {_openai_api_base()}"
return f"Ollama · {_ollama_model()}"
def ai_config_status() -> dict:
"""调试用:当前进程内读到的 AI 配置(不含密钥明文)。"""
key = _openai_api_key()
return {
"provider": _ai_provider(),
"openai_base": _openai_api_base(),
"openai_model": _openai_model(),
"openai_key_configured": bool(key),
"ollama_api": _ollama_api(),
"ollama_model": _ollama_model(),
}
+180
View File
@@ -0,0 +1,180 @@
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(四所共用)。"""
from __future__ import annotations
import os
import uuid
from typing import Any, Callable, List, Mapping, Optional, Sequence
from lib.instance.journal_chart_lib import (
JOURNAL_CHART_ANCHOR_CLOSE,
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
JOURNAL_CHART_DEFAULT_TF2,
normalize_chart_timeframe,
)
def _journal_nz(v: Any, default: str = "") -> str:
if v is None:
return default
s = str(v).strip()
return s if s else default
def _row_get(row: Any, key: str, default: Any = None) -> Any:
"""兼容 dict 与 sqlite3.RowRow 无 .get 方法)。"""
if row is None:
return default
getter = getattr(row, "get", None)
if callable(getter):
return getter(key, default)
try:
keys = row.keys() if hasattr(row, "keys") else ()
if key in keys:
return row[key]
except Exception:
pass
try:
return row[key]
except (KeyError, TypeError, IndexError):
return default
def journal_row_lines_for_ai(
idx: int,
row: Any,
*,
include_hold_duration: bool = True,
) -> str:
"""把 journal 字段拼成给 AI 的文本;四所日复盘/周复盘共用。"""
lines = [
(
f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} "
f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U "
f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} "
f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}"
),
f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}",
f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}",
]
if include_hold_duration:
lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}")
ee_bits = [
_journal_nz(_row_get(row, "early_exit")),
_journal_nz(_row_get(row, "early_exit_reason")),
_journal_nz(_row_get(row, "early_exit_trigger")),
_journal_nz(_row_get(row, "early_exit_note")),
]
if any(x != "" for x in ee_bits):
lines.append(
" 提前离场记录:"
f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}"
)
mood_bits = f"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}"
mood_score = _row_get(row, "mood_score")
if mood_score is not None:
mood_bits += f" | 自评心态分:{mood_score}"
lines.append(f" {mood_bits}")
if _journal_nz(_row_get(row, "post_breakeven_stare")) != "":
lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}")
if _journal_nz(_row_get(row, "new_trade_while_occupied")) != "":
lines.append(f" 占用时新开仓:{_journal_nz(_row_get(row, 'new_trade_while_occupied'))}")
if _journal_nz(_row_get(row, "note")) != "":
lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}")
return "\n".join(lines) + "\n"
def collect_images_for_ai_review(
rows: Sequence,
upload_folder: str,
*,
build_chart_if_missing: Optional[Callable] = None,
) -> List[str]:
"""
收集传给视觉模型的本地图片路径。
- 优先 journal_entries.image 已存附图;
- 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。
"""
paths: List[str] = []
seen = set()
upload_folder = os.path.abspath(upload_folder or "")
for row in rows or []:
candidate = None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
img = row["image"] if "image" in keys else None
if img:
candidate = os.path.join(upload_folder, str(img).strip())
elif build_chart_if_missing:
try:
candidate = build_chart_if_missing(row)
except Exception:
candidate = None
if not candidate:
continue
candidate = os.path.abspath(candidate)
if os.path.isfile(candidate) and candidate not in seen:
seen.add(candidate)
paths.append(candidate)
return paths
def build_journal_ai_chart_path(
row,
upload_folder: str,
*,
order_chart_enabled: bool,
normalize_exchange_symbol_fn: Callable[[str], str],
generate_chart_fn: Callable,
local_datetime_to_ms_fn: Callable[[str], Optional[int]],
now_ts_ms_fn: Callable[[], int],
) -> Optional[str]:
"""无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径。"""
if not order_chart_enabled:
return None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
return None
coin = (row["coin"] if "coin" in keys else "") or ""
coin = str(coin).strip()
if not coin:
return None
try:
symbol = normalize_exchange_symbol_fn(coin)
except Exception:
return None
open_dt = row["open_datetime"] if "open_datetime" in keys else ""
close_dt = row["close_datetime"] if "close_datetime" in keys else ""
entry_ms = local_datetime_to_ms_fn(open_dt)
exit_ms = local_datetime_to_ms_fn(close_dt)
if not entry_ms:
return None
row_tf = row["tf"] if "tf" in keys else ""
tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1
tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h"
row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8]
marker = {
"entry_ts_ms": entry_ms,
"exit_ts_ms": exit_ms,
"chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE,
"now_ts_ms": int(now_ts_ms_fn()),
}
fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png"
saved = generate_chart_fn(
symbol,
f"AI复盘 {coin}",
timeframes=[tf1, tf2],
limit=JOURNAL_CHART_DEFAULT_LIMIT,
out_dir=upload_folder,
filename=fname,
marker_payload=marker,
marker_timeframes={tf1, tf2},
layout="vertical",
)
if not saved:
return None
path = os.path.join(upload_folder, saved)
return path if os.path.isfile(path) else None
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+130
View File
@@ -0,0 +1,130 @@
"""
每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额。
- 交易账户 < 目标:从资金账户划入差额
- 交易账户 > 目标:将多余划回资金账户
- 有 active 持仓:不划转,写账簿并企业微信说明
"""
from __future__ import annotations
from typing import Any, Callable
def run_auto_transfer_once_per_day(
*,
enabled: bool,
bj_hour: int,
target_amount: float,
from_account: str,
to_account: str,
funds_decimals: int,
get_db: Callable[[], Any],
get_active_position_count: Callable[[Any], int],
get_account_usdt_total: Callable[[str], float | None],
execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]],
send_wechat_msg: Callable[[str], None],
utc_now_dt: Callable[[], Any],
app_tz: Any,
utc_calendar_date_str: Callable[[], str],
app_now_str: Callable[[], str],
min_transfer: float = 0.01,
) -> None:
if not enabled:
return
utc_dt = utc_now_dt()
bj = utc_dt.astimezone(app_tz)
if bj.hour != bj_hour:
return
transfer_day = utc_calendar_date_str()
conn = get_db()
exists = conn.execute(
"SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?",
("auto_daily", transfer_day),
).fetchone()
if exists:
conn.close()
return
def _log(
amount: float,
fr: str,
to: str,
status: str,
message: str,
*,
commit_close: bool = True,
) -> None:
conn.execute(
"INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)",
("auto_daily", transfer_day, amount, fr, to, status, message[:500]),
)
conn.commit()
if commit_close:
conn.close()
active = get_active_position_count(conn)
if active > 0:
msg = f"持仓中({active}笔),本次资金无划转"
_log(0, from_account, to_account, "skipped", msg)
send_wechat_msg(
f"自动划转:{msg}\n"
f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n"
f"账簿日(UTC){transfer_day}|触发时刻(北京){app_now_str()}"
)
return
target = round(float(target_amount), funds_decimals)
trade_bal = get_account_usdt_total(to_account)
if trade_bal is None:
_log(
0,
from_account,
to_account,
"failed",
f"读取{to_account}账户USDT失败",
)
return
trade = round(float(trade_bal), funds_decimals)
diff = round(target - trade, funds_decimals)
if abs(diff) < min_transfer:
_log(
0,
from_account,
to_account,
"skipped",
f"{to_account}账户已为{trade}U(目标{target}U",
)
return
if diff > 0:
fr, to, amount = from_account, to_account, diff
action = "划入"
else:
fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals)
action = "划出"
from_bal = get_account_usdt_total(fr)
if from_bal is not None and round(float(from_bal), funds_decimals) < amount:
cur = round(float(from_bal), funds_decimals)
_log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U")
send_wechat_msg(
f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U{action}{to_account}目标{target}U\n"
f"账簿日(UTC){transfer_day}|触发时刻(北京){app_now_str()}"
)
return
ok, msg, _ = execute_transfer_usdt(amount, fr, to)
_log(amount, fr, to, "success" if ok else "failed", msg)
if ok:
send_wechat_msg(
f"自动划转成功:{to_account} {trade}U→目标{target}U{action}{amount}U {fr}->{to}\n"
f"账簿日(UTC){transfer_day}|触发时刻(北京){app_now_str()}"
)
else:
send_wechat_msg(
f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U\n原因:{msg}\n"
f"账簿日(UTC){transfer_day}|触发时刻(北京){app_now_str()}"
)
+51
View File
@@ -0,0 +1,51 @@
"""防重复提交:Flask session 短窗口去重(下单 / 关键位等)。"""
from __future__ import annotations
import time
from typing import Any, Optional
DEFAULT_SUBMIT_GUARD_TTL = 90.0
def _prune_locks(locks: dict, now: float) -> dict:
return {k: float(v) for k, v in (locks or {}).items() if float(v) > now}
def check_duplicate_submit(
session: Any,
scope: str,
*,
ttl: float = DEFAULT_SUBMIT_GUARD_TTL,
) -> Optional[str]:
"""
同一 scope 在 ttl 秒内仅允许通过一次。
返回提示文案表示应拒绝;返回 None 表示可继续处理。
"""
scope = (scope or "").strip()
if not scope:
return None
now = time.time()
locks = _prune_locks(session.get("_form_submit_guard") or {}, now)
if scope in locks:
return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)"
locks[scope] = now + float(ttl)
session["_form_submit_guard"] = locks
try:
session.modified = True
except Exception:
pass
return None
def submit_scope_add_order(symbol: str, direction: str) -> str:
sym = (symbol or "").strip().upper()
d = (direction or "").strip().lower()
return f"add_order:{sym}:{d}"
def submit_scope_add_key(symbol: str, monitor_type: str, direction: str) -> str:
sym = (symbol or "").strip().upper()
mt = (monitor_type or "").strip()
d = (direction or "").strip().lower() or "watch"
return f"add_key:{sym}:{mt}:{d}"
+162
View File
@@ -0,0 +1,162 @@
"""列表/导出用 UTC 时间窗(Gate / Binance 主站共用)。"""
from datetime import datetime, timedelta, timezone
PRESET_UTC_TODAY = "utc_today"
PRESET_UTC_LAST24H = "utc_last24h"
PRESET_UTC_LAST7D = "utc_last7d"
PRESET_CUSTOM = "custom"
def utc_now():
return datetime.now(timezone.utc)
def utc_today_bounds(now=None):
now = now or utc_now()
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
return start, now
def resolve_window(query_mapping, default_preset=PRESET_UTC_TODAY):
"""
从 ?win_preset= & from_utc= & to_utc= 解析窗口。
返回 dict: preset, start_utc, end_utc, label, start_ms, end_ms
"""
preset = (query_mapping.get("win_preset") or default_preset or PRESET_UTC_TODAY).strip().lower()
now = utc_now()
if preset == PRESET_UTC_LAST24H:
start = now - timedelta(hours=24)
end = now
label = "近24小时(UTC)"
elif preset == PRESET_UTC_LAST7D:
start = now - timedelta(days=7)
end = now
label = "近7天(UTC)"
elif preset == PRESET_CUSTOM:
start = _parse_utc_input(query_mapping.get("from_utc")) or utc_today_bounds(now)[0]
end = _parse_utc_input(query_mapping.get("to_utc")) or now
if end < start:
start, end = end, start
label = f"{start.strftime('%Y-%m-%d %H:%M')} ~ {end.strftime('%Y-%m-%d %H:%M')} UTC"
else:
start, end = utc_today_bounds(now)
preset = PRESET_UTC_TODAY
label = f"UTC当日 {start.strftime('%Y-%m-%d')}"
return {
"preset": preset,
"start_utc": start,
"end_utc": end,
"label": label,
"start_ms": int(start.timestamp() * 1000),
"end_ms": int(end.timestamp() * 1000),
}
def _parse_utc_input(raw):
s = (raw or "").strip().replace("T", " ").replace("Z", "").strip()
if not s:
return None
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
dt = datetime.strptime(s[:n], fmt)
return dt.replace(tzinfo=timezone.utc)
except Exception:
continue
return None
def utc_window_to_bj_sql_strings(start_utc, end_utc, app_tz):
"""DB 存北京时间字符串时,用于 SQLite 字符串范围比较。"""
start_bj = start_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S")
end_bj = end_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S")
return start_bj, end_bj
def utc_window_to_utc_sql_strings(start_utc, end_utc):
"""SQLite CURRENT_TIMESTAMP 写入 UTC 时,用于 created_at 范围比较。"""
return (
start_utc.strftime("%Y-%m-%d %H:%M:%S"),
end_utc.strftime("%Y-%m-%d %H:%M:%S"),
)
def normalize_bj_datetime_storage(raw):
"""表单 datetime-local(含 T)入库前统一为 YYYY-MM-DD HH:MM:SS(北京时间)。"""
s = (raw or "").strip().replace("T", " ").replace("Z", "").strip()
if not s:
return ""
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
return datetime.strptime(s[:n], fmt).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
continue
return s
def sql_list_time_field(*columns):
"""
SQLite 列表时间窗比较表达式。
journal_entries 的 open/close 可能含 'T',直接与 bounds(空格格式)比会误判为超出上界。
单列时不用 COALESCESQLite 要求 COALESCE 至少 2 个参数)。
"""
cols = [c for c in columns if c]
if not cols:
raise ValueError("sql_list_time_field requires at least one column")
if len(cols) == 1:
return f"REPLACE({cols[0]}, 'T', ' ')"
return f"REPLACE(COALESCE({', '.join(cols)}), 'T', ' ')"
SESSION_KEY_LIST_WIN = "list_win_filter"
def query_mapping_from_session(session_store):
"""从 Flask session 恢复 win_preset / from_utc / to_utc。"""
if not session_store:
return {}
block = session_store.get(SESSION_KEY_LIST_WIN)
if not isinstance(block, dict):
return {}
preset = (block.get("preset") or "").strip()
if not preset:
return {}
return {
"win_preset": preset,
"from_utc": (block.get("from_utc") or "").strip(),
"to_utc": (block.get("to_utc") or "").strip(),
}
def resolve_list_window(query_mapping, session_store=None, default_preset=PRESET_UTC_TODAY):
"""
URL 带 win_preset 时解析并写入 session;无参数时用 session 中上次「应用」的预设。
"""
qm = query_mapping or {}
preset_in_q = (qm.get("win_preset") or "").strip()
if preset_in_q:
win = resolve_window(qm, default_preset=default_preset)
if session_store is not None:
session_store[SESSION_KEY_LIST_WIN] = {
"preset": win["preset"],
"from_utc": (qm.get("from_utc") or "").strip(),
"to_utc": (qm.get("to_utc") or "").strip(),
}
return win
stored = query_mapping_from_session(session_store)
if stored.get("win_preset"):
return resolve_window(stored, default_preset=default_preset)
return resolve_window(qm, default_preset=default_preset)
def list_window_redirect_query(session_store):
"""复盘/表单 POST 后重定向时附带列表筛选 query。"""
from urllib.parse import urlencode
stored = query_mapping_from_session(session_store)
if not stored.get("win_preset"):
return ""
params = {k: v for k, v in stored.items() if v}
return urlencode(params)
+150
View File
@@ -0,0 +1,150 @@
/* 账户风控状态徽章 — 四所实例 + 中控共用;兼容 data-theme light/dark */
:root,
html[data-theme="dark"] {
--risk-normal-fg: #9cf0c4;
--risk-normal-bg: rgba(36, 140, 96, 0.16);
--risk-normal-border: rgba(72, 190, 130, 0.42);
--risk-normal-glow: rgba(72, 190, 130, 0.35);
--risk-1h-fg: #ffd27a;
--risk-1h-bg: rgba(210, 150, 40, 0.16);
--risk-1h-border: rgba(230, 170, 60, 0.45);
--risk-1h-glow: rgba(230, 170, 60, 0.32);
--risk-4h-fg: #ffab8a;
--risk-4h-bg: rgba(210, 90, 55, 0.16);
--risk-4h-border: rgba(230, 110, 70, 0.48);
--risk-4h-glow: rgba(230, 110, 70, 0.34);
--risk-daily-fg: #ff9ec4;
--risk-daily-bg: rgba(190, 55, 100, 0.18);
--risk-daily-border: rgba(210, 75, 120, 0.5);
--risk-daily-glow: rgba(210, 75, 120, 0.36);
--risk-position-fg: #8ec8ff;
--risk-position-bg: rgba(55, 120, 210, 0.18);
--risk-position-border: rgba(75, 145, 230, 0.48);
--risk-position-glow: rgba(75, 145, 230, 0.34);
--risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
html[data-theme="light"] {
--risk-normal-fg: #056b44;
--risk-normal-bg: rgba(10, 143, 92, 0.14);
--risk-normal-border: rgba(8, 122, 80, 0.38);
--risk-normal-glow: rgba(10, 143, 92, 0.22);
--risk-1h-fg: #8a5a00;
--risk-1h-bg: rgba(200, 140, 20, 0.14);
--risk-1h-border: rgba(170, 115, 10, 0.38);
--risk-1h-glow: rgba(200, 140, 20, 0.2);
--risk-4h-fg: #a83812;
--risk-4h-bg: rgba(210, 85, 35, 0.12);
--risk-4h-border: rgba(180, 65, 25, 0.36);
--risk-4h-glow: rgba(210, 85, 35, 0.2);
--risk-daily-fg: #9a1248;
--risk-daily-bg: rgba(180, 35, 80, 0.1);
--risk-daily-border: rgba(155, 28, 68, 0.34);
--risk-daily-glow: rgba(180, 35, 80, 0.18);
--risk-position-fg: #0b5cab;
--risk-position-bg: rgba(20, 100, 190, 0.12);
--risk-position-border: rgba(15, 85, 165, 0.36);
--risk-position-glow: rgba(20, 100, 190, 0.2);
--risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1);
}
.risk-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.15;
padding: 5px 12px 5px 10px;
border-radius: 999px;
border: 1px solid var(--risk-border, transparent);
background: var(--risk-bg, transparent);
color: var(--risk-fg, inherit);
box-shadow: var(--risk-badge-shadow);
white-space: nowrap;
vertical-align: middle;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */
html[data-hub-linked="1"] .header-row .risk-status-badge {
transition: none;
}
.risk-status-badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent),
0 0 8px var(--risk-glow, currentColor);
opacity: 0.92;
}
.risk-status-normal {
--risk-fg: var(--risk-normal-fg);
--risk-bg: var(--risk-normal-bg);
--risk-border: var(--risk-normal-border);
--risk-glow: var(--risk-normal-glow);
}
.risk-status-freeze_1h {
--risk-fg: var(--risk-1h-fg);
--risk-bg: var(--risk-1h-bg);
--risk-border: var(--risk-1h-border);
--risk-glow: var(--risk-1h-glow);
}
.risk-status-freeze_4h {
--risk-fg: var(--risk-4h-fg);
--risk-bg: var(--risk-4h-bg);
--risk-border: var(--risk-4h-border);
--risk-glow: var(--risk-4h-glow);
}
.risk-status-freeze_daily {
--risk-fg: var(--risk-daily-fg);
--risk-bg: var(--risk-daily-bg);
--risk-border: var(--risk-daily-border);
--risk-glow: var(--risk-daily-glow);
}
.risk-status-freeze_position {
--risk-fg: var(--risk-position-fg);
--risk-bg: var(--risk-position-bg);
--risk-border: var(--risk-position-border);
--risk-glow: var(--risk-position-glow);
}
/* 实例页:与交易所标签并排 */
.header-row .risk-status-badge {
min-height: 28px;
}
/* 中控卡片标题内 */
.card-title .risk-status-badge,
.hub-tile-name .risk-status-badge {
font-size: 0.7rem;
padding: 3px 10px 3px 8px;
vertical-align: middle;
}
.card-title .risk-status-badge::before,
.hub-tile-name .risk-status-badge::before {
width: 6px;
height: 6px;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 账户风控徽章倒计时 — 四所实例 + 中控共用。
*/
(function (global) {
"use strict";
function formatRemaining(totalSec) {
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
if (sec <= 0) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
function baseLabel(riskStatus, el) {
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
return "正常";
}
function resolveFreezeUntilMs(riskStatus) {
if (!riskStatus) return null;
const sec = Number(riskStatus.freeze_remaining_sec);
if (Number.isFinite(sec) && sec > 0) {
return Date.now() + sec * 1000;
}
const until = Number(riskStatus.freeze_until_ms);
return Number.isFinite(until) && until > 0 ? until : null;
}
function badgeText(riskStatus) {
const label = baseLabel(riskStatus, null);
const until = resolveFreezeUntilMs(riskStatus);
if (!until || until <= Date.now()) return label;
const cd = formatRemaining((until - Date.now()) / 1000);
return cd ? `${label} · ${cd}` : label;
}
function setNormalBadge(el) {
el.className = "risk-status-badge risk-status-normal";
el.dataset.statusLabel = "正常";
el.textContent = "正常";
el.title = "";
if (el.dataset) delete el.dataset.freezeUntilMs;
}
function refreshElement(el) {
if (!el) return;
const label = baseLabel(null, el);
const until = Number(el.dataset && el.dataset.freezeUntilMs);
if (!Number.isFinite(until) || until <= Date.now()) {
if (el.dataset && el.dataset.freezeUntilMs) {
setNormalBadge(el);
} else {
el.textContent = label;
}
return;
}
const cd = formatRemaining((until - Date.now()) / 1000);
el.textContent = cd ? `${label} · ${cd}` : label;
}
function applyToElement(el, riskStatus) {
if (!el || !riskStatus) return;
const st = riskStatus.status || "normal";
el.className = "risk-status-badge risk-status-" + st;
el.dataset.statusLabel = baseLabel(riskStatus, el);
const until = resolveFreezeUntilMs(riskStatus);
if (until) {
el.dataset.freezeUntilMs = String(until);
} else if (el.dataset) {
delete el.dataset.freezeUntilMs;
}
el.textContent = badgeText(riskStatus);
el.title = riskStatus.reason || "";
}
function formatBadgeHtml(riskStatus, esc) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const safe = typeof esc === "function" ? esc : (s) => String(s);
const st = riskStatus.status || "normal";
const label = safe(riskStatus.status_label || "正常");
const title = safe(riskStatus.reason || "");
const text = safe(badgeText(riskStatus));
const until = resolveFreezeUntilMs(riskStatus);
const untilAttr =
until != null
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
: "";
return (
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
);
}
function tickAll(root) {
const scope = root || document;
scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement);
}
let timer = null;
function startTicker() {
if (timer) return;
tickAll();
timer = setInterval(() => tickAll(), 1000);
}
global.AccountRiskBadge = {
formatRemaining,
badgeText,
refreshElement,
applyToElement,
formatBadgeHtml,
tickAll,
startTicker,
};
})(typeof window !== "undefined" ? window : globalThis);
+223
View File
@@ -0,0 +1,223 @@
/**
* AI 日复盘 / 周复盘:Markdown 子集渲染 + 五节大标题图标兜底
*/
(function (global) {
"use strict";
var SECTION_FIXES = [
{ re: /^\*\*1\.\s*(?!📊)总体盈亏结构\*\*/m, rep: "**1. 📊 总体盈亏结构**" },
{ re: /^\*\*2\.\s*(?!🧠)心态与执行\*\*/m, rep: "**2. 🧠 心态与执行**" },
{ re: /^\*\*3\.\s*(?!🏷️)行为标签\*\*/m, rep: "**3. 🏷️ 行为标签**" },
{ re: /^\*\*4\.\s*(?!✅)改进建议\*\*/m, rep: "**4. ✅ 改进建议**" },
{ re: /^\*\*5\.\s*(?!📈)图表(?:分析)?\*\*/m, rep: "**5. 📈 图表分析**" },
{ re: /^1\.\s*(?!📊)总体盈亏结构/m, rep: "**1. 📊 总体盈亏结构**" },
{ re: /^2\.\s*(?!🧠)心态与执行/m, rep: "**2. 🧠 心态与执行**" },
{ re: /^3\.\s*(?!🏷️)行为标签/m, rep: "**3. 🏷️ 行为标签**" },
{ re: /^4\.\s*(?!✅)改进建议/m, rep: "**4. ✅ 改进建议**" },
{ re: /^5\.\s*(?!📈)图表/m, rep: "**5. 📈 图表分析**" },
];
function escapeHtml(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function parseInline(raw) {
var s = escapeHtml(raw);
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
return s;
}
function enhanceReviewHeadings(text) {
var out = String(text || "");
SECTION_FIXES.forEach(function (item) {
out = out.replace(item.re, item.rep);
});
if (/^【系统说明/m.test(out) && !/^️/m.test(out)) {
out = out.replace(/^【系统说明/gm, "️ 【系统说明");
}
if (/^原始记录:/m.test(out) && !/^📎/m.test(out)) {
out = out.replace(/^原始记录:/gm, "📎 **原始记录**");
}
return out;
}
function isNumberedListLine(trimmed) {
if (!trimmed) return false;
if (/^\d+\.\s+/.test(trimmed)) return true;
if (/^\*\*\d+\.\s*.+\*\*$/.test(trimmed)) return true;
return false;
}
/** 编号列表项之间的空行不拆段,避免每条都从 1 重新开始 */
function preprocessListBlanks(text) {
var lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
var out = [];
for (var i = 0; i < lines.length; i++) {
var trimmed = lines[i].trim();
if (!trimmed) {
var prevTrim = out.length ? String(out[out.length - 1]).trim() : "";
var nextTrim = "";
for (var j = i + 1; j < lines.length; j++) {
var t = lines[j].trim();
if (t) {
nextTrim = t;
break;
}
}
if (isNumberedListLine(prevTrim) && isNumberedListLine(nextTrim)) {
continue;
}
}
out.push(lines[i]);
}
return out.join("\n");
}
function renderMarkdown(text) {
var src = enhanceReviewHeadings(preprocessListBlanks(text));
var lines = src.replace(/\r\n/g, "\n").split("\n");
var html = [];
var inUl = false;
var inOl = false;
function closeLists() {
if (inUl) {
html.push("</ul>");
inUl = false;
}
if (inOl) {
html.push("</ol>");
inOl = false;
}
}
lines.forEach(function (line) {
var trimmed = line.trim();
if (!trimmed) {
closeLists();
return;
}
var hm = trimmed.match(/^(#{1,3})\s+(.+)$/);
if (hm) {
closeLists();
var level = hm[1].length + 1;
if (level > 4) level = 4;
html.push("<h" + level + ">" + parseInline(hm[2]) + "</h" + level + ">");
return;
}
var ulm = trimmed.match(/^[-*]\s+(.+)$/);
if (ulm) {
if (!inUl) {
closeLists();
html.push("<ul>");
inUl = true;
}
html.push("<li>" + parseInline(ulm[1]) + "</li>");
return;
}
var boldOl = trimmed.match(/^\*\*(\d+)\.\s*(.+)\*\*$/);
if (boldOl) {
if (!inOl) {
closeLists();
html.push("<ol>");
inOl = true;
}
html.push("<li>" + parseInline(trimmed) + "</li>");
return;
}
var olm = trimmed.match(/^\d+\.\s+(.+)$/);
if (olm) {
if (!inOl) {
closeLists();
html.push("<ol>");
inOl = true;
}
html.push("<li>" + parseInline(olm[1]) + "</li>");
return;
}
closeLists();
if (/^📎\s*\*\*原始记录\*\*/.test(trimmed) || /^原始记录:/.test(trimmed)) {
html.push('<div class="md-raw-block-title">' + parseInline(trimmed) + "</div>");
return;
}
html.push("<p>" + parseInline(trimmed) + "</p>");
});
closeLists();
return html.join("\n");
}
var _genBusy = false;
function setGenerating(opts) {
opts = opts || {};
_genBusy = true;
var wrap = document.getElementById(opts.wrapId);
var el = document.getElementById(opts.elId);
var btn = opts.btnId ? document.getElementById(opts.btnId) : null;
if (wrap) wrap.style.display = "block";
if (el) {
el.classList.remove("ai-result-md");
el.classList.add("is-loading");
el.innerHTML = "";
el.innerText = opts.message || "生成复盘中,请稍候…";
}
if (btn) {
btn.disabled = true;
if (!btn.dataset.aiOrigText) btn.dataset.aiOrigText = btn.textContent;
btn.textContent = opts.btnLabel || "生成中…";
}
if (wrap && wrap.scrollIntoView) {
try {
wrap.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch (e) { /* ignore */ }
}
}
function clearGenerating(btnId) {
_genBusy = false;
var btn = btnId ? document.getElementById(btnId) : null;
if (btn) {
btn.disabled = false;
if (btn.dataset.aiOrigText) {
btn.textContent = btn.dataset.aiOrigText;
delete btn.dataset.aiOrigText;
}
}
}
function isGenerating() {
return _genBusy;
}
function setElementMarkdown(el, rawText) {
if (!el) return;
var raw = String(rawText || "");
el.dataset.markdownRaw = raw;
el.classList.remove("is-loading");
el.classList.add("ai-result-md");
el.innerHTML = renderMarkdown(raw);
}
function getElementMarkdown(el) {
if (!el) return "";
if (el.dataset && el.dataset.markdownRaw != null) {
return el.dataset.markdownRaw;
}
return el.innerText || "";
}
global.AiReviewRender = {
enhanceReviewHeadings: enhanceReviewHeadings,
renderMarkdown: renderMarkdown,
setElementMarkdown: setElementMarkdown,
getElementMarkdown: getElementMarkdown,
setGenerating: setGenerating,
clearGenerating: clearGenerating,
isGenerating: isGenerating,
};
})(typeof window !== "undefined" ? window : this);
+221
View File
@@ -0,0 +1,221 @@
/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */
body.focus-page {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding: 14px;
margin: 0;
background: var(--focus-bg, #0b0d14);
color: var(--focus-fg, #eaeaea);
}
html[data-theme="light"] body.focus-page {
--focus-bg: #eef3f8;
--focus-fg: #142232;
--focus-card-bg: #fff;
--focus-card-border: #b8c8d8;
--focus-meta-bg: #fff;
--focus-meta-border: #9eb4c8;
--focus-meta-label: #2a4a66;
--focus-meta-value: #0a1628;
--focus-status: #4a6078;
--focus-chart-bg: #f0f4f9;
--focus-chart-border: #b8c8d8;
--focus-btn-bg: #fff;
--focus-btn-fg: #006e9a;
--focus-btn-border: rgba(0, 95, 140, 0.22);
--focus-input-bg: #fff;
--focus-input-fg: #142232;
--focus-input-border: #b8c8d8;
--focus-title: #0a1628;
--focus-pnl-up: #0a7a3d;
--focus-pnl-down: #c62828;
--focus-dir-short: #b71c1c;
--focus-dir-long: #0a7a3d;
}
html[data-theme="dark"] body.focus-page {
--focus-bg: #0b0d14;
--focus-fg: #eaeaea;
--focus-card-bg: #121726;
--focus-card-border: #2a3150;
--focus-meta-bg: #141b2f;
--focus-meta-border: #3d4f72;
--focus-meta-label: #c8d8f0;
--focus-meta-value: #f0f4ff;
--focus-status: #95a2c2;
--focus-chart-bg: #0f1320;
--focus-chart-border: #2a3150;
--focus-btn-bg: #151a2a;
--focus-btn-fg: #8fc8ff;
--focus-btn-border: #304164;
--focus-input-bg: #1a1a29;
--focus-input-fg: #fff;
--focus-input-border: #2e2e45;
--focus-title: #dbe4ff;
--focus-pnl-up: #3ddc84;
--focus-pnl-down: #ff7070;
--focus-dir-short: #ff8a80;
--focus-dir-long: #69f0ae;
}
body.focus-page * {
box-sizing: border-box;
}
.focus-page .container {
width: min(98vw, 1900px);
margin: 0 auto;
}
.focus-page .card {
background: var(--focus-card-bg);
border-radius: 10px;
padding: 12px;
border: 1px solid var(--focus-card-border);
margin-bottom: 12px;
}
.focus-page .row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.focus-page .btn {
padding: 7px 10px;
border-radius: 8px;
text-decoration: none;
border: 1px solid var(--focus-btn-border);
background: var(--focus-btn-bg);
color: var(--focus-btn-fg);
cursor: pointer;
}
.focus-page .btn:hover {
filter: brightness(1.06);
}
.focus-page select,
.focus-page input,
.focus-page button {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--focus-input-border);
background: var(--focus-input-bg);
color: var(--focus-input-fg);
}
.focus-page .focus-title {
color: var(--focus-title);
font-weight: 700;
}
.focus-page .meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
margin-top: 10px;
}
.focus-page .meta-item {
background: var(--focus-meta-bg);
border: 1px solid var(--focus-meta-border);
border-radius: 8px;
padding: 10px 10px 9px;
}
.focus-page .meta-item .k {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--focus-meta-label);
}
.focus-page .meta-item .v {
font-size: 1.02rem;
font-weight: 600;
margin-top: 5px;
word-break: break-all;
color: var(--focus-meta-value);
}
.focus-page .meta-item--emph {
border-width: 2px;
border-color: var(--focus-meta-label);
}
.focus-page .meta-item--emph .k {
font-size: 0.82rem;
font-weight: 700;
}
.focus-page .meta-item--emph .v {
font-size: 1.12rem;
font-weight: 800;
}
.focus-page .meta-item--pnl .v {
font-size: 1.14rem;
font-weight: 800;
letter-spacing: 0.01em;
}
.focus-page .meta-pnl-up {
color: var(--focus-pnl-up) !important;
}
.focus-page .meta-pnl-down {
color: var(--focus-pnl-down) !important;
}
.focus-page .meta-dir-long {
color: var(--focus-dir-long) !important;
}
.focus-page .meta-dir-short {
color: var(--focus-dir-short) !important;
}
.focus-page .status {
font-size: 0.84rem;
color: var(--focus-status);
}
.focus-page .status.err {
color: var(--focus-pnl-down);
}
.focus-page #chart-wrap {
height: 560px;
background: var(--focus-chart-bg);
border: 1px solid var(--focus-chart-border);
border-radius: 10px;
padding: 8px;
}
.focus-page #chart {
width: 100%;
height: 100%;
}
.focus-page .empty {
padding: 18px;
color: var(--focus-status);
}
.focus-page .exchange-tag {
font-size: 0.72rem;
font-weight: 600;
color: #b8f5d0;
background: #14241e;
border: 1px solid #2d6a4f;
padding: 4px 10px;
border-radius: 999px;
margin-left: 8px;
}
html[data-theme="light"] .focus-page .exchange-tag {
color: #0a5c38;
background: #e8f5ee;
border-color: #7bc9a0;
}
+401
View File
@@ -0,0 +1,401 @@
/**
* 实盘/关键位放大 K 线:交易所 tick 精度、主题感知图表、高对比 meta。
*/
(function (global) {
"use strict";
let activePriceTick = null;
function currentTheme() {
return document.documentElement.getAttribute("data-theme") === "light"
? "light"
: "dark";
}
function chartTheme(theme) {
if (theme === "light") {
return {
layout: { background: { color: "#f0f4f9" }, textColor: "#142232" },
grid: { vertLines: { color: "#d0dae4" }, horzLines: { color: "#d0dae4" } },
rightPriceScale: { borderColor: "#b8c8d8" },
timeScale: { borderColor: "#b8c8d8" },
candle: {
upColor: "#0a7a3d",
downColor: "#c62828",
wickUpColor: "#0a7a3d",
wickDownColor: "#c62828",
},
};
}
return {
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
rightPriceScale: { borderColor: "#2a3150" },
timeScale: { borderColor: "#2a3150" },
candle: {
upColor: "#4cd97f",
downColor: "#ff6666",
wickUpColor: "#4cd97f",
wickDownColor: "#ff6666",
},
};
}
const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 };
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const minMove = Number(tick);
if (minMove >= 1) return 0;
const raw = String(minMove);
const sci = raw.match(/e-(\d+)/i);
if (sci) return Math.min(12, parseInt(sci[1], 10));
const fixed = minMove.toFixed(12);
const frac = fixed.split(".")[1] || "";
const trimmed = frac.replace(/0+$/, "");
if (trimmed.length) return Math.min(12, trimmed.length);
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
}
function tickToPriceFormat(tick) {
try {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
return { type: "price", precision: 2, minMove: 0.01 };
}
const minMove = Number(tick);
let prec = decimalsFromTick(minMove);
if (prec == null || prec < 0) prec = 4;
prec = Math.min(12, Math.max(0, Math.floor(prec)));
return { type: "price", precision: prec, minMove: minMove };
} catch (_) {
return SAFE_PRICE_FORMAT;
}
}
function roundToTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return v;
const n = Number(v);
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n;
const t = Number(tick);
const rounded = Math.round(n / t) * t;
const dec = decimalsFromTick(t);
if (dec == null) return rounded;
return parseFloat(rounded.toFixed(dec));
}
function fmtPriceByTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(roundToTick(v, tick));
if (n === 0) return "0";
const dec = decimalsFromTick(tick);
if (dec != null) return n.toFixed(dec);
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
const text = n.toFixed(d);
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
}
function setActivePriceTick(tick) {
activePriceTick =
tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0
? null
: Number(tick);
}
function formatSigned(v, digits) {
digits = digits === undefined ? 2 : digits;
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const sign = n > 0 ? "+" : "";
return sign + n.toFixed(digits);
}
function formatSignedPrice(v) {
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const body = fmtPriceByTick(Math.abs(n), activePriceTick);
if (body === "-") return "-";
return (n > 0 ? "+" : n < 0 ? "-" : "") + body;
}
function formatRrRatio(rr) {
if (rr === null || typeof rr === "undefined") return "-:1";
const n = Number(rr);
if (Number.isNaN(n)) return "-:1";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function displayPrice(orderOrData, field, rawField) {
const dispKey = field + "_display";
if (orderOrData && orderOrData[dispKey] && orderOrData[dispKey] !== "-") {
return String(orderOrData[dispKey]);
}
const raw = orderOrData ? orderOrData[rawField || field] : null;
if (raw === null || typeof raw === "undefined" || Number.isNaN(Number(raw))) return "-";
return fmtPriceByTick(raw, activePriceTick);
}
function lineTitle(label, display) {
const d = display && display !== "-" ? display : "";
return d ? label + " " + d : label;
}
function paintOrderMeta(order) {
const symEl = document.getElementById("m-symbol");
const dirEl = document.getElementById("m-direction");
const pnlEl = document.getElementById("m-pnl");
if (symEl) symEl.textContent = order.symbol || "-";
if (dirEl) {
const isShort = order.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set("m-entry", displayPrice(order, "trigger_price"));
set("m-sl", displayPrice(order, "stop_loss"));
set("m-tp", displayPrice(order, "take_profit"));
set("m-rr", formatRrRatio(order.rr_ratio));
set(
"m-breakeven",
order.breakeven_enabled === false || order.breakeven_enabled === 0 ? "关闭" : "开启"
);
set(
"m-price",
order.current_price_display ||
order.price_display ||
displayPrice(order, "current_price")
);
if (pnlEl) {
pnlEl.textContent =
formatSigned(order.float_pnl, 2) +
"U (" +
formatSigned(order.float_pct, 2) +
"%)";
pnlEl.className = "v";
const pnl = Number(order.float_pnl || 0);
if (pnl > 0) pnlEl.classList.add("meta-pnl-up");
else if (pnl < 0) pnlEl.classList.add("meta-pnl-down");
}
}
function paintKeyMeta(data) {
const key = data.key_monitor || null;
const symEl = document.getElementById("m-symbol");
if (symEl) symEl.textContent = data.symbol || "-";
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set(
"m-price",
data.current_price_display || displayPrice(data, "current_price")
);
const dirEl = document.getElementById("m-direction");
if (!key) {
set("m-type", "未匹配到关键位");
set("m-direction", "-");
if (dirEl) dirEl.className = "v";
set("m-upper", "-");
set("m-lower", "-");
set("m-updiff", "-");
set("m-lowdiff", "-");
return;
}
set("m-type", key.monitor_type || "-");
if (dirEl) {
const isShort = key.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
set("m-upper", key.upper_display || displayPrice(key, "upper"));
set("m-lower", key.lower_display || displayPrice(key, "lower"));
if (activePriceTick != null) {
set(
"m-updiff",
formatSignedPrice(key.upper_diff) +
" (" +
formatSigned(key.upper_pct, 2) +
"%)"
);
set(
"m-lowdiff",
formatSignedPrice(key.lower_diff) +
" (" +
formatSigned(key.lower_pct, 2) +
"%)"
);
} else {
set(
"m-updiff",
formatSigned(key.upper_diff, 4) + " (" + formatSigned(key.upper_pct, 2) + "%)"
);
set(
"m-lowdiff",
formatSigned(key.lower_diff, 4) + " (" + formatSigned(key.lower_pct, 2) + "%)"
);
}
}
function applyPriceFormatToSeries(series, pf) {
if (!series || !series.applyOptions) return;
try {
series.applyOptions({ priceFormat: pf });
} catch (_) {
try {
series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT });
} catch (_2) {}
}
}
function createFocusChart(host) {
if (!global.LightweightCharts) return null;
const th = chartTheme(currentTheme());
const chart = global.LightweightCharts.createChart(host, {
layout: th.layout,
grid: th.grid,
rightPriceScale: th.rightPriceScale,
timeScale: Object.assign({ timeVisible: true, secondsVisible: false }, th.timeScale),
crosshair: { mode: 0 },
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
let candleSeries = null;
function applyChartPriceFormat() {
let pf = SAFE_PRICE_FORMAT;
try {
pf = tickToPriceFormat(activePriceTick);
} catch (_) {
pf = SAFE_PRICE_FORMAT;
}
applyPriceFormatToSeries(candleSeries, pf);
try {
chart.applyOptions({
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
} catch (_) {}
}
function setPriceTick(tick) {
setActivePriceTick(tick);
applyChartPriceFormat();
}
const opts = Object.assign({ borderVisible: false }, th.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(opts);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, opts);
}
applyChartPriceFormat();
const priceLines = [];
function resetPriceLines() {
if (!candleSeries) return;
priceLines.forEach(function (line) {
try {
candleSeries.removePriceLine(line);
} catch (_) {}
});
priceLines.length = 0;
}
function addLine(price, title, color) {
if (!candleSeries || price === null || typeof price === "undefined") return;
const p = Number(roundToTick(price, activePriceTick));
if (Number.isNaN(p) || p <= 0) return;
priceLines.push(
candleSeries.createPriceLine({
price: p,
color: color,
lineWidth: 1,
lineStyle: 0,
axisLabelVisible: true,
title: title,
})
);
}
function applyTheme() {
const t = chartTheme(currentTheme());
chart.applyOptions({
layout: t.layout,
grid: t.grid,
rightPriceScale: t.rightPriceScale,
timeScale: t.timeScale,
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
if (candleSeries && typeof candleSeries.applyOptions === "function") {
candleSeries.applyOptions(t.candle);
}
applyChartPriceFormat();
}
function resize() {
chart.applyOptions({ width: host.clientWidth, height: host.clientHeight });
}
global.addEventListener("resize", resize);
resize();
const obs = new MutationObserver(applyTheme);
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return {
chart: chart,
candleSeries: candleSeries,
resetPriceLines: resetPriceLines,
addLine: addLine,
applyTheme: applyTheme,
setPriceTick: setPriceTick,
ensureSeries: function () {
if (candleSeries) return true;
const t = chartTheme(currentTheme());
const o = Object.assign({ borderVisible: false }, t.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(o);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, o);
}
applyChartPriceFormat();
return !!candleSeries;
},
};
}
global.FocusChartPage = {
currentTheme: currentTheme,
chartTheme: chartTheme,
formatSigned: formatSigned,
formatRrRatio: formatRrRatio,
displayPrice: displayPrice,
lineTitle: lineTitle,
paintOrderMeta: paintOrderMeta,
paintKeyMeta: paintKeyMeta,
createFocusChart: createFocusChart,
setActivePriceTick: setActivePriceTick,
fmtPriceByTick: fmtPriceByTick,
};
})(typeof window !== "undefined" ? window : globalThis);
+80
View File
@@ -0,0 +1,80 @@
/**
* 表单提交防重复:网络慢时禁用按钮并显示「提交中」。
*/
(function (global) {
"use strict";
function submitButtons(form) {
if (!form) return [];
return Array.prototype.slice.call(
form.querySelectorAll('button[type="submit"], input[type="submit"]')
);
}
function lockForm(form, label) {
if (!form) return false;
if (form.dataset.submitGuard === "locked") return false;
form.dataset.submitGuard = "locked";
form.classList.add("is-form-submitting");
submitButtons(form).forEach(function (btn) {
if (btn.dataset.submitGuardOrig === undefined) {
btn.dataset.submitGuardOrig =
btn.tagName === "BUTTON" ? btn.textContent : btn.value;
}
btn.disabled = true;
if (label) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
}
});
return true;
}
function unlockForm(form) {
if (!form) return;
delete form.dataset.submitGuard;
form.classList.remove("is-form-submitting");
submitButtons(form).forEach(function (btn) {
btn.disabled = false;
var orig = btn.dataset.submitGuardOrig;
if (orig !== undefined) {
if (btn.tagName === "BUTTON") btn.textContent = orig;
else btn.value = orig;
delete btn.dataset.submitGuardOrig;
}
});
}
function isLocked(form) {
return !!(form && form.dataset.submitGuard === "locked");
}
/** 已锁定时仅更新按钮文案(校验通过 → 真正提交前) */
function setSubmitLabel(form, label) {
if (!form || !label) return;
submitButtons(form).forEach(function (btn) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
});
}
/** 已通过前端校验,发起最终 POST(页面将跳转) */
function nativeSubmitOnce(form, label) {
if (!form) return;
var text = label || "提交中…";
if (form.dataset.submitGuard === "locked") {
setSubmitLabel(form, text);
} else {
lockForm(form, text);
}
form.submit();
}
global.FormSubmitGuard = {
lock: lockForm,
unlock: unlockForm,
isLocked: isLocked,
setSubmitLabel: setSubmitLabel,
nativeSubmitOnce: nativeSubmitOnce,
};
})(typeof window !== "undefined" ? window : this);
+262
View File
@@ -0,0 +1,262 @@
/**
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>。
*/
(function (global) {
const TAB_PATH = {
key_monitor: "/key_monitor",
trade: "/trade",
strategy: "/strategy",
strategy_records: "/strategy/records",
records: "/records",
stats: "/stats",
};
let navToken = 0;
let loadingTab = false;
/** 自带校验后 form.submit() 的表单,勿在捕获阶段再 fetch 一份(会双发 POST */
const CUSTOM_SUBMIT_FORM_IDS = new Set(["add-order-form", "key-form"]);
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function getTab() {
try {
const t = new URLSearchParams(location.search).get("tab");
if (t) return t;
} catch (_) {}
return document.body.getAttribute("data-page") || "trade";
}
function listWindowQueryString() {
if (typeof global.listWindowQueryString === "function") {
return global.listWindowQueryString();
}
return "";
}
function setRootLoading(on) {
const root = document.getElementById("embed-page-root");
if (root) root.classList.toggle("is-embed-tab-loading", !!on);
}
function setNavActive(tab) {
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab);
});
}
function syncUrl(tab, replace) {
const q = new URLSearchParams(location.search);
q.set("tab", tab);
q.set("embed", "1");
const qs = q.toString();
const url = "/embed?" + qs;
if (replace) history.replaceState({ embedTab: tab }, "", url);
else history.pushState({ embedTab: tab }, "", url);
}
function runPageInit(tab) {
document.body.setAttribute("data-page", tab);
if (typeof global.attachListWindowToExports === "function") {
global.attachListWindowToExports();
}
if (tab === "trade") {
if (typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
if (global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
global.ManualOrderRrPreview.wire();
}
}
if (tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
global.KeyMonitorForm.init();
}
if (tab === "records") {
if (typeof global.loadJournals === "function") global.loadJournals();
if (typeof global.loadReviews === "function") global.loadReviews();
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
}
if (tab === "stats") {
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
}
if (typeof global.refreshPriceSnapshotConditional === "function") {
global.refreshPriceSnapshotConditional();
}
}
function injectFragment(html) {
const root = document.getElementById("embed-page-root");
if (!root) return;
root.innerHTML = html;
root.querySelectorAll("script").forEach((old) => {
const s = document.createElement("script");
if (old.src) s.src = old.src;
else s.textContent = old.textContent;
old.replaceWith(s);
});
}
async function loadTab(tab, opts) {
const options = opts || {};
if (!tab || loadingTab) return;
const token = ++navToken;
loadingTab = true;
setRootLoading(true);
try {
const qs = listWindowQueryString();
const url = "/api/embed/page/" + encodeURIComponent(tab) + (qs ? "?" + qs : "");
const r = await fetch(url, { credentials: "same-origin" });
if (token !== navToken) return;
const j = await r.json();
if (!j.ok || !j.html) throw new Error(j.msg || "加载失败");
injectFragment(j.html);
setNavActive(tab);
if (!options.skipUrl) syncUrl(tab, !!options.replace);
runPageInit(tab);
} catch (e) {
if (token === navToken) {
const flash = document.getElementById("embed-flash");
if (flash) {
flash.style.display = "";
flash.textContent = String(e && e.message ? e.message : e);
}
}
} finally {
if (token === navToken) {
loadingTab = false;
setRootLoading(false);
}
}
}
function reloadCurrentTab() {
return loadTab(getTab(), { replace: true, skipUrl: true });
}
function postFormAndReload(form, label) {
if (!form) return Promise.resolve();
if (global.FormSubmitGuard) {
if (global.FormSubmitGuard.isLocked(form)) {
global.FormSubmitGuard.setSubmitLabel(form, label || "提交中…");
} else {
global.FormSubmitGuard.lock(form, label || "提交中…");
}
}
const fd = new FormData(form);
return fetch(form.action, {
method: form.method || "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
})
.then(() => reloadCurrentTab())
.catch(() => reloadCurrentTab());
}
function patchApplyListWindow() {
if (typeof global.applyListWindow !== "function") return;
global.applyListWindow = function embedApplyListWindow() {
const qs = listWindowQueryString();
const tab = getTab();
const q = new URLSearchParams(qs);
q.set("tab", tab);
q.set("embed", "1");
window.location.href = "/embed?" + q.toString();
};
}
function patchHardNavigations() {
const resubmitPaths =
/^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/;
document.addEventListener(
"click",
(ev) => {
if (!isEmbedShell()) return;
const a = ev.target.closest("a[href]");
if (!a || ev.defaultPrevented) return;
if (a.closest(".embed-top-nav")) return;
if (a.hasAttribute("download") || a.target === "_blank") return;
const raw = a.getAttribute("href");
if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return;
let url;
try {
url = new URL(raw, location.href);
} catch (_) {
return;
}
if (url.origin !== location.origin) return;
if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) {
return;
}
if (!resubmitPaths.test(url.pathname)) return;
ev.preventDefault();
fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" })
.then(() => reloadCurrentTab())
.catch(() => reloadCurrentTab());
},
false
);
document.addEventListener(
"submit",
(ev) => {
if (!isEmbedShell()) return;
const form = ev.target;
if (!(form instanceof HTMLFormElement)) return;
if (form.method && form.method.toUpperCase() === "GET") return;
if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return;
ev.preventDefault();
const fd = new FormData(form);
fetch(form.action, {
method: form.method || "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
})
.then(() => reloadCurrentTab())
.catch(() => reloadCurrentTab());
},
true
);
}
function bindNav() {
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.addEventListener("click", (ev) => {
ev.preventDefault();
const tab = a.getAttribute("data-embed-tab");
if (!tab || tab === getTab()) return;
void loadTab(tab);
});
});
window.addEventListener("popstate", () => {
const tab = getTab();
void loadTab(tab, { replace: true, skipUrl: true });
});
}
function boot() {
if (!isEmbedShell()) return;
patchApplyListWindow();
patchHardNavigations();
bindNav();
runPageInit(getTab());
try {
window.parent.postMessage({ type: "instance-frame-ready" }, "*");
} catch (_) {}
}
global.InstanceEmbed = {
loadTab,
reloadCurrentTab,
getTab,
postFormAndReload,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})(typeof window !== "undefined" ? window : globalThis);
+231
View File
@@ -0,0 +1,231 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px}
.container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)}
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
.stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch}
.stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152}
.stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%}
.stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center}
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150}
.full{grid-column:1/-1}
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
.order-preview-risk{color:#ff6b6b}
.order-preview-risk strong{color:#ff8f8f;font-weight:600}
.order-preview-profit{color:#4cd97f}
.order-preview-profit strong{color:#6ee7a0;font-weight:600}
.order-preview-rr{color:#cfd3ef}
.order-preview-rr strong{font-weight:600;color:#dbe4ff}
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
.form-row > button,.form-row > label{flex:0 0 auto}
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
.journal-card .form-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
.journal-card .form-grid > input,
.journal-card .form-grid > select{
min-width:0;
width:100%;
max-width:100%;
}
.journal-card .form-grid select[name="entry_reason"]{
grid-column:1/-1;
font-size:.8rem;
line-height:1.35;
}
.journal-card .form-grid input[name="entry_reason_custom"]{
grid-column:1/-1;
font-size:.8rem;
}
input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none}
button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer}
.list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto}
.list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px}
.btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem}
.rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px}
table{width:100%;border-collapse:collapse}
th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem}
th{color:#a9a9ff}
.badge{padding:2px 6px;border-radius:6px;font-size:.72rem}
.profit{background:#1e332f;color:#4cd97f}
.loss{background:#331e24;color:#ff6666}
.miss{background:#29241e;color:#eac147}
.direction{background:#1e2533;color:#4cc2ff}
.direction-long{background:#1e332f;color:#4cd97f}
.direction-short{background:#331e24;color:#ff6666}
.pnl-profit{color:#4cd97f;font-weight:600}
.pnl-loss{color:#ff6666;font-weight:600}
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
form.is-form-submitting{opacity:.88;pointer-events:none}
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
.ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0}
.ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5}
.ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600}
.ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45}
.ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px}
.ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em}
.ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600}
.price-up{color:#4cd97f}
.price-down{color:#ff6666}
.price-flat{color:#cfd3ef}
.panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto}
.entry{border-bottom:1px solid #2b2b43;padding:8px 0}
.entry:last-child{border-bottom:none}
.table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem}
.mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
.mood-grid label{display:flex;align-items:center;gap:3px}
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1210}
.modal img{max-width:90%;max-height:90%;border-radius:8px}
.detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px}
.detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}
.detail-modal .panel-title{font-size:1rem;color:#dbe4ff}
.detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer}
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
.detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0}
.detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem}
.detail-modal.fullscreen{padding:10px}
.detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden}
.detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem}
.ai-result-wrap{margin-top:8px}
.ai-result-toolbar{display:flex;gap:8px;margin-top:6px}
.ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer}
.table-wrap{overflow-x:auto}
.dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
.records-card{grid-column:1/-1}
.review-card{grid-column:1/-1}
.review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}
.review-card-head h2{margin:0}
.review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap}
.review-card-fs-btn:hover{filter:brightness(1.08)}
body.review-card-fullscreen-open{overflow:hidden}
.review-card.is-fullscreen{
position:fixed;inset:12px;z-index:1100;margin:0;
width:auto !important;max-width:none;height:auto;
overflow:auto;display:flex;flex-direction:column;
box-shadow:0 12px 48px rgba(0,0,0,.55);
}
.review-card.is-fullscreen .panel-list{flex:1;min-height:320px}
.review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px}
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
@media (max-width: 1200px){
.stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
}
@media (min-width: 1440px){
.panel-scroll,.pos-list{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto}
}
@media (min-width: 2200px){
.container{max-width:min(1720px,90vw)}
}
@media (min-width: 2560px){
.container{max-width:min(1860px,88vw)}
.dual-panel-grid{gap:18px}
}
@media (min-width: 3000px){
.container{max-width:min(1980px,86vw)}
.pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
}
@media (max-width: 1100px){
.grid{grid-template-columns:1fr}
.dual-panel-grid{grid-template-columns:1fr}
.records-card,.review-card{grid-column:auto}
.panel-list{grid-template-columns:1fr}
}
@media (max-width: 960px){
body{padding:10px}
.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.stat-box{grid-template-columns:repeat(2,minmax(0,1fr))}
}
.stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px}
.stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px}
.stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem}
.stats-detail .stat-item .label{font-size:.75rem}
.stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all}
.export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem}
.export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a}
.export-bar a:hover{background:#1f2740}
.list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem}
.list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px}
.stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468}
.stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px}
.key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150}
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
.key-history .list{max-height:200px}
.pos-section{margin-top:12px}
.pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500}
.pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto}
.dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto}
.dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible}
.pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px}
.pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0}
.pos-meta-item{display:inline-flex;align-items:center}
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
.pos-meta-on{color:#6eb5ff}
.pos-meta-off{color:#7d8799}
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
.pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600}
.pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
.pos-side-long{background:#253a6e;color:#6eb5ff}
.pos-side-short{background:#4a2230;color:#ff8a8a}
.pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
.pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap}
.pos-entrust-btn:hover{background:#355d96}
.pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block}
.pos-close-btn:hover{background:#d66565;color:#fff}
.pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348}
.pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px}
.pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px}
.pos-ex-order-main{flex:1;min-width:0;line-height:1.35}
.pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0}
.pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed}
.tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px}
.tpsl-modal-backdrop.open{display:flex}
.tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto}
.tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff}
.tpsl-modal .form-row{margin-bottom:10px}
.tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
.tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
.tpsl-modal-submit{background:#2d6a4f;color:#fff}
.tpsl-modal-cancel{background:#3a3f52;color:#ddd}
.pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px}
.pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0}
.pos-label{font-size:.72rem;color:#7d8799}
.pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25}
.pos-val-dash{opacity:.75;color:#8b95a8}
.pos-value.price-up{color:#4cd97f}
.pos-value.price-down{color:#ff6666}
.pos-value.price-flat{color:#e8ecf4}
.pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689}
.pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
.stats-card{grid-column:1/-1;margin-top:14px}
.stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer}
.stats-card.collapsed .stats-content{display:none}
.stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150}
.stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
#embed-page-root{transition:opacity .12s ease}
#embed-page-root.is-embed-tab-loading{opacity:.55;pointer-events:none}
@@ -0,0 +1,74 @@
/**
* 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情。
*/
(function (global) {
"use strict";
var resizeTimer = null;
function refreshTradeRecords() {
var UI = global.InstanceUI;
if (!UI) return;
var card = document.querySelector(".records-card");
if (!card) return;
var tableWrap = card.querySelector(".table-wrap");
var table = tableWrap && tableWrap.querySelector("table");
if (!table) return;
var listEl = card.querySelector(".mobile-record-list");
var mobile = UI.isMobileCompactRecords();
if (!mobile) {
if (listEl) listEl.remove();
return;
}
if (!listEl) {
listEl = document.createElement("div");
listEl.className = "mobile-record-list";
tableWrap.parentNode.insertBefore(listEl, tableWrap);
}
var rows = table.querySelectorAll('tr[id^="trade-row-"]');
listEl.innerHTML = rows.length
? Array.prototype.map
.call(rows, function (tr) {
return UI.renderMobileTradeRow(tr);
})
.join("")
: '<div class="journal-empty-msg">暂无交易记录</div>';
listEl.querySelectorAll(".mobile-record-row").forEach(function (btn) {
btn.addEventListener("click", function () {
var rowId = btn.getAttribute("data-row-id");
var tr = rowId && document.getElementById(rowId);
if (tr) UI.openTradeRecordDetailModal(tr);
});
});
}
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
refreshTradeRecords();
if (typeof global.loadJournals === "function" && document.getElementById("journal-list")) {
global.loadJournals();
}
}, 180);
}
function init() {
refreshTradeRecords();
global.addEventListener("resize", onResize);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
global.InstanceRecordsMobile = {
refresh: refreshTradeRecords,
};
})(typeof window !== "undefined" ? window : globalThis);
File diff suppressed because it is too large Load Diff
+572
View File
@@ -0,0 +1,572 @@
/**
* 四所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO 随 hub-theme 联动。
*/
(function (global) {
const STANDALONE_KEY = "instance-theme";
const HUB_LINKED_THEME_KEY = "hub-linked-theme";
const META = { dark: "#0b0d14", light: "#d8e2ec" };
function normalize(theme) {
return theme === "light" ? "light" : "dark";
}
function isHubLinked() {
try {
const p = new URLSearchParams(location.search);
if (p.get("embed") === "1") return true;
const ht = p.get("hub_theme");
if (ht === "light" || ht === "dark") return true;
} catch (_) {}
try {
if (window.self !== window.top) return true;
} catch (_) {
return true;
}
return false;
}
function themeFromUrl() {
try {
const t = new URLSearchParams(location.search).get("hub_theme");
if (t === "light" || t === "dark") return t;
} catch (_) {}
return null;
}
function readLinkedThemeStorage() {
try {
const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY);
if (t === "light" || t === "dark") return t;
} catch (_) {}
return null;
}
function writeLinkedThemeStorage(theme) {
if (!isHubLinked()) return;
try {
sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme));
} catch (_) {}
}
function getStandalone() {
try {
return normalize(localStorage.getItem(STANDALONE_KEY));
} catch (_) {
return "dark";
}
}
function setStandalone(theme) {
try {
localStorage.setItem(STANDALONE_KEY, normalize(theme));
} catch (_) {}
}
let _linkedTheme = null;
let _appliedTheme = null;
function get() {
if (isHubLinked()) {
return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark";
}
return getStandalone();
}
/** 模板内联暗色 → 亮色(切换时重写 style 属性) */
const INLINE_HEX_LIGHT = {
"#cfd3ef": "#1a2838",
"#8892b0": "#4a6078",
"#9aa3c4": "#4a6078",
"#8b95a8": "#4a6078",
"#8b95b8": "#4a6078",
"#6a7598": "#4a6078",
"#7d8799": "#4a6078",
"#6d7689": "#4a6078",
"#dbe4ff": "#142232",
"#f0f2ff": "#142232",
"#e8ecf4": "#142232",
"#c5cce0": "#4a6078",
"#b8c4ff": "#142232",
"#8fc8ff": "#006e9a",
"#6ab8ff": "#006e9a",
"#6eb5ff": "#006e9a",
"#101522": "#ffffff",
"#121726": "#ffffff",
"#141423": "#ffffff",
"#24243b": "#b8c8d8",
"#252a45": "#b8c8d8",
"#252538": "#eef3f8",
"#1a1a29": "#f6f9fc",
"#2e2e45": "#b8c8d8",
"#2b2b43": "#d0dae4",
"#151a2a": "#eef3f8",
"#141a2a": "#ffffff",
"#141923": "#ffffff",
"#141a2e": "#ffffff",
"#0f1424": "#f6f9fc",
"#0f1420": "#f6f9fc",
"#0f1117": "#d8e2ec",
"#1a2034": "#eef3f8",
"#1a2030": "#ffffff",
"#1f3a5a": "#e8eef5",
"#2f2f44": "#dde5ec",
"#2a3f6c": "rgba(0,110,154,0.14)",
"#304164": "rgba(0,95,140,0.22)",
"#2a3150": "#b8c8d8",
"#2a3152": "#b8c8d8",
"#3a5a8a": "rgba(0,95,140,0.35)",
"#2a3348": "#b8c8d8",
"#243050": "rgba(0,75,115,0.16)",
"#2a3558": "#d0dae4",
"#3a4468": "#c8d4e0",
"#3a4a66": "#b8c8d8",
"#3a3f52": "#dde5ec",
"#3d4659": "#b8c8d8",
"#1f2740": "#eef3f8",
"#1f2a44": "rgba(0,110,154,0.1)",
"#1f4a3a": "#e8f5ef",
"#2a4a7a": "#e8eef5",
"#3a3048": "#eef3f8",
"#d4b8ff": "#5b4fc7",
"#e6e8ef": "#1a2838",
};
function remapInlineStyle(style, theme) {
if (!style) return style;
if (theme !== "light") return style;
const hadSecondaryBtnBg = /#1f3a5a/i.test(style);
let out = style;
for (const [from, to] of Object.entries(INLINE_HEX_LIGHT)) {
out = out.replace(new RegExp(from.replace("#", "\\#"), "gi"), to);
}
if (hadSecondaryBtnBg && !/color\s*:/i.test(style)) {
out = `${out.replace(/;+\s*$/, "")};color:#006e9a`;
}
return out;
}
function syncInlineStyles(theme, root) {
const scope = root || document;
scope.querySelectorAll("[style]").forEach((el) => {
const raw = el.getAttribute("style");
if (!raw) return;
if (!el.dataset.instStyleBase) {
el.dataset.instStyleBase = raw;
}
const base = el.dataset.instStyleBase;
el.setAttribute("style", theme === "light" ? remapInlineStyle(base, "light") : base);
});
}
function mergeHubQueryIntoHref(href, theme) {
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return href;
try {
const u = new URL(href, location.origin);
if (u.origin !== location.origin) return href;
if (isHubLinked()) {
u.searchParams.set("embed", "1");
if (theme === "light" || theme === "dark") {
u.searchParams.set("hub_theme", theme);
}
}
return u.pathname + u.search + u.hash;
} catch (_) {
return href;
}
}
function patchHubNavLinks(theme) {
if (!isHubLinked()) return;
const t = normalize(theme || get());
document
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
.forEach((a) => {
const href = a.getAttribute("href");
if (!href) return;
const next = mergeHubQueryIntoHref(href, t);
if (next !== href) a.setAttribute("href", next);
});
}
function apply(theme, opts) {
const options = opts || {};
const linked = isHubLinked();
const t = normalize(theme);
const root = document.documentElement;
const unchanged =
!options.force &&
_appliedTheme === t &&
root.getAttribute("data-theme") === t;
if (unchanged) {
return t;
}
_appliedTheme = t;
if (linked) {
_linkedTheme = t;
writeLinkedThemeStorage(t);
root.setAttribute("data-hub-linked", "1");
} else {
root.removeAttribute("data-hub-linked");
}
if (!linked && !options.skipStore) {
setStandalone(t);
}
root.setAttribute("data-theme", t);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", META[t]);
root.style.colorScheme = t;
if (document.body) {
syncInlineStyles(t);
patchHubNavLinks(t);
} else {
document.addEventListener(
"DOMContentLoaded",
function onDom() {
syncInlineStyles(t);
patchHubNavLinks(t);
},
{ once: true }
);
}
syncToggleUI();
document.dispatchEvent(
new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } })
);
return t;
}
function syncToggleUI(root) {
const scope = root || document;
const linked = isHubLinked();
const toggle = scope.querySelector(".instance-theme-toggle");
if (toggle) {
toggle.classList.toggle("is-hub-linked", linked);
toggle.setAttribute("aria-hidden", linked ? "true" : "false");
}
if (linked) return;
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
const on = btn.getAttribute("data-theme-value") === getStandalone();
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
});
}
function initToggleUI(root) {
const scope = root || document;
syncToggleUI(scope);
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
if (btn.dataset.themeBound === "1") return;
btn.dataset.themeBound = "1";
btn.addEventListener("click", () => {
if (isHubLinked()) return;
apply(btn.getAttribute("data-theme-value"));
});
});
}
function initMobileTopNav() {
const mq = window.matchMedia("(max-width: 720px)");
function scrollActiveTab(nav) {
const active = nav.querySelector("a.active");
if (!active) return;
requestAnimationFrame(() => {
try {
active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" });
} catch (_) {
active.scrollIntoView(false);
}
});
}
function apply() {
if (!mq.matches) return;
document.querySelectorAll(".top-nav").forEach(scrollActiveTab);
}
apply();
mq.addEventListener("change", apply);
window.addEventListener("resize", apply);
window.addEventListener("orientationchange", apply);
}
function initFromHubMessage(data) {
if (!data || data.type !== "hub-theme-sync") return;
if (!isHubLinked()) return;
apply(data.theme, { skipStore: true });
}
/** 交易记录页:核对开关与按钮 disabled 保持同步(iframe 软导航/表单恢复后不触发 change) */
function syncReviewEditButtons() {
const toggle = document.getElementById("review-mode-toggle");
if (!toggle) return;
const on = !!toggle.checked;
document.querySelectorAll(".review-edit-btn").forEach((btn) => {
btn.disabled = !on;
});
}
function initReviewEditModeSync() {
const toggle = document.getElementById("review-mode-toggle");
if (!toggle) return;
if (toggle.dataset.instReviewModeBound !== "1") {
toggle.dataset.instReviewModeBound = "1";
toggle.addEventListener("input", () => {
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
else syncReviewEditButtons();
});
}
const run = () => {
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
else syncReviewEditButtons();
};
run();
requestAnimationFrame(run);
setTimeout(run, 0);
if (!global.__instReviewModePageshowBound) {
global.__instReviewModePageshowBound = true;
window.addEventListener("pageshow", run);
}
}
function notifyParentFrameNavStart() {
if (!isHubLinked()) return;
try {
window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*");
} catch (_) {}
}
function notifyParentFrameReady() {
if (!isHubLinked()) return;
dismissNavOverlay();
try {
window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*");
} catch (_) {}
}
function ensureNavOverlay() {
const t = normalize(get());
const bg = META[t];
let el = document.getElementById("inst-nav-overlay");
if (!el) {
el = document.createElement("div");
el.id = "inst-nav-overlay";
el.setAttribute("aria-hidden", "true");
(document.body || document.documentElement).appendChild(el);
}
el.style.cssText =
"position:fixed;inset:0;z-index:2147483646;background:" +
bg +
";opacity:1;pointer-events:auto;transition:opacity 80ms ease;";
return el;
}
function dismissNavOverlay() {
const el = document.getElementById("inst-nav-overlay");
if (!el) return;
el.style.opacity = "0";
window.setTimeout(() => {
try {
el.remove();
} catch (_) {}
}, 90);
}
function injectNavOverlayIntoHtml(html, theme) {
const t = normalize(theme || get());
const bg = META[t];
let out = html || "";
const guard =
'<style id="inst-nav-guard">html,body{background:' +
bg +
"!important;color-scheme:" +
t +
';}</style>';
if (out.includes("</head>")) {
out = out.replace("</head>", guard + "</head>");
} else {
out = guard + out;
}
out = out.replace(/<html([^>]*)>/i, (m, attrs) => {
if (/data-theme=/i.test(attrs)) {
return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"');
}
return "<html" + attrs + ' data-theme="' + t + '">';
});
const overlay =
'<div id="inst-nav-overlay" aria-hidden="true" style="position:fixed;inset:0;z-index:2147483646;background:' +
bg +
';opacity:1;pointer-events:auto"></div>';
if (/<body[^>]*>/i.test(out)) {
out = out.replace(/<body([^>]*)>/i, "<body$1>" + overlay);
}
return out;
}
/** 中控 iframefetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白。 */
function initHubEmbedInFrameNav() {
if (!isHubLinked()) return;
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
let navToken = 0;
function isSoftNavLink(a) {
if (!a || !a.getAttribute) return false;
if (a.hasAttribute("download") || a.target === "_blank") return false;
return !!a.closest(".top-nav, .strategy-subnav");
}
function softNavFetch(href) {
return fetch(href, {
credentials: "same-origin",
headers: { "X-Instance-Soft-Nav": "1" },
});
}
async function navigateInFrame(href, opts) {
const token = ++navToken;
notifyParentFrameNavStart();
ensureNavOverlay();
try {
const r = await softNavFetch(href);
if (token !== navToken) return;
if (!r.ok) {
location.assign(href);
return;
}
let html = await r.text();
if (token !== navToken) return;
html = injectNavOverlayIntoHtml(html, get());
let path = href;
try {
const u = new URL(href, location.href);
path = u.pathname + u.search + u.hash;
} catch (_) {}
if (opts && opts.replace) history.replaceState(null, "", path);
else history.pushState(null, "", path);
document.open();
document.write(html);
document.close();
} catch (_) {
if (token === navToken) location.assign(href);
}
}
document.addEventListener(
"click",
(ev) => {
const a = ev.target.closest("a[href]");
if (!a || !isSoftNavLink(a) || ev.defaultPrevented) return;
if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
const rawHref = a.getAttribute("href");
if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("javascript:")) return;
let target;
try {
target = new URL(rawHref, location.href);
} catch (_) {
return;
}
if (target.origin !== location.origin) return;
const nextHref = target.pathname + target.search + target.hash;
if (target.pathname === location.pathname && target.search === location.search) return;
ev.preventDefault();
void navigateInFrame(nextHref);
},
true
);
window.addEventListener("popstate", () => {
void navigateInFrame(location.pathname + location.search + location.hash, { replace: true });
});
}
function purgeLegacySoftNavCache() {
try {
for (let i = localStorage.length - 1; i >= 0; i -= 1) {
const key = localStorage.key(i);
if (!key) continue;
if (
key.startsWith("inst-pc:") ||
key === "inst-page-cache-index" ||
key === "inst-page-cache-days"
) {
localStorage.removeItem(key);
}
}
sessionStorage.removeItem("inst-soft-nav");
sessionStorage.removeItem("inst-cache-revalidate");
} catch (_) {}
}
function boot() {
purgeLegacySoftNavCache();
if (isHubLinked()) {
apply(get(), { skipStore: true });
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
initHubEmbedInFrameNav();
try {
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
} catch (_) {}
} else {
apply(getStandalone());
}
function observeDynamicLists() {
["journal-list", "review-list"].forEach((id) => {
const el = document.getElementById(id);
if (!el || el.dataset.instThemeObserved === "1") return;
el.dataset.instThemeObserved = "1";
new MutationObserver(() => {
syncInlineStyles(get());
patchHubNavLinks(get());
}).observe(el, {
childList: true,
subtree: true,
});
});
}
const onReady = () => {
initToggleUI();
initMobileTopNav();
initReviewEditModeSync();
syncInlineStyles(get());
patchHubNavLinks(get());
observeDynamicLists();
if (isHubLinked()) {
requestAnimationFrame(() => {
requestAnimationFrame(() => notifyParentFrameReady());
});
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onReady);
} else {
onReady();
}
document.addEventListener("instance-theme-change", (ev) => {
const t = ev.detail && ev.detail.theme;
if (t) {
syncInlineStyles(t);
patchHubNavLinks(t);
}
});
}
boot();
global.InstanceTheme = {
STANDALONE_KEY,
HUB_LINKED_THEME_KEY,
isHubLinked,
get,
apply,
initToggleUI,
syncToggleUI,
syncInlineStyles,
patchHubNavLinks,
mergeHubQueryIntoHref,
syncReviewEditButtons,
initReviewEditModeSync,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,43 @@
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
html {
background: #0b0d14;
color-scheme: dark;
}
html[data-theme="light"] {
background: #d8e2ec;
color-scheme: light;
}
html[data-theme="light"] body {
background: #d8e2ec !important;
color: #1a2838 !important;
}
.review-edit-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
html[data-theme="light"] .header h1 {
color: #142232 !important;
}
html[data-theme="light"] .top-nav a,
html[data-theme="light"] .strategy-subnav a {
background: #fff !important;
color: #006e9a !important;
border-color: rgba(0, 95, 140, 0.22) !important;
}
html[data-theme="light"] .top-nav a.active,
html[data-theme="light"] .strategy-subnav a.active {
background: rgba(0, 110, 154, 0.12) !important;
color: #142232 !important;
}
html[data-theme="light"] .card,
html[data-theme="light"] .stat-item {
background: #fff !important;
border-color: #b8c8d8 !important;
}
+269
View File
@@ -0,0 +1,269 @@
/**
* 四所实例共用 UI:复盘详情、盈亏着色等。
*/
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pnlClassFromValue(val) {
const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, ""));
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function formatPnlSpan(val, suffix) {
const sfx = suffix == null ? "U" : suffix;
const cls = pnlClassFromValue(val);
const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx;
return cls ? `<span class="${cls}">${text}</span>` : text;
}
function buildJournalDetailHtml(o, formatExitLine) {
const moodTags =
Array.isArray(o.mood_issues) && o.mood_issues.length
? o.mood_issues.join(",")
: o.mood_issues || "无";
const exitText =
typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无";
const lines = [
`币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`,
`开仓时间:${escapeHtml(o.open_datetime || "-")}`,
`平仓时间:${escapeHtml(o.close_datetime || "-")}`,
`持仓时长:${escapeHtml(o.hold_duration || "-")}`,
`盈亏:${formatPnlSpan(o.pnl)}`,
`开仓类型:${escapeHtml(o.entry_reason || "无")}`,
`平仓/离场:${escapeHtml(exitText)}`,
`预期RR${escapeHtml(o.expect_rr || "-")}`,
`实际RR${escapeHtml(o.real_rr || "-")}`,
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
`占用时新开仓:${escapeHtml(o.new_trade_while_occupied || "-")}`,
`心态标签:${escapeHtml(moodTags)}`,
`备注:${escapeHtml(o.note || "无")}`,
];
return lines.join("<br>");
}
function setJournalDetailBody(o, formatExitLine) {
const body = document.getElementById("detailBody");
if (!body) return;
body.classList.remove("md-review", "trade-record-detail-wrap");
body.classList.add("journal-detail-meta");
body.innerHTML = buildJournalDetailHtml(o, formatExitLine);
}
function openJournalDetailModal(id, journalCache, formatExitLine) {
const o = journalCache && journalCache[id];
if (!o) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`;
}
setJournalDetailBody(o, formatExitLine);
clearDetailActions();
const imgEl = document.getElementById("detailImage");
if (imgEl) {
if (o.image) {
imgEl.src = `/static/images/${o.image}`;
imgEl.style.display = "block";
} else {
imgEl.src = "";
imgEl.style.display = "none";
}
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
function isMobileCompactRecords() {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(max-width: 720px)").matches;
}
function inferJournalDirection(o) {
const text = String((o && o.entry_reason) || "");
if (/做空|空头|short/i.test(text)) {
return { text: "做空", cls: "direction-short" };
}
if (/做多|多头|long/i.test(text)) {
return { text: "做多", cls: "direction-long" };
}
return null;
}
function renderJournalListHtml(data) {
if (!data || !data.length) return "";
const mobile = isMobileCompactRecords();
return data
.map(function (o) {
if (mobile) {
const dir = inferJournalDirection(o);
const pnlCls = pnlClassFromValue(o.pnl);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: `<span class="mrr-muted">-</span>`;
const id = escapeHtml(o.id);
return `<div class="mobile-record-row-wrap">
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
<span class="mrr-dir">${dirHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
</button>
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
</div>`;
}
const moodTags = (o.mood_issues || []).join(",") || "无";
const id = escapeHtml(o.id);
return `<div class="entry">
<div><strong>${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}</strong> | 盈亏:${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</div>
<div>开:${escapeHtml(o.open_datetime || "-")} 平:${escapeHtml(o.close_datetime || "-")} 持仓:${escapeHtml(o.hold_duration || "-")}</div>
<div>心态标签:${escapeHtml(moodTags)}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openJournalDetail('${id}')">查看详情</button>
<button type="button" class="btn-del" onclick="deleteJournal('${id}')">删除</button>
</div>
</div>`;
})
.join("");
}
function parseTradeRecordRow(tr) {
const cells = tr.querySelectorAll("td");
if (cells.length < 14) return null;
const dirBadge = cells[2].querySelector(".badge");
return {
rowId: tr.id,
symbol: cells[0].textContent.trim(),
type: cells[1].textContent.trim(),
directionHtml: (dirBadge ? dirBadge.outerHTML : cells[2].innerHTML).trim(),
directionText: cells[2].textContent.trim(),
trigger: cells[3].textContent.trim(),
stopLoss: cells[4].textContent.trim(),
takeProfit: cells[5].textContent.trim(),
margin: cells[6].textContent.trim(),
leverage: cells[7].textContent.trim(),
holdMinutes: cells[8].textContent.trim(),
openedAt: cells[9].textContent.trim(),
closedAt: cells[10].textContent.trim(),
pnlHtml: cells[11].innerHTML.trim(),
pnlText: cells[11].textContent.trim(),
resultHtml: cells[12].innerHTML.trim(),
resultText: cells[12].textContent.trim(),
actionsHtml: cells[13].innerHTML,
};
}
function renderMobileTradeRow(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return "";
const pnlCls = pnlClassFromValue(row.pnlText);
return `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
<span class="mrr-dir">${row.directionHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
</button>`;
}
function tradeDetailRow(label, valueHtml) {
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
}
function buildTradeRecordDetailHtml(row) {
return `<div class="trade-record-detail">${
tradeDetailRow("品种", escapeHtml(row.symbol)) +
tradeDetailRow("类型", escapeHtml(row.type)) +
tradeDetailRow("方向", row.directionHtml) +
tradeDetailRow("成交价", escapeHtml(row.trigger)) +
tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) +
tradeDetailRow("止盈", escapeHtml(row.takeProfit)) +
tradeDetailRow("基数", escapeHtml(row.margin)) +
tradeDetailRow("杠杆", escapeHtml(row.leverage)) +
tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) +
tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) +
tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) +
tradeDetailRow("盈亏U", row.pnlHtml) +
tradeDetailRow("结果", row.resultHtml)
}</div>`;
}
function clearDetailActions() {
const el = document.getElementById("detailActions");
if (el) {
el.innerHTML = "";
el.style.display = "none";
}
}
function setDetailActionsHtml(html) {
let el = document.getElementById("detailActions");
if (!el) {
const panel = document.querySelector("#detailModal .panel");
if (!panel) return;
el = document.createElement("div");
el.id = "detailActions";
el.className = "detail-actions";
const body = document.getElementById("detailBody");
if (body && body.parentNode === panel) {
panel.insertBefore(el, body.nextSibling);
} else {
panel.appendChild(el);
}
}
el.innerHTML = html || "";
el.style.display = html ? "flex" : "none";
}
function openTradeRecordDetailModal(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易记录|${row.symbol}`;
}
const body = document.getElementById("detailBody");
if (body) {
body.classList.remove("md-review", "journal-detail-meta");
body.classList.add("trade-record-detail-wrap");
body.innerHTML = buildTradeRecordDetailHtml(row);
}
setDetailActionsHtml(
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
);
const imgEl = document.getElementById("detailImage");
if (imgEl) {
imgEl.src = "";
imgEl.style.display = "none";
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
global.InstanceUI = {
escapeHtml: escapeHtml,
pnlClassFromValue: pnlClassFromValue,
formatPnlSpan: formatPnlSpan,
buildJournalDetailHtml: buildJournalDetailHtml,
setJournalDetailBody: setJournalDetailBody,
openJournalDetailModal: openJournalDetailModal,
isMobileCompactRecords: isMobileCompactRecords,
inferJournalDirection: inferJournalDirection,
renderJournalListHtml: renderJournalListHtml,
parseTradeRecordRow: parseTradeRecordRow,
renderMobileTradeRow: renderMobileTradeRow,
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
openTradeRecordDetailModal: openTradeRecordDetailModal,
clearDetailActions: clearDetailActions,
};
})(typeof window !== "undefined" ? window : globalThis);
+160
View File
@@ -0,0 +1,160 @@
/**
* 关键位监控添加表单:类型切换显隐、成交量排名校验(四所实例共用)。
*/
(function (global) {
const RS_TYPES = new Set([
"关键支撑阻力",
"关键阻力位",
"关键支撑位",
]);
function syncKeyMonitorFormFields() {
const typeEl = document.querySelector('#key-form [name="type"]');
const dirEl = document.getElementById("key-direction");
const modeEl = document.getElementById("key-sl-tp-mode");
const manualTp = document.getElementById("key-manual-tp");
const beWrap = document.getElementById("key-breakeven-wrap");
if (!typeEl) return;
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破", "收敛突破"]);
const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !RS_TYPES.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if (dirEl) {
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
if (!showDir) dirEl.value = "";
}
if (modeEl) modeEl.style.display = showAuto ? "" : "none";
if (manualTp) {
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
manualTp.style.display = trend ? "" : "none";
manualTp.required = !!trend;
}
if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if (upperEl) {
upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !hideBounds;
if (hideBounds) upperEl.value = "";
}
if (lowerEl) {
lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !hideBounds;
if (hideBounds) lowerEl.value = "";
}
if (fbPriceEl) {
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if (!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder =
dirEl && dirEl.value === "short"
? "高点(阻力)"
: dirEl && dirEl.value === "long"
? "低点(支撑)"
: "做空填高点/做多填低点";
}
[teEntryEl, teSlEl, teTpEl].forEach((el) => {
if (!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if (!showTe) el.value = "";
});
}
function submitKeyForm(keyForm, label) {
if (
document.body &&
document.body.getAttribute("data-embed-shell") === "1" &&
global.InstanceEmbed &&
typeof global.InstanceEmbed.postFormAndReload === "function"
) {
global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…");
else keyForm.submit();
}
function bindKeyMonitorForm() {
const keyForm = document.getElementById("key-form");
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
if (global.TimeCloseUI) {
global.TimeCloseUI.bindTimeCloseForm(
"key-time-close-cb",
"key-time-close-hours",
"key-time-close-wrap"
);
}
if (!keyForm || keyForm.dataset.keyFormBound === "1") return;
keyForm.dataset.keyFormBound = "1";
keyForm.addEventListener("submit", (e) => {
e.preventDefault();
if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if (!symbol) {
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if (typeVal === "假突破") {
submitKeyForm(keyForm, "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then((r) => r.json().then((d) => ({ status: r.status, data: d })))
.then(({ status, data }) => {
if (status >= 400 || !data.ok) {
alert((data && data.msg) || "日成交量排名读取失败");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
const inTop = data.in_top != null ? data.in_top : data.in_top30;
if (data.rank == null || !inTop) {
alert(
`${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截。`
);
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
submitKeyForm(keyForm, "提交中…");
})
.catch(() => {
alert("日成交量排名检查失败,请稍后重试");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
});
});
}
global.KeyMonitorForm = {
syncFields: syncKeyMonitorFormFields,
init: bindKeyMonitorForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bindKeyMonitorForm);
} else {
bindKeyMonitorForm();
}
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,340 @@
/**
* 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比。
* 以损定仓:风险 = 当前交易基数 × risk%。
* 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致)。
*/
(function (global) {
"use strict";
let debounceMs = 400;
let minRr = 1.5;
let debounceTimer = null;
let fetchSeq = 0;
function $(id) {
return document.getElementById(id);
}
function num(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function formatRr(rr) {
if (rr === null || typeof rr === "undefined") return "—";
const n = Number(rr);
if (!Number.isFinite(n)) return "—";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function formatU(v) {
if (v === null || typeof v === "undefined" || !Number.isFinite(Number(v))) return "—";
return Number(v).toFixed(2) + "U";
}
function setMetric(el, label, valueText) {
if (!el) return;
el.innerHTML = label + "<strong>" + valueText + "</strong>";
}
function sizingMode() {
return (document.body && document.body.getAttribute("data-position-sizing-mode")) || "risk";
}
function isFullMarginMode() {
return sizingMode() === "full_margin";
}
function fullMarginBuffer() {
const n = Number(document.body && document.body.getAttribute("data-full-margin-buffer"));
return Number.isFinite(n) && n > 0 ? n : 0.9;
}
function leverageForSymbol(sym) {
const u = (sym || "").trim().toUpperCase();
const btc = Number(document.body && document.body.getAttribute("data-btc-leverage"));
const alt = Number(document.body && document.body.getAttribute("data-alt-leverage"));
if (u.startsWith("BTC") || u.startsWith("ETH")) {
return Number.isFinite(btc) && btc > 0 ? btc : 10;
}
return Number.isFinite(alt) && alt > 0 ? alt : 5;
}
function riskPercent() {
const form = $("add-order-form");
const raw =
(form && form.getAttribute("data-risk-percent")) ||
(document.body && document.body.getAttribute("data-risk-percent")) ||
"";
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : 1;
}
function calcRiskFraction(direction, entry, sl) {
const e = num(entry);
const s = num(sl);
if (e === null || s === null || e <= 0 || s <= 0) return null;
let risk = 0;
if (direction === "short") {
risk = s - e;
} else {
risk = e - s;
}
if (risk <= 0) return null;
return risk / e;
}
function calcRr(direction, entry, sl, tp) {
const e = num(entry);
const s = num(sl);
const t = num(tp);
if (e === null || s === null || t === null) return null;
if (direction === "short") {
if (s <= e || t >= e) return null;
return (e - t) / (s - e);
}
if (s >= e || t <= e) return null;
return (t - e) / (e - s);
}
function calcRrFromPct(slPct, tpPct) {
const sl = num(slPct);
const tp = num(tpPct);
if (sl === null || tp === null || sl <= 0 || tp <= 0) return null;
return tp / sl;
}
function calcTpFromFixedRr(direction, entry, sl, rr) {
const e = num(entry);
const s = num(sl);
const r = num(rr);
if (e === null || s === null || r === null || r <= 0) return null;
if (direction === "short") {
if (s <= e) return null;
return e - (s - e) * r;
}
if (s >= e) return null;
return e + (e - s) * r;
}
function resolveSlPrice(mode, direction, entry) {
if (mode === "pct") {
const slPct = num($("order-sl-pct") && $("order-sl-pct").value);
if (slPct === null || slPct <= 0) return null;
if (direction === "short") return entry * (1 + slPct / 100);
return entry * (1 - slPct / 100);
}
return num($("order-sl") && $("order-sl").value);
}
function currentMode() {
return ($("sltp-mode") && $("sltp-mode").value) || "fixed_rr";
}
function currentDirection() {
return ($("order-direction") && $("order-direction").value) || "long";
}
function currentSymbol() {
return (($("order-symbol") && $("order-symbol").value) || "").trim();
}
function inputsComplete(m) {
const dir = currentDirection();
if (!currentSymbol() || !dir) return false;
if (m === "pct") {
const sl = num($("order-sl-pct") && $("order-sl-pct").value);
const tp = num($("order-tp-pct") && $("order-tp-pct").value);
return sl !== null && tp !== null && sl > 0 && tp > 0;
}
if (m === "fixed_rr") {
const sl = num($("order-sl") && $("order-sl").value);
const rr = num($("order-fixed-rr") && $("order-fixed-rr").value);
return sl !== null && rr !== null && sl > 0 && rr > 0;
}
const sl = num($("order-sl") && $("order-sl").value);
const tp = num($("order-tp") && $("order-tp").value);
return sl !== null && tp !== null && sl > 0 && tp > 0;
}
function paintEmpty() {
setMetric($("order-risk-preview"), "预估风险", "—");
setMetric($("order-profit-preview"), "预估盈利", "—");
setMetric($("order-rr-preview"), "预估盈亏比", "—");
}
function paintLoading() {
setMetric($("order-risk-preview"), "预估风险", "计算中…");
setMetric($("order-profit-preview"), "预估盈利", "计算中…");
setMetric($("order-rr-preview"), "预估盈亏比", "计算中…");
}
function paintFail(kind) {
const msg = kind === "fetch_fail" ? "取价失败" : "无效";
setMetric($("order-risk-preview"), "预估风险", msg);
setMetric($("order-profit-preview"), "预估盈利", msg);
setMetric($("order-rr-preview"), "预估盈亏比", msg);
}
function paintOk(riskU, profitU, rr) {
setMetric($("order-risk-preview"), "预估风险", formatU(riskU));
setMetric($("order-profit-preview"), "预估盈利", formatU(profitU));
const rrEl = $("order-rr-preview");
const rrText = formatRr(rr);
setMetric(rrEl, "预估盈亏比", rrText);
if (rrEl && rr !== null && Number.isFinite(Number(rr))) {
rrEl.classList.toggle("order-preview-rr-low", Number(rr) < minRr);
rrEl.classList.toggle("order-preview-rr-ok", Number(rr) >= minRr);
}
}
function plannedRiskFromRiskMode(capital) {
const cap = num(capital);
if (cap === null || cap <= 0) return null;
return Math.round((cap * riskPercent()) / 100 * 100) / 100;
}
function plannedRiskFromFullMargin(availableUsdt, symbol, direction, entry, sl) {
const avail = num(availableUsdt);
if (avail === null || avail <= 0) return null;
const slPx = num(sl);
const entryPx = num(entry);
if (slPx === null || entryPx === null) return null;
const rf = calcRiskFraction(direction, entryPx, slPx);
if (rf === null) return null;
const margin = Math.round(avail * fullMarginBuffer() * 100) / 100;
const lev = leverageForSymbol(symbol);
return Math.round(margin * lev * rf * 100) / 100;
}
function resolvePreviewRr(m, dir, entry) {
if (m === "pct") {
return calcRrFromPct(
$("order-sl-pct") && $("order-sl-pct").value,
$("order-tp-pct") && $("order-tp-pct").value
);
}
const sl = num($("order-sl") && $("order-sl").value);
if (m === "fixed_rr") {
const fixed = num($("order-fixed-rr") && $("order-fixed-rr").value);
if (fixed !== null && fixed > 0) return fixed;
const tp = calcTpFromFixedRr(dir, entry, sl, fixed);
return calcRr(dir, entry, sl, tp);
}
const tp = num($("order-tp") && $("order-tp").value);
return calcRr(dir, entry, sl, tp);
}
function refreshNow() {
if (!$("order-plan-preview")) return;
const m = currentMode();
if (!inputsComplete(m)) {
paintEmpty();
return;
}
const sym = currentSymbol();
const dir = currentDirection();
const seq = ++fetchSeq;
paintLoading();
const defaultsP = fetch(
"/api/order_defaults?symbol=" +
encodeURIComponent(sym) +
"&direction=" +
encodeURIComponent(dir)
).then(function (r) {
return r.json();
});
const capitalP = fetch("/api/account_snapshot").then(function (r) {
return r.json();
});
Promise.all([defaultsP, capitalP])
.then(function (results) {
if (seq !== fetchSeq) return;
const data = results[0];
const account = results[1] || {};
if (!data.ok) {
paintFail("fetch_fail");
return;
}
const entry = num(data.last_price != null ? data.last_price : data.price);
if (entry === null) {
paintFail("fetch_fail");
return;
}
const rr = resolvePreviewRr(m, dir, entry);
if (rr === null) {
paintFail("invalid");
return;
}
let riskU = null;
if (isFullMarginMode()) {
const slPx = resolveSlPrice(m, dir, entry);
const avail =
data.available_trading_usdt != null
? data.available_trading_usdt
: account.available_trading_usdt;
riskU = plannedRiskFromFullMargin(avail, sym, dir, entry, slPx);
} else {
riskU = plannedRiskFromRiskMode(account.current_capital);
}
if (riskU === null) {
paintFail("fetch_fail");
return;
}
const profitU = Math.round(riskU * rr * 100) / 100;
paintOk(riskU, profitU, rr);
})
.catch(function () {
if (seq !== fetchSeq) return;
paintFail("fetch_fail");
});
}
function schedule() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshNow, debounceMs);
}
function wire(opts) {
opts = opts || {};
if (opts.minRr != null && Number.isFinite(Number(opts.minRr))) {
minRr = Number(opts.minRr);
}
if (opts.debounceMs != null && Number.isFinite(Number(opts.debounceMs))) {
debounceMs = Number(opts.debounceMs);
}
[
"order-symbol",
"order-direction",
"sltp-mode",
"order-sl",
"order-tp",
"order-sl-pct",
"order-tp-pct",
"order-fixed-rr",
"order-leverage",
].forEach(function (id) {
const el = $(id);
if (!el || el._rrPreviewBound) return;
el._rrPreviewBound = true;
el.addEventListener("input", schedule);
el.addEventListener("change", schedule);
});
schedule();
}
global.ManualOrderRrPreview = {
wire: wire,
schedule: schedule,
refresh: refreshNow,
calcRr: calcRr,
calcRrFromPct: calcRrFromPct,
calcRiskFraction: calcRiskFraction,
formatRr: formatRr,
};
})(typeof window !== "undefined" ? window : globalThis);
+289
View File
@@ -0,0 +1,289 @@
(function () {
"use strict";
function syncRollFormMode(form, mode) {
if (!form) return;
const m = mode || "market";
form.setAttribute("data-add-mode", m);
const showFib = m === "fib_618" || m === "fib_786";
const showBreakout = m === "breakout";
const fibWrap = form.querySelector(".roll-field-fib");
const breakoutWrap = form.querySelector(".roll-field-breakout");
const fibUpper = form.querySelector("#roll-fib-upper");
const fibLower = form.querySelector("#roll-fib-lower");
const breakoutInput = form.querySelector("#roll-breakout");
function tuneInput(inp, active, required) {
if (!inp) return;
inp.disabled = !active;
inp.required = !!required && active;
inp.tabIndex = active ? 0 : -1;
if (!active) inp.value = "";
}
if (fibWrap) fibWrap.setAttribute("aria-hidden", showFib ? "false" : "true");
if (breakoutWrap) breakoutWrap.setAttribute("aria-hidden", showBreakout ? "false" : "true");
tuneInput(fibUpper, showFib, showFib);
tuneInput(fibLower, showFib, showFib);
tuneInput(breakoutInput, showBreakout, showBreakout);
}
window.syncRollFormMode = syncRollFormMode;
const form = document.getElementById("roll-form");
if (!form) return;
if (form.dataset.rollJsInit === "1") return;
form.dataset.rollJsInit = "1";
const symbolSel = document.getElementById("roll-symbol");
const dirInput = document.getElementById("roll-direction");
const modeSel = document.getElementById("roll-add-mode");
const riskBanner = document.getElementById("roll-risk-banner");
const previewBtn = document.getElementById("roll-preview-btn");
const submitBtn = document.getElementById("roll-submit-btn");
const previewBox = document.getElementById("roll-preview-box");
const previewText = document.getElementById("roll-preview-text");
const countdownEl = document.getElementById("roll-countdown");
const trendLocked = submitBtn && submitBtn.getAttribute("data-trend-locked") === "1";
let countdownTimer = null;
let previewOk = false;
let lastPreviewMode = "";
let monitorSubmitting = false;
function isMarketMode() {
return (modeSel.value || "market") === "market";
}
function isMonitorMode() {
const m = modeSel.value || "market";
return m === "fib_618" || m === "fib_786" || m === "breakout";
}
function selectedOption() {
return symbolSel.options[symbolSel.selectedIndex];
}
function syncDirectionLock() {
const opt = selectedOption();
if (!opt || !opt.value) {
riskBanner.textContent = "当前风险:请选择持仓币种";
return;
}
const dir = opt.getAttribute("data-direction") || "long";
const rp = opt.getAttribute("data-risk-percent") || "—";
dirInput.value = dir;
riskBanner.textContent =
"当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + "";
}
function syncSubmitButton() {
if (!submitBtn || trendLocked) return;
if (isMonitorMode()) {
submitBtn.disabled = false;
return;
}
submitBtn.disabled = !previewOk || !!countdownTimer;
}
function clearMessageBox() {
if (!previewBox) return;
previewBox.style.display = "none";
previewBox.classList.remove("is-error", "is-preview");
if (previewText) previewText.textContent = "";
if (countdownEl) countdownEl.style.display = "none";
}
function showReject(msg) {
if (!previewBox || !previewText) return;
previewBox.style.display = "block";
previewBox.classList.remove("is-preview");
previewBox.classList.add("is-error");
previewText.textContent = msg || "无法执行";
if (countdownEl) countdownEl.style.display = "none";
previewBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function showPreviewResult(p) {
if (!previewBox || !previewText) return;
previewBox.style.display = "block";
previewBox.classList.remove("is-error");
previewBox.classList.add("is-preview");
previewText.innerHTML =
"<strong>" +
(p.add_mode_label || "") +
"</strong> · 约 <strong>" +
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
"</strong> 张<br>" +
"加仓参考价 " +
(p.add_price_display != null ? p.add_price_display : p.add_price) +
" · 新止损 " +
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss) +
"<br>" +
"合并均价 " +
p.avg_entry_after +
" · 打到止损约 " +
p.loss_at_sl_usdt +
"U(风险预算 " +
(p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") +
"U";
}
function syncFieldVisibility() {
syncRollFormMode(form, modeSel.value || "market");
resetPreview();
}
function resetPreview() {
previewOk = false;
monitorSubmitting = false;
clearMessageBox();
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
syncSubmitButton();
}
function formPayload() {
const fd = new FormData(form);
const obj = {};
fd.forEach(function (v, k) {
if (v !== "") obj[k] = v;
});
return obj;
}
function requestPreview() {
return fetch("/strategy/roll/preview", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(formPayload()),
credentials: "same-origin",
}).then(function (r) {
return r.json();
});
}
function runPreview() {
resetPreview();
if (!symbolSel.value) {
showReject("请先选择持仓币种");
return;
}
if (previewBtn) previewBtn.disabled = true;
requestPreview()
.then(function (data) {
if (previewBtn) previewBtn.disabled = false;
if (!data.ok) {
showReject(data.msg || "预览失败");
return;
}
const p = data.preview || {};
lastPreviewMode = p.add_mode || modeSel.value;
showPreviewResult(p);
previewOk = true;
if (lastPreviewMode === "market") {
startCountdown(10);
} else {
syncSubmitButton();
}
})
.catch(function () {
if (previewBtn) previewBtn.disabled = false;
showReject("预览请求失败,请稍后重试");
});
}
function runMonitorSubmit() {
if (monitorSubmitting) return;
if (!symbolSel.value) {
showReject("请先选择持仓币种");
return;
}
monitorSubmitting = true;
if (submitBtn) submitBtn.disabled = true;
requestPreview()
.then(function (data) {
monitorSubmitting = false;
if (submitBtn && !trendLocked) submitBtn.disabled = false;
if (!data.ok) {
showReject(data.msg || "无法提交监控");
return;
}
const p = data.preview || {};
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
const summary =
"约 " +
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
" 张 · 触发参考价 " +
(p.add_price_display != null ? p.add_price_display : p.add_price) +
" · 新止损 " +
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss);
if (!confirm("确认提交「" + modeLabel + "」?\n" + summary)) {
return;
}
form.submit();
})
.catch(function () {
monitorSubmitting = false;
if (submitBtn && !trendLocked) submitBtn.disabled = false;
showReject("校验请求失败,请稍后重试");
});
}
function startCountdown(sec) {
let left = sec;
if (submitBtn) submitBtn.disabled = true;
if (countdownEl) {
countdownEl.style.display = "block";
countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)";
}
countdownTimer = setInterval(function () {
left -= 1;
if (left <= 0) {
clearInterval(countdownTimer);
countdownTimer = null;
if (countdownEl) countdownEl.textContent = "可以执行市价加仓";
syncSubmitButton();
return;
}
if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行";
}, 1000);
}
symbolSel.addEventListener("change", function () {
syncDirectionLock();
resetPreview();
});
modeSel.addEventListener("change", syncFieldVisibility);
form.addEventListener("input", resetPreview);
form.addEventListener("change", function (e) {
if (e.target !== previewBtn) resetPreview();
});
if (previewBtn) previewBtn.addEventListener("click", runPreview);
form.addEventListener("submit", function (e) {
if (isMonitorMode()) {
e.preventDefault();
runMonitorSubmit();
return;
}
if (!previewOk) {
e.preventDefault();
showReject("请先点击「预览」并通过校验");
return;
}
if (submitBtn && submitBtn.disabled) {
e.preventDefault();
showReject("请等待 10 秒确认倒计时结束后再执行市价加仓");
return;
}
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
if (!confirm("确认提交「" + modeLabel + "」?")) {
e.preventDefault();
}
});
syncDirectionLock();
syncFieldVisibility();
})();
+100
View File
@@ -0,0 +1,100 @@
/**
* 时间平仓:表单开关 + 持仓倒计时。
*/
(function (global) {
"use strict";
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
function formatCountdown(sec) {
const s = Math.max(0, parseInt(sec, 10) || 0);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
}
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
const cb = document.getElementById(checkboxId);
const sel = document.getElementById(selectId);
const wrap = wrapId ? document.getElementById(wrapId) : null;
if (!cb || !sel) return;
function sync() {
const on = !!cb.checked;
sel.disabled = false;
sel.tabIndex = 0;
if (wrap) wrap.classList.toggle("is-disabled", !on);
}
sel.addEventListener("mousedown", function (ev) {
ev.stopPropagation();
});
sel.addEventListener("click", function (ev) {
ev.stopPropagation();
});
cb.addEventListener("change", sync);
sync();
}
function paintOrderTimeClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
const cd = document.getElementById("order-time-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!(order.time_close_enabled || order.time_close_at_ms);
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const hours = order.time_close_hours;
const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓");
const labelEl = wrap.querySelector(".pos-time-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.time_close_remaining_sec != null
? Number(order.time_close_remaining_sec)
: null;
if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) {
rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000));
}
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
}
function tickLocalCountdowns() {
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
const cd = wrap.querySelector(".pos-time-close-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
cd.textContent = formatCountdown(rem);
});
}
function paintOrders(orders) {
(orders || []).forEach(paintOrderTimeClose);
}
function syncKeyTimeCloseVisibility(show) {
const wrap = document.getElementById("key-time-close-wrap");
if (!wrap) return;
wrap.style.display = show ? "inline-flex" : "none";
}
global.TimeCloseUI = {
bindTimeCloseForm: bindTimeCloseForm,
paintOrderTimeClose: paintOrderTimeClose,
paintOrders: paintOrders,
tickLocalCountdowns: tickLocalCountdowns,
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
formatCountdown: formatCountdown,
};
if (!global.__timeCloseCountdownTimer) {
global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000);
}
})(typeof window !== "undefined" ? window : globalThis);
+160
View File
@@ -0,0 +1,160 @@
/* 交易日历:内照明心 + 四所统计分析共用,随 data-theme 浅/深切换 */
.trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22));
--trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32)));
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg));
--trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent);
--trade-cal-selected-border: rgba(59, 130, 246, 0.85);
--trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg));
--trade-cal-selected-shadow: rgba(59, 130, 246, 0.45);
--trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg));
--trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
--trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent);
--trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent);
--trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff);
--trade-cal-pos: var(--green, #22c55e);
--trade-cal-neg: var(--red, #ef4444);
margin-top: 4px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28));
background: var(--trade-cal-wrap-bg);
}
.stats-calendar-wrap {
margin-bottom: 14px;
}
.trade-cal-wrap button.trade-cal-cell {
background: var(--trade-cal-cell-bg) !important;
background-image: none !important;
border: 1px solid transparent;
padding: 4px 3px;
min-height: 68px;
width: 100%;
box-shadow: none;
line-height: 1.15;
font-size: inherit;
text-align: center;
}
.trade-cal-wrap button.trade-cal-cell:disabled {
opacity: 1;
cursor: default;
}
.trade-cal-wrap .trade-cal-head .btn,
.trade-cal-wrap .trade-cal-head button {
min-height: 0;
min-width: 34px;
padding: 4px 12px;
line-height: 1.2;
}
.trade-cal-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.trade-cal-title {
font-size: 0.95rem;
font-weight: 600;
min-width: 120px;
text-align: center;
color: var(--text, #e8ecff);
}
.trade-cal-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.trade-cal-wd {
text-align: center;
font-size: 0.72rem;
color: var(--muted, #8892b0);
}
.trade-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.trade-cal-cell {
min-height: 62px;
padding: 4px 3px;
border-radius: 8px;
border: 1px solid transparent;
background: var(--trade-cal-cell-bg);
color: inherit;
font: inherit;
cursor: default;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
}
.trade-cal-cell.has-trade {
cursor: pointer;
}
.trade-cal-wrap button.trade-cal-cell.has-trade:hover {
background: var(--trade-cal-cell-hover-bg) !important;
background-image: none !important;
border-color: var(--trade-cal-cell-hover-border);
}
.trade-cal-cell.is-selected {
border-color: var(--trade-cal-selected-border);
background: var(--trade-cal-selected-bg);
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-cell.is-sick-day {
border-color: var(--trade-cal-sick-border);
background: var(--trade-cal-sick-bg);
}
.trade-cal-cell.is-sick-day.is-selected {
border-color: var(--trade-cal-selected-border);
background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg));
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-day-num {
font-size: 0.78rem;
font-weight: 600;
color: var(--text, #e8ecff);
}
.trade-cal-pnl {
font-size: 0.72rem;
font-weight: 600;
line-height: 1.1;
color: var(--text, #e8ecff);
}
.trade-cal-cell.pnl-pos .trade-cal-pnl {
color: var(--trade-cal-pos);
}
.trade-cal-cell.pnl-neg .trade-cal-pnl {
color: var(--trade-cal-neg);
}
.trade-cal-cnt {
font-size: 0.65rem;
color: var(--muted, #8892b0);
font-weight: 500;
}
.trade-cal-sick-tag {
font-size: 0.62rem;
padding: 1px 4px;
border-radius: 4px;
background: var(--trade-cal-sick-tag-bg);
color: var(--trade-cal-sick-tag-fg);
font-weight: 600;
}
.trade-cal-pad {
background: transparent;
border: none;
min-height: 0;
}
html[data-theme="light"] .trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, #eef3f8);
--trade-cal-cell-bg: var(--section-surface, #f6f9fc);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc);
--trade-cal-selected-border: rgba(37, 99, 235, 0.75);
--trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #f6f9fc);
--trade-cal-selected-shadow: rgba(37, 99, 235, 0.35);
--trade-cal-sick-tag-fg: #b91c1c;
}
+314
View File
@@ -0,0 +1,314 @@
/**
* 交易日历组件:内照明心档案 + 四所统计分析共用。
*/
(function (global) {
"use strict";
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function monthLabel(y, m) {
return y + "年" + m + "月";
}
function formatCalPnl(pnl) {
var n = Number(pnl);
if (!Number.isFinite(n)) n = 0;
return (n >= 0 ? "+" : "") + n.toFixed(1) + "U";
}
function dayHasTrade(info) {
if (!info) return false;
var cnt = Number(info.open_count);
if (Number.isFinite(cnt) && cnt > 0) return true;
var pnl = Number(info.pnl_total);
return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001;
}
function dayOpenCount(info) {
var cnt = Number(info && info.open_count);
return Number.isFinite(cnt) && cnt > 0 ? cnt : 0;
}
function dayPnl(info) {
return Number(info && info.pnl_total) || 0;
}
function TradeStatsCalendar(config) {
this.gridEl = config.gridEl;
this.titleEl = config.titleEl;
this.prevBtn = config.prevBtn || null;
this.nextBtn = config.nextBtn || null;
this.apiUrl = config.apiUrl || "/api/stats/calendar";
this.buildQuery =
config.buildQuery ||
function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
return q;
};
this.parseResponse =
config.parseResponse ||
function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
};
this.fetchFn = config.fetchFn || null;
this.showSick = config.showSick !== false;
this.selectedDay = config.selectedDay || "";
this.onDayClick = config.onDayClick || null;
this.onMonthChange = config.onMonthChange || null;
this.year = config.year || 0;
this.month = config.month || 0;
this.days = {};
this.monthPnlTotal = 0;
this.monthOpenCount = 0;
this._navBound = false;
this._bindNav();
}
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
if (this.year > 0 && this.month > 0) return;
var d;
if (ref instanceof Date) d = ref;
else if (typeof ref === "string" && ref.length >= 7) {
var p = ref.slice(0, 10).split("-");
this.year = parseInt(p[0], 10) || new Date().getFullYear();
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
return;
} else d = new Date();
this.year = d.getFullYear();
this.month = d.getMonth() + 1;
};
TradeStatsCalendar.prototype.applyPayload = function (data) {
if (!data) return;
var y = Number(data.year);
var m = Number(data.month);
if (Number.isFinite(y) && y > 0) this.year = y;
if (Number.isFinite(m) && m > 0) this.month = m;
this.days = this.parseResponse(data) || {};
this.monthPnlTotal = Number(data.month_pnl_total) || 0;
this.monthOpenCount = Number(data.month_open_count) || 0;
if (!this.monthOpenCount) {
var self = this;
Object.keys(this.days).forEach(function (k) {
if (dayHasTrade(self.days[k])) {
self.monthOpenCount += dayOpenCount(self.days[k]);
self.monthPnlTotal += dayPnl(self.days[k]);
}
});
this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000;
}
};
function readStatsCalendarBootstrap() {
var el = document.getElementById("stats-calendar-bootstrap");
if (!el || !el.textContent) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
console.warn("[trade calendar] bootstrap parse", e);
return null;
}
}
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
this.selectedDay = day || "";
this.render();
};
TradeStatsCalendar.prototype.render = function () {
if (!this.gridEl || !this.titleEl) return;
if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date());
var title = monthLabel(this.year, this.month);
if (this.monthOpenCount > 0) {
title +=
" · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔";
}
this.titleEl.textContent = title;
var first = new Date(this.year, this.month - 1, 1);
var lastDay = new Date(this.year, this.month, 0).getDate();
var startWd = first.getDay();
var html =
'<div class="trade-cal-weekdays">' +
WEEKDAYS.map(function (w) {
return '<span class="trade-cal-wd">' + w + "</span>";
}).join("") +
'</div><div class="trade-cal-grid">';
var i;
for (i = 0; i < startWd; i++) {
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
}
for (var d = 1; d <= lastDay; d++) {
var dayStr =
this.year +
"-" +
String(this.month).padStart(2, "0") +
"-" +
String(d).padStart(2, "0");
var info = this.days[dayStr];
var hasTrade = dayHasTrade(info);
var sick = this.showSick && info && info.has_sick;
var pnl = hasTrade ? dayPnl(info) : null;
var cnt = hasTrade ? dayOpenCount(info) : 0;
var cls =
"trade-cal-cell" +
(hasTrade ? " has-trade" : "") +
(sick ? " is-sick-day" : "") +
(this.selectedDay === dayStr ? " is-selected" : "") +
(pnl != null && pnl > 0.0001
? " pnl-pos"
: pnl != null && pnl < -0.0001
? " pnl-neg"
: "");
var body = '<span class="trade-cal-day-num">' + d + "</span>";
if (hasTrade) {
body +=
'<span class="trade-cal-pnl">' +
esc(formatCalPnl(pnl)) +
"</span>" +
'<span class="trade-cal-cnt">' +
cnt +
"笔</span>";
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
}
html +=
'<button type="button" class="' +
cls +
'" data-day="' +
dayStr +
'" data-sick="' +
(sick ? "1" : "0") +
'"' +
(hasTrade ? "" : " disabled") +
">" +
body +
"</button>";
}
html += "</div>";
this.gridEl.innerHTML = html;
var self = this;
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
btn.addEventListener("click", function () {
var day = btn.getAttribute("data-day");
if (!day || !self.onDayClick) return;
self.selectedDay = day;
self.render();
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
});
});
};
TradeStatsCalendar.prototype.load = async function () {
this.ensureMonth(new Date());
this.render();
var q = this.buildQuery(this.year, this.month);
if (!q.has("year")) q.set("year", String(this.year));
if (!q.has("month")) q.set("month", String(this.month));
try {
var data;
if (this.fetchFn) {
data = await this.fetchFn(q);
} else {
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
credentials: "same-origin",
});
if (!resp.ok) {
console.warn("[trade calendar] api", resp.status);
this.render();
return;
}
data = await resp.json();
}
this.applyPayload(data);
this.render();
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
} catch (e) {
console.warn("[trade calendar]", e);
this.render();
}
};
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
this.ensureMonth(new Date());
this.month += delta;
if (this.month > 12) {
this.month = 1;
this.year += 1;
} else if (this.month < 1) {
this.month = 12;
this.year -= 1;
}
void this.load();
};
TradeStatsCalendar.prototype._bindNav = function () {
if (this._navBound) return;
var self = this;
if (this.prevBtn) {
this.prevBtn.addEventListener("click", function () {
self.shiftMonth(-1);
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener("click", function () {
self.shiftMonth(1);
});
}
this._navBound = true;
};
global.TradeStatsCalendar = TradeStatsCalendar;
global.statsCalendarWidget = null;
global.initInstanceStatsCalendar = function () {
var grid = document.getElementById("stats-calendar");
if (!grid || !global.TradeStatsCalendar) return null;
var bootstrap = readStatsCalendarBootstrap();
if (
global.statsCalendarWidget &&
global.statsCalendarWidget.gridEl === grid
) {
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
}
global.statsCalendarWidget = new TradeStatsCalendar({
gridEl: grid,
titleEl: document.getElementById("stats-cal-title"),
prevBtn: document.getElementById("stats-cal-prev"),
nextBtn: document.getElementById("stats-cal-next"),
apiUrl: "/api/stats/calendar",
showSick: false,
buildQuery: function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
var sel = document.getElementById("stats-segment-select");
if (sel) q.set("segment", sel.value || "all");
return q;
},
parseResponse: function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
},
});
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
};
global.initStatsCalendarWidget = global.initInstanceStatsCalendar;
})(window);
+117
View File
@@ -0,0 +1,117 @@
"""企业微信机器人 Webhook 推送(多实例共用)。"""
from __future__ import annotations
import re
from typing import Optional
import requests
def strip_markdown_for_text(content: str) -> str:
s = str(content or "")
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
s = re.sub(r"`([^`]+)`", r"\1", s)
s = re.sub(r"^#+\s*", "", s, flags=re.MULTILINE)
s = re.sub(r"^---\s*$", "", s, flags=re.MULTILINE)
return s.strip()
def looks_like_wechat_markdown(content: str) -> bool:
if not content:
return False
if re.search(r"^#+\s", content, re.MULTILINE):
return True
return "**" in content or "`" in content
def send_wechat_webhook(
webhook_url: str,
content: str,
*,
timeout: int = 10,
prefix: str = "【加密货币】",
) -> bool:
url = (webhook_url or "").strip()
if not url or "replace-me" in url:
return False
body = str(content or "").strip()
if prefix:
full = f"{prefix}\n{body}" if body else prefix
else:
full = body
if not full.strip():
return False
payloads = []
if looks_like_wechat_markdown(full):
payloads.append({"msgtype": "markdown", "markdown": {"content": full}})
plain = strip_markdown_for_text(full) if looks_like_wechat_markdown(full) else full
payloads.append({"msgtype": "text", "text": {"content": plain}})
seen = set()
for payload in payloads:
key = payload["msgtype"]
if key in seen:
continue
seen.add(key)
try:
resp = requests.post(url, json=payload, timeout=timeout)
if resp.status_code != 200:
continue
data = resp.json()
if int(data.get("errcode", -1)) == 0:
return True
except Exception:
continue
return False
def wechat_direction_label(direction: str) -> str:
d = (direction or "").strip().lower()
if d == "long":
return "多头(long"
if d == "short":
return "空头(short"
return "双向(watch"
def build_wechat_rs_level_message(
*,
symbol: str,
monitor_type: str,
account_label: str,
trigger_time: str,
upper_txt: str,
lower_txt: str,
close_txt: str,
edge_txt: str,
break_label: str,
direction: str,
notify_index: int,
notify_max: int,
interval_min: int,
extra_note: Optional[str] = None,
) -> str:
"""阻力/支撑突破提醒(与开平仓推送一致的 emoji 纯文本风格)。"""
head = "📈" if (direction or "").strip().lower() == "long" else "📉"
dir_txt = wechat_direction_label(direction)
lines = [
f"{head} {symbol} 关键位突破提醒({notify_index}/{notify_max}",
f"💼 账户:{account_label}",
"",
"🧾 突破概要",
f"📌 类型:{monitor_type}",
f"⏱ 触发时间:{trigger_time}",
f"📊 上沿:{upper_txt}|下沿:{lower_txt}",
f"💹 触发收盘:{close_txt}",
f"🎯 {break_label}{dir_txt}",
f"📍 突破价位:{edge_txt}",
"",
"📎 说明",
f"· 人工盯盘,共推送 {notify_max} 次(间隔约 {interval_min} 分钟)",
"· 推送完毕后本条监控自动结案",
"· 不参与自动开仓",
]
if extra_note:
lines.append(f"· {extra_note}")
return "\n".join(lines)
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+66
View File
@@ -0,0 +1,66 @@
"""Gate 平仓历史匹配(fetch_positions_history),供 reconcile / 中控全平同步共用。"""
from __future__ import annotations
def unified_symbol_for_match(symbol_str: str) -> str:
x = (symbol_str or "").strip().upper()
if ":" in x:
x = x.split(":")[0]
return x
def pick_gate_position_close(
hist: list[dict],
symbol: str,
direction: str,
*,
opened_at_ms: int | None = None,
closed_at_ms: int | None = None,
used_keys: set[str] | None = None,
max_close_delta_ms: int = 25 * 60 * 1000,
) -> dict | None:
"""
从 Gate 平仓历史列表中选取与 symbol/direction/开仓时间最匹配的一条。
返回 normalize 后的 dict(含 close_ms、pnl、sync_key 等),无匹配则 None。
"""
if not hist:
return None
sym_u = unified_symbol_for_match(symbol)
dir_l = (direction or "long").strip().lower()
if dir_l not in ("long", "short"):
return None
used = used_keys or set()
ref_ms = closed_at_ms or opened_at_ms
best = None
best_d = None
for h in hist:
if not isinstance(h, dict):
continue
sk = h.get("sync_key")
if not sk or sk in used:
continue
if h.get("symbol_u") != sym_u:
continue
if (h.get("side") or "").strip().lower() != dir_l:
continue
cm = h.get("close_ms")
if cm is None:
continue
if opened_at_ms is not None:
if cm < opened_at_ms - 15 * 60 * 1000:
continue
if cm > opened_at_ms + 15 * 86400 * 1000:
continue
if ref_ms is not None:
d = abs(int(cm) - int(ref_ms))
else:
d = 0
if best_d is None or d < best_d:
best_d = d
best = h
if best is None or best_d is None:
return None
if ref_ms is not None and best_d > max_close_delta_ms:
return None
return best
+55
View File
@@ -0,0 +1,55 @@
"""Gate.io 资金划转(crypto_monitor_gate / crypto_monitor_gate_bot 共用)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
INVALID_KEY_HINT = (
"。常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;"
"③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);"
"④ Key 已重置或权限变更。你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单。"
)
def execute_transfer_usdt(
exchange,
amount: float,
from_account: str,
to_account: str,
*,
transfer_ccy: str = "USDT",
ensure_live_ready: Callable[[], tuple[bool, str]],
ensure_markets_loaded: Optional[Callable[[], None]] = None,
) -> tuple[bool, str, Any]:
if amount <= 0:
return False, "划转金额必须大于0", None
ok_live, reason = ensure_live_ready()
if not ok_live:
return False, reason, None
if ensure_markets_loaded:
try:
ensure_markets_loaded()
except Exception:
pass
try:
resp = exchange.transfer(transfer_ccy, float(amount), from_account, to_account)
return True, "划转成功", resp
except Exception as e:
msg = str(e)
if "INVALID_KEY" in msg or "Invalid key" in msg:
msg += INVALID_KEY_HINT
return False, msg, None
def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int:
"""自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划。"""
n = int(count_order_monitors(conn) or 0)
if n > 0:
return n
try:
row = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans "
"WHERE status='active' AND COALESCE(first_order_done, 0) != 0"
).fetchone()
return int(row[0] or 0) if row else 0
except Exception:
return n
+116
View File
@@ -0,0 +1,116 @@
"""
OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger)。
交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到。
"""
from __future__ import annotations
from typing import Any
def _order_dedupe_key(order: dict) -> str:
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
return str(order.get("id") or info.get("algoId") or info.get("ordId") or "")
def _okx_algo_cancel_id(order_id: str) -> str:
oid = str(order_id or "")
if ":" in oid:
return oid.split(":", 1)[0]
return oid
def _okx_order_needs_stop_cancel_param(order: dict) -> bool:
"""OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败。"""
if not isinstance(order, dict):
return False
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
if order.get("stopLossPrice") is not None or order.get("takeProfitPrice") is not None:
return True
if info.get("algoId") or info.get("slTriggerPx") or info.get("tpTriggerPx"):
return True
typ = str(order.get("type") or info.get("ordType") or "").lower()
for token in ("conditional", "oco", "trigger", "move_order_stop", "iceberg"):
if token in typ:
return True
return False
def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]:
"""合并 OKX 普通挂单与算法挂单(去重)。"""
if not exchange_symbol:
return []
ex.load_markets()
sym = exchange_symbol
try:
sym = ex.market(exchange_symbol)["symbol"]
except Exception:
pass
seen: set[str] = set()
out: list[dict] = []
def add_batch(batch: list | None) -> None:
for o in batch or []:
if not isinstance(o, dict):
continue
k = _order_dedupe_key(o)
if not k or k in seen:
continue
seen.add(k)
out.append(o)
try:
add_batch(ex.fetch_open_orders(sym))
except Exception:
pass
for params in (
{"ordType": "conditional"},
{"ordType": "oco"},
{"trigger": True},
):
try:
add_batch(ex.fetch_open_orders(sym, params=dict(params)))
except Exception:
pass
return out
def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int:
"""
撤销某合约全部挂单(普通 + 条件/算法)。
OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉。
"""
if not exchange_symbol:
return 0
ex.load_markets()
sym = exchange_symbol
try:
sym = ex.market(exchange_symbol)["symbol"]
except Exception:
pass
n = 0
for o in fetch_okx_all_open_orders(ex, sym):
oid = _order_dedupe_key(o)
if not oid:
continue
cancel_id = _okx_algo_cancel_id(oid)
params = {"stop": True} if _okx_order_needs_stop_cancel_param(o) else None
try:
ex.cancel_order(cancel_id, sym, params)
n += 1
continue
except Exception:
pass
try:
ex.cancel_order(oid, sym, params)
n += 1
except Exception:
pass
try:
ex.cancel_all_orders(sym)
except Exception:
pass
return n
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+36
View File
@@ -0,0 +1,36 @@
"""中控调用实例 API 时的鉴权(Flask request 头 X-Hub-Token)。SSO 见 hub_sso.py。"""
from __future__ import annotations
import os
from lib.hub.hub_sso import (
HUB_SSO_TTL_SEC,
hub_bridge_token,
mint_hub_sso_token,
safe_next_path,
verify_hub_sso_token,
)
__all__ = [
"HUB_SSO_TTL_SEC",
"hub_bridge_token",
"mint_hub_sso_token",
"safe_next_path",
"verify_hub_sso_token",
"request_allowed",
]
def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool:
if auth_disabled or session_logged_in:
return True
tok = hub_bridge_token()
if not tok:
return False
try:
from flask import request
except ImportError:
return False
if request.headers.get("X-Hub-Token") == tok:
return True
return False
File diff suppressed because it is too large Load Diff
+498
View File
@@ -0,0 +1,498 @@
"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)。"""
from __future__ import annotations
from typing import Any, Callable, Optional, Tuple
from lib.strategy.strategy_roll_lib import max_roll_legs
from lib.strategy.strategy_trend_lib import (
build_trend_preview_level_rows,
calc_risk_fraction,
compute_trend_plan_core,
validate_trend_bounds,
)
DEFAULT_DCA_LEGS = 5
MARGIN_BUFFER = 0.95
def _resolve_market(
exchange_id: str,
base: str,
) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]:
from lib.hub.hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market
market, err = get_calculator_market(exchange_id, base)
if err or not market:
return None, None, err or "无法解析合约"
amount_precise = make_amount_precise_fn_from_market(market)
return market, amount_precise, None
def calc_trend_calculator(
*,
direction: str,
capital_usdt: float,
risk_percent: float,
leverage: int,
entry_price: float,
stop_loss: float,
add_upper: float,
take_profit: float,
dca_legs: int = DEFAULT_DCA_LEGS,
exchange_id: str = "0",
base: str = "ETH",
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
market, amount_precise, merr = _resolve_market(exchange_id, base)
if merr or not market or not amount_precise:
return None, merr or "无法解析合约"
contract_size = float(market.get("contract_size") or 1.0)
exchange_symbol = market["exchange_symbol"]
direction = (direction or "long").strip().lower()
if direction not in ("long", "short"):
return None, "方向须为 long 或 short"
try:
capital = float(capital_usdt)
rp = float(risk_percent)
lev = int(leverage)
entry = float(entry_price)
sl = float(stop_loss)
upper = float(add_upper)
tp = float(take_profit)
legs = max(1, int(dca_legs))
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0:
return None, "资金、风险、杠杆与价格须大于 0"
bound_err = validate_trend_bounds(direction, sl, upper)
if bound_err:
return None, bound_err
rf = calc_risk_fraction(direction, upper, sl)
if rf is None or rf <= 0:
return None, "止损与补仓区间边界组合无法计算风险比例"
risk_budget = capital * (rp / 100.0)
notional = risk_budget / rf
margin_plan = min(notional / float(lev), capital * MARGIN_BUFFER)
if margin_plan <= 0:
return None, "计划保证金过小"
target_amt = _amount_from_margin(margin_plan, lev, entry, cs)
if target_amt is None or target_amt <= 0:
return None, "无法计算计划张数,请检查入场价与杠杆"
target_amt = amount_precise(target_amt)
if target_amt is None or target_amt <= 0:
return None, "计划张数低于交易所最小精度"
def _amount_precise(_symbol: str, amount: float) -> Optional[float]:
return amount_precise(amount)
payload, err = compute_trend_plan_core(
direction=direction,
stop_loss=sl,
add_upper=upper,
risk_percent=rp,
snapshot_usdt=capital,
leverage=lev,
live_price=entry,
target_order_amount=target_amt,
exchange_symbol=exchange_symbol,
dca_legs=legs,
amount_precise=_amount_precise,
min_amount=float(market.get("min_amount") or 0.0),
full_margin_buffer_ratio=MARGIN_BUFFER,
)
if err:
return None, err
payload["take_profit"] = tp
payload["leverage"] = lev
payload["contract_size"] = cs
preview, rows = build_trend_preview_level_rows(payload)
px_dec = int(market.get("price_decimals") or 4)
amt_dec = int(market.get("amount_decimals") or 4)
def _f(v: Any, nd: int | None = None) -> Any:
if v is None:
return None
try:
return round(float(v), nd if nd is not None else 8)
except (TypeError, ValueError):
return v
table = []
for row in rows:
table.append(
{
"label": row.get("label"),
"price": _f(row.get("price"), px_dec),
"contracts": _f(row.get("contracts"), amt_dec),
"avg_entry": _f(row.get("avg_entry"), px_dec),
"profit_u": _f(row.get("profit_u")),
"risk_u": _f(row.get("risk_u")),
"rr": _f(row.get("rr"), 4),
}
)
return {
"direction": direction,
"capital_usdt": _f(capital),
"risk_percent": _f(rp, 2),
"risk_budget_u": _f(preview.get("preview_risk_amount_u")),
"leverage": lev,
"entry_price": _f(entry, px_dec),
"stop_loss": _f(sl, px_dec),
"add_upper": _f(upper, px_dec),
"take_profit": _f(tp, px_dec),
"plan_margin_u": _f(preview.get("plan_margin_capital")),
"target_contracts": _f(preview.get("target_order_amount"), amt_dec),
"first_contracts": _f(preview.get("first_order_amount"), amt_dec),
"dca_legs": int(preview.get("dca_legs") or legs),
"first_profit_u": _f(preview.get("preview_first_profit_u")),
"first_rr": _f(preview.get("preview_target_rr"), 4),
"market": market,
"rows": table,
}, None
def _amount_from_margin(
margin_capital: float,
leverage: int,
price: float,
contract_size: float,
) -> Optional[float]:
try:
margin = float(margin_capital)
lev = int(leverage)
px = float(price)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None
if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0:
return None
notional = margin * lev
return notional / (px * cs)
def _round(v: Any, nd: int = 4) -> Any:
if v is None:
return None
try:
return round(float(v), nd)
except (TypeError, ValueError):
return v
def _money_rr(profit_u: Optional[float], risk_u: Optional[float]) -> Optional[float]:
try:
if risk_u is None or float(risk_u) <= 0 or profit_u is None:
return None
return round(float(profit_u) / float(risk_u), 4)
except (TypeError, ValueError):
return None
def calc_initial_roll_qty(
direction: str,
entry_price: float,
stop_loss: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""首仓以损定仓:打到初始止损亏损 = 风险预算。"""
try:
entry = float(entry_price)
sl = float(stop_loss)
budget = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if entry <= 0 or sl <= 0 or budget <= 0 or cs <= 0:
return None, "入场价、止损与风险预算须大于 0"
direction = (direction or "long").strip().lower()
if direction == "short":
per_unit = (sl - entry) * cs
if per_unit <= 0:
return None, "做空:止损价须高于首仓入场价"
else:
per_unit = (entry - sl) * cs
if per_unit <= 0:
return None, "做多:止损价须低于首仓入场价"
return budget / per_unit, None
def solve_add_amount_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""合并持仓打到新止损总亏损 = 风险预算,反推本次加仓张数。"""
try:
q1 = float(qty_existing)
e1 = float(entry_existing)
e2 = float(add_price)
sl = float(new_stop)
b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower()
if direction == "short":
denom = sl - e2
numer = b / cs - q1 * (sl - e1)
if denom <= 0:
return None, "做空:新止损须高于限价加仓价"
else:
denom = e2 - sl
numer = b / cs - q1 * (e1 - sl)
if denom <= 0:
return None, "做多:新止损须低于限价/市价加仓价"
q2 = numer / denom
if q2 <= 0:
return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)"
return q2, None
def _roll_leg_preview(
*,
direction: str,
qty_existing: float,
entry_existing: float,
take_profit: float,
add_price: float,
new_stop_loss: float,
risk_budget: float,
contract_size: float,
amount_precise: Callable[[float], Optional[float]],
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
try:
tp = float(take_profit)
sl = float(new_stop_loss)
entry_add = float(add_price)
e1 = float(entry_existing)
except (TypeError, ValueError):
return None, "止损/止盈格式错误"
if sl <= 0 or tp <= 0 or entry_add <= 0:
return None, "止损与首仓止盈须大于0"
if direction == "long":
if sl >= entry_add:
return None, "做多:新止损须低于加仓价"
if tp <= e1:
return None, "做多:首仓止盈须高于当前持仓均价参考"
else:
if sl <= entry_add:
return None, "做空:新止损须高于加仓价"
if tp >= e1:
return None, "做空:首仓止盈须低于当前持仓均价参考"
q2_raw, err = solve_add_amount_for_total_risk(
direction,
qty_existing,
entry_existing,
entry_add,
sl,
risk_budget,
contract_size,
)
if err:
return None, err
q2 = amount_precise(float(q2_raw))
if q2 is None or q2 <= 0:
return None, "加仓张数低于交易所最小精度"
new_qty = float(qty_existing) + float(q2)
new_avg = (float(qty_existing) * float(entry_existing) + float(q2) * entry_add) / new_qty
cs = float(contract_size) if contract_size else 1.0
if direction == "long":
loss_at_sl = (new_avg - sl) * new_qty * cs
reward_at_tp = (tp - new_avg) * new_qty * cs
else:
loss_at_sl = (sl - new_avg) * new_qty * cs
reward_at_tp = (new_avg - tp) * new_qty * cs
return {
"add_amount_raw": q2,
"qty_after": new_qty,
"avg_entry_after": new_avg,
"add_price": entry_add,
"new_stop_loss": sl,
"loss_at_sl_usdt": loss_at_sl,
"reward_at_tp_usdt": reward_at_tp,
}, None
def calc_roll_calculator(
*,
direction: str,
capital_usdt: float,
risk_percent: float,
entry_price: float,
stop_loss: float,
take_profit: float,
add_legs: list[dict[str, float]] | None = None,
legs_done: int = 0,
exchange_id: str = "0",
base: str = "ETH",
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""
滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓。
add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算。
legs_done: 已完成滚仓次数(仅标记,仍参与链式状态推进)。
"""
market, amount_precise, merr = _resolve_market(exchange_id, base)
if merr or not market or not amount_precise:
return None, merr or "无法解析合约"
contract_size = float(market.get("contract_size") or 1.0)
px_dec = int(market.get("price_decimals") or 4)
amt_dec = int(market.get("amount_decimals") or 4)
direction = (direction or "long").strip().lower()
if direction not in ("long", "short"):
return None, "方向须为 long 或 short"
try:
capital = float(capital_usdt)
rp = float(risk_percent)
entry = float(entry_price)
initial_sl = float(stop_loss)
tp = float(take_profit)
done = max(0, int(legs_done))
except (TypeError, ValueError):
return None, "参数格式错误"
if capital <= 0 or rp <= 0 or entry <= 0 or initial_sl <= 0 or tp <= 0:
return None, "资金、风险与价格须大于 0"
if done > max_roll_legs(direction):
return None, f"已完成滚仓次数不能超过 {max_roll_legs(direction)}"
legs_in: list[dict[str, float]] = []
for raw in add_legs or []:
if not isinstance(raw, dict):
continue
try:
ap = float(raw.get("add_price"))
nsl = float(raw.get("new_stop_loss"))
except (TypeError, ValueError):
return None, "加仓价与新止损须为有效数字"
if ap <= 0 or nsl <= 0:
return None, "加仓价与新止损须大于 0"
legs_in.append({"add_price": ap, "new_stop_loss": nsl})
if done + len(legs_in) > max_roll_legs(direction):
return None, f"已完成 {done} 次 + 待测算 {len(legs_in)} 次,合计不能超过 {max_roll_legs(direction)} 次滚仓"
if direction == "long":
if tp <= entry:
return None, "做多:止盈价须高于首仓入场价"
else:
if tp >= entry:
return None, "做空:止盈价须低于首仓入场价"
risk_budget = capital * (rp / 100.0)
qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget, contract_size)
if err:
return None, err
if qty is None or qty <= 0:
return None, "无法计算首仓张数"
qty_p = amount_precise(float(qty))
if qty_p is None or qty_p <= 0:
return None, "首仓张数低于交易所最小精度"
qty_f = float(qty_p)
avg = entry
rows: list[dict[str, Any]] = []
cs = contract_size
if direction == "long":
first_loss = (avg - initial_sl) * qty_f * cs
first_profit = (tp - avg) * qty_f * cs
else:
first_loss = (initial_sl - avg) * qty_f * cs
first_profit = (avg - tp) * qty_f * cs
rows.append(
{
"label": "首仓",
"leg_index": 0,
"already_done": False,
"entry_or_add_price": _round(entry, px_dec),
"stop_loss": _round(initial_sl, px_dec),
"add_contracts": _round(qty_f, amt_dec),
"total_contracts": _round(qty_f, amt_dec),
"avg_entry": _round(avg, px_dec),
"take_profit": _round(tp, px_dec),
"loss_at_sl_u": _round(first_loss),
"profit_at_tp_u": _round(first_profit),
"rr": _money_rr(first_profit, first_loss),
}
)
current_qty = qty_f
current_avg = avg
for i, leg in enumerate(legs_in):
leg_no = i + 1
preview, err = _roll_leg_preview(
direction=direction,
qty_existing=current_qty,
entry_existing=current_avg,
take_profit=tp,
add_price=leg["add_price"],
new_stop_loss=leg["new_stop_loss"],
risk_budget=risk_budget,
contract_size=cs,
amount_precise=amount_precise,
)
if err:
return None, f"滚仓第 {leg_no} 次:{err}"
if not preview:
return None, f"滚仓第 {leg_no} 次计算失败"
current_qty = float(preview["qty_after"])
current_avg = float(preview["avg_entry_after"])
loss = preview.get("loss_at_sl_usdt")
reward = preview.get("reward_at_tp_usdt")
rows.append(
{
"label": f"滚仓{leg_no}",
"leg_index": leg_no,
"already_done": leg_no <= done,
"entry_or_add_price": _round(preview.get("add_price"), px_dec),
"stop_loss": _round(preview.get("new_stop_loss"), px_dec),
"add_contracts": _round(preview.get("add_amount_raw"), amt_dec),
"total_contracts": _round(current_qty, amt_dec),
"avg_entry": _round(current_avg, px_dec),
"take_profit": _round(tp, px_dec),
"loss_at_sl_u": _round(loss),
"profit_at_tp_u": _round(reward),
"rr": _money_rr(reward, loss),
}
)
last = rows[-1]
return {
"direction": direction,
"capital_usdt": _round(capital),
"risk_percent": _round(rp, 2),
"risk_budget_u": _round(risk_budget),
"entry_price": _round(entry, px_dec),
"stop_loss": _round(initial_sl, px_dec),
"take_profit": _round(tp, px_dec),
"legs_done": done,
"roll_legs_planned": len(legs_in),
"first_contracts": _round(qty_f, amt_dec),
"final_contracts": last.get("total_contracts"),
"final_avg_entry": last.get("avg_entry"),
"final_loss_at_sl_u": last.get("loss_at_sl_u"),
"final_profit_at_tp_u": last.get("profit_at_tp_u"),
"final_rr": last.get("rr"),
"market": market,
"rows": rows,
}, None
+257
View File
@@ -0,0 +1,257 @@
"""计算器:从已配置交易实例读取 USDT 永续合约精度与张数规则。"""
from __future__ import annotations
import json
import threading
import time
import urllib.error
import urllib.request
from typing import Any, Callable, Optional, Tuple
from urllib.parse import urlencode
try:
from settings_store import enabled_exchanges, load_settings
except ImportError:
from manual_trading_hub.settings_store import enabled_exchanges, load_settings
MARKET_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
MARKET_LOCK = threading.Lock()
MARKET_TTL_SEC = 300.0
HUB_FLASK_TIMEOUT = float(__import__("os").getenv("HUB_FLASK_TIMEOUT", "20"))
def normalize_base_symbol(text: str) -> str:
s = str(text or "").upper().strip()
for suf in ("USDT:USDT", "/USDT:USDT", "/USDT", "USDT", "-USDT-SWAP"):
if s.endswith(suf) and len(s) > len(suf):
s = s[: -len(suf)].strip("-/")
break
if "/" in s:
s = s.split("/", 1)[0].strip()
if ":" in s:
s = s.split(":", 1)[0].strip()
return s
def resolve_usdt_perp_symbol(exchange: Any, base: str) -> Tuple[Optional[str], Optional[str]]:
base_u = normalize_base_symbol(base)
if not base_u:
return None, "请输入币种,如 ETH"
candidates = [f"{base_u}/USDT:USDT", f"{base_u}/USDT"]
markets = getattr(exchange, "markets", None) or {}
for sym in candidates:
m = markets.get(sym)
if not m:
continue
if m.get("active") is False:
continue
if m.get("swap") or m.get("linear") or m.get("contract"):
return sym, None
for sym, m in markets.items():
if m.get("active") is False:
continue
if not (m.get("swap") or m.get("linear")):
continue
if (m.get("quote") or "").upper() != "USDT":
continue
if (m.get("base") or "").upper() == base_u:
return sym, None
return None, f"未找到 {base_u}/USDT 永续合约"
def _decimals_from_precision_value(value: Any) -> Optional[int]:
if value in (None, ""):
return None
try:
p = float(value)
except (TypeError, ValueError):
return None
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
return int(round(p))
if 0 < p < 1:
s = f"{p:.12f}".rstrip("0")
if "." in s:
return min(12, len(s.split(".", 1)[1]))
return None
def _decimals_from_ccxt_str(text: str) -> int:
s = str(text or "").strip()
if not s or "." not in s:
return 0
frac = s.split(".", 1)[1]
if not frac:
return 0
return min(12, len(frac.rstrip("0") or frac))
def amount_decimals_from_exchange(exchange: Any, exchange_symbol: str) -> int:
try:
return _decimals_from_ccxt_str(exchange.amount_to_precision(exchange_symbol, 1.23456789))
except Exception:
market = exchange.market(exchange_symbol)
prec = (market.get("precision") or {}).get("amount")
d = _decimals_from_precision_value(prec)
return d if d is not None else 4
def price_decimals_from_exchange(
exchange: Any, exchange_symbol: str, price_tick: Optional[float]
) -> int:
from lib.hub.hub_ohlcv_lib import normalize_price_tick
tick = normalize_price_tick(price_tick)
if tick and tick > 0:
if tick >= 1:
return 0
s = f"{tick:.12f}".rstrip("0")
if "." in s:
return min(12, len(s.split(".", 1)[1]))
try:
return _decimals_from_ccxt_str(exchange.price_to_precision(exchange_symbol, 12345.678901234))
except Exception:
market = exchange.market(exchange_symbol)
prec = (market.get("precision") or {}).get("price")
d = _decimals_from_precision_value(prec)
return d if d is not None else 4
def make_amount_precise_fn_from_market(market: dict[str, Any]) -> Callable[[float], Optional[float]]:
dec = max(0, int(market.get("amount_decimals") or 4))
min_amt = market.get("min_amount")
def _fn(amount: float) -> Optional[float]:
try:
v = float(amount)
except (TypeError, ValueError):
return None
if v <= 0:
return None
factor = 10**dec
v = int(v * factor + 1e-12) / factor
if min_amt is not None:
try:
if v < float(min_amt):
return None
except (TypeError, ValueError):
pass
if v <= 0:
return None
return v
return _fn
def find_exchange(exchange_id: str) -> dict | None:
needle = str(exchange_id or "").strip()
if not needle:
return None
for ex in load_settings().get("exchanges") or []:
if str(ex.get("id") or "").strip() == needle:
return ex
if str(ex.get("key") or "").strip().lower() == needle.lower():
return ex
return None
def list_calculator_exchanges() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for ex in enabled_exchanges():
rows.append(
{
"id": str(ex.get("id") or ""),
"key": str(ex.get("key") or ""),
"name": str(ex.get("name") or ex.get("key") or ""),
"enabled": bool(ex.get("enabled")),
}
)
return rows
def _hub_headers() -> dict[str, str]:
import os
token = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
if token:
return {"X-Hub-Token": token}
return {}
def fetch_instance_market_sync(ex: dict, *, base: str) -> dict[str, Any]:
base_url = (ex.get("flask_url") or "").rstrip("/")
if not base_url:
return {"ok": False, "msg": "未配置 flask_url"}
params = urlencode({"base": normalize_base_symbol(base) or base})
url = f"{base_url}/api/hub/market?{params}"
req = urllib.request.Request(url, headers=_hub_headers(), method="GET")
try:
with urllib.request.urlopen(req, timeout=HUB_FLASK_TIMEOUT) as resp:
status = int(getattr(resp, "status", 200) or 200)
raw = resp.read().decode("utf-8", errors="replace")
data = json.loads(raw) if raw else {}
if not isinstance(data, dict):
return {"ok": False, "msg": "无效 JSON"}
if status >= 400:
data.setdefault("ok", False)
return data
except urllib.error.HTTPError as exc:
try:
raw = exc.read().decode("utf-8", errors="replace")
body = json.loads(raw) if raw else {}
except Exception:
body = {"ok": False, "msg": raw if "raw" in locals() else str(exc)}
if isinstance(body, dict):
body.setdefault("ok", False)
return body
return {"ok": False, "msg": f"HTTP {exc.code}"}
except Exception as exc:
return {"ok": False, "msg": str(exc)}
def _enrich_market_from_settings(ex: dict, payload: dict[str, Any]) -> dict[str, Any]:
out = dict(payload)
out["exchange_id"] = str(ex.get("id") or "")
out["exchange_key"] = str(ex.get("key") or "")
out["exchange_name"] = str(ex.get("name") or ex.get("key") or "")
out["exchange_label"] = out["exchange_name"]
return out
def get_calculator_market(
exchange_id: str,
base: str,
*,
ex: dict | None = None,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""从系统设置中的交易实例拉取合约精度(与实盘一致)。"""
row = ex or find_exchange(exchange_id)
if not row:
return None, "未找到该交易所配置"
if not row.get("enabled"):
return None, f"{row.get('name') or exchange_id} 未启用"
base_u = normalize_base_symbol(base)
if not base_u:
return None, "请输入币种,如 ETH"
cache_key = f"{row.get('id')}:{base_u}"
now = time.time()
with MARKET_LOCK:
cached = MARKET_CACHE.get(cache_key)
if cached and now - cached[0] < MARKET_TTL_SEC:
return dict(cached[1]), None
remote = fetch_instance_market_sync(row, base=base_u)
if not remote.get("ok"):
return None, str(remote.get("msg") or "实例返回失败")
data = _enrich_market_from_settings(row, remote)
with MARKET_LOCK:
MARKET_CACHE[cache_key] = (now, data)
return data, None
def clear_market_cache() -> None:
with MARKET_LOCK:
MARKET_CACHE.clear()
+453
View File
@@ -0,0 +1,453 @@
"""中控开仓计划:进行中 / 历史归档 / 胜率统计。"""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
PLAN_TYPES = {
"trend": "趋势单",
"swing": "波段单",
"intraday": "日内短线",
}
TREND_TIMEFRAMES = ("5m", "15m", "30m", "1h", "4h", "1d")
ENTRY_TIMEFRAMES = ("1m", "5m", "15m", "30m", "1h")
DIRECTIONS = {"long": "", "short": ""}
ENTRY_SCHEMES = {
"breakout": "突破方案",
"false_breakout": "假突破突破方案",
"box_inflection": "箱体拐点方案",
}
RESULTS = {"win": "", "loss": ""}
STAT_DIMENSIONS = ("symbol", "trend_tf", "entry_scheme")
DISPLAY_TZ = ZoneInfo(
(os.getenv("HUB_ENTRY_PLAN_TZ") or os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip()
or "Asia/Shanghai"
)
def default_db_path() -> Path:
raw = (os.getenv("HUB_ENTRY_PLAN_DB_PATH") or "").strip()
if raw:
return Path(raw)
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
hub_dir.mkdir(parents=True, exist_ok=True)
return hub_dir / "hub_entry_plans.db"
def _now_ms() -> int:
return int(time.time() * 1000)
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
path = db_path or default_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def init_db(db_path: Path | None = None) -> None:
conn = _connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS entry_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_date TEXT NOT NULL,
exchange_key TEXT NOT NULL,
symbol TEXT NOT NULL,
plan_type TEXT NOT NULL,
trend_timeframe TEXT NOT NULL,
entry_timeframe TEXT NOT NULL,
direction TEXT NOT NULL,
target_level TEXT NOT NULL DEFAULT '',
current_range TEXT NOT NULL DEFAULT '',
entry_scheme TEXT NOT NULL,
result TEXT,
pnl_amount REAL,
note TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_entry_plans_status_date
ON entry_plans (status, plan_date DESC, id DESC)
"""
)
finally:
conn.close()
def normalize_plan_symbol(raw: str) -> str:
s = str(raw or "").strip().upper()
if not s:
raise ValueError("缺少币种")
if ":" in s:
s = s.split(":", 1)[0]
if "/" in s:
base, quote = s.split("/", 1)
base = base.strip()
quote = (quote or "USDT").strip() or "USDT"
if not base:
raise ValueError("币种无效")
return f"{base}/{quote}"
if s.endswith("USDT") and len(s) > 4:
return f"{s[:-4]}/{s[-4:]}"
return f"{s}/USDT"
def _validate_choice(value: str, allowed: dict[str, str] | tuple[str, ...], field: str) -> str:
key = str(value or "").strip().lower()
if isinstance(allowed, dict):
if key not in allowed:
raise ValueError(f"{field} 无效")
return key
if key not in allowed:
raise ValueError(f"{field} 无效")
return key
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
d = dict(row)
d["plan_type_label"] = PLAN_TYPES.get(d.get("plan_type") or "", d.get("plan_type") or "")
d["direction_label"] = DIRECTIONS.get(d.get("direction") or "", d.get("direction") or "")
d["entry_scheme_label"] = ENTRY_SCHEMES.get(
d.get("entry_scheme") or "", d.get("entry_scheme") or ""
) or "待填写"
res = d.get("result")
d["result_label"] = RESULTS.get(res, "") if res else ""
return d
def _parse_optional_pnl(raw: Any) -> float | None:
if raw is None or raw == "":
return None
try:
return round(float(raw), 4)
except (TypeError, ValueError) as e:
raise ValueError("盈亏金额无效") from e
def create_entry_plan(payload: dict[str, Any], *, db_path: Path | None = None) -> dict[str, Any]:
init_db(db_path)
plan_date = str(payload.get("plan_date") or "").strip()[:10]
if not plan_date:
raise ValueError("缺少 plan_date")
exchange_key = str(payload.get("exchange_key") or "").strip().lower()
if not exchange_key:
raise ValueError("缺少 exchange_key")
symbol = normalize_plan_symbol(payload.get("symbol") or "")
plan_type = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型")
trend_tf = _validate_choice(payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期")
entry_tf = _validate_choice(payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期")
direction = _validate_choice(payload.get("direction"), DIRECTIONS, "方向")
entry_scheme = ""
if payload.get("entry_scheme"):
entry_scheme = _validate_choice(payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案")
target_level = str(payload.get("target_level") or "").strip()
current_range = str(payload.get("current_range") or "").strip()
note = str(payload.get("note") or "").strip()
now = _now_ms()
conn = _connect(db_path)
try:
cur = conn.execute(
"""
INSERT INTO entry_plans (
plan_date, exchange_key, symbol, plan_type, trend_timeframe, entry_timeframe,
direction, target_level, current_range, entry_scheme, note, status,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
""",
(
plan_date,
exchange_key,
symbol,
plan_type,
trend_tf,
entry_tf,
direction,
target_level,
current_range,
entry_scheme,
note,
now,
now,
),
)
row = conn.execute(
"SELECT * FROM entry_plans WHERE id=?",
(int(cur.lastrowid),),
).fetchone()
return _row_to_dict(row) or {}
finally:
conn.close()
def list_entry_plans(
*,
status: str = "active",
db_path: Path | None = None,
) -> list[dict[str, Any]]:
init_db(db_path)
st = (status or "active").strip().lower()
if st not in ("active", "archived"):
raise ValueError("status 无效")
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM entry_plans
WHERE status=?
ORDER BY plan_date DESC, id DESC
""",
(st,),
).fetchall()
return [_row_to_dict(r) for r in rows if r]
finally:
conn.close()
def get_entry_plan(plan_id: int, *, db_path: Path | None = None) -> dict[str, Any] | None:
init_db(db_path)
conn = _connect(db_path)
try:
row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
return _row_to_dict(row)
finally:
conn.close()
def update_entry_plan(
plan_id: int,
payload: dict[str, Any],
*,
db_path: Path | None = None,
) -> dict[str, Any] | None:
init_db(db_path)
conn = _connect(db_path)
try:
row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
if not row:
return None
if row["status"] == "archived":
raise ValueError("已归档计划不可修改")
fields: dict[str, Any] = {}
if "plan_date" in payload:
qd = str(payload.get("plan_date") or "").strip()[:10]
if not qd:
raise ValueError("缺少 plan_date")
fields["plan_date"] = qd
if "exchange_key" in payload:
ex = str(payload.get("exchange_key") or "").strip().lower()
if not ex:
raise ValueError("缺少 exchange_key")
fields["exchange_key"] = ex
if "symbol" in payload:
fields["symbol"] = normalize_plan_symbol(payload.get("symbol") or "")
if "plan_type" in payload:
fields["plan_type"] = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型")
if "trend_timeframe" in payload:
fields["trend_timeframe"] = _validate_choice(
payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期"
)
if "entry_timeframe" in payload:
fields["entry_timeframe"] = _validate_choice(
payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期"
)
if "direction" in payload:
fields["direction"] = _validate_choice(payload.get("direction"), DIRECTIONS, "方向")
if "entry_scheme" in payload:
fields["entry_scheme"] = _validate_choice(
payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案"
)
if "target_level" in payload:
fields["target_level"] = str(payload.get("target_level") or "").strip()
if "current_range" in payload:
fields["current_range"] = str(payload.get("current_range") or "").strip()
if "note" in payload:
fields["note"] = str(payload.get("note") or "").strip()
if "pnl_amount" in payload:
fields["pnl_amount"] = _parse_optional_pnl(payload.get("pnl_amount"))
archive_now = False
if "result" in payload:
res_raw = payload.get("result")
if res_raw is None or str(res_raw).strip() == "":
fields["result"] = None
else:
fields["result"] = _validate_choice(res_raw, RESULTS, "结果")
archive_now = True
if not fields:
return _row_to_dict(row)
now = _now_ms()
fields["updated_at"] = now
if archive_now:
scheme_val = fields.get("entry_scheme", row["entry_scheme"])
if not str(scheme_val or "").strip():
raise ValueError("归档前请在进行中计划里选择入场方案")
fields["status"] = "archived"
fields["archived_at"] = now
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(
f"UPDATE entry_plans SET {sets} WHERE id=?",
(*fields.values(), int(plan_id)),
)
updated = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
return _row_to_dict(updated)
finally:
conn.close()
def delete_entry_plan(plan_id: int, *, db_path: Path | None = None) -> bool:
init_db(db_path)
conn = _connect(db_path)
try:
row = conn.execute("SELECT status FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
if not row:
return False
if row["status"] != "active":
raise ValueError("仅进行中的计划可删除")
cur = conn.execute("DELETE FROM entry_plans WHERE id=? AND status='active'", (int(plan_id),))
return int(cur.rowcount or 0) > 0
finally:
conn.close()
def _today_iso() -> str:
return datetime.now(DISPLAY_TZ).strftime("%Y-%m-%d")
def resolve_stats_date_bounds(
*,
period: str = "all",
date_from: str = "",
date_to: str = "",
) -> tuple[str | None, str | None, str]:
"""返回 (date_from, date_to, label)all 时 bounds 为 None。"""
p = (period or "all").strip().lower() or "all"
today = _today_iso()
if p == "all":
return None, None, "全部历史"
if p == "week":
day_dt = datetime.strptime(today, "%Y-%m-%d")
monday = (day_dt - timedelta(days=day_dt.weekday())).strftime("%Y-%m-%d")
return monday, today, f"本周 {monday}{today}"
if p == "month":
day_dt = datetime.strptime(today, "%Y-%m-%d")
first = day_dt.replace(day=1).strftime("%Y-%m-%d")
return first, today, f"本月 {first}{today}"
if p == "range":
df = (date_from or "").strip()[:10] or today
dt = (date_to or "").strip()[:10] or df
if df > dt:
df, dt = dt, df
label = f"区间 {df}{dt}" if df != dt else f"区间 {df}"
return df, dt, label
return None, None, "全部历史"
def compute_entry_plan_stats(
*,
dimension: str = "symbol",
period: str = "all",
date_from: str = "",
date_to: str = "",
db_path: Path | None = None,
) -> dict[str, Any]:
init_db(db_path)
dim = (dimension or "symbol").strip().lower()
if dim not in STAT_DIMENSIONS:
raise ValueError("dimension 无效")
df_bound, dt_bound, period_label = resolve_stats_date_bounds(
period=period, date_from=date_from, date_to=date_to
)
col_map = {
"symbol": "symbol",
"trend_tf": "trend_timeframe",
"entry_scheme": "entry_scheme",
}
col = col_map[dim]
conn = _connect(db_path)
try:
where = "status='archived' AND result IN ('win','loss')"
params: list[Any] = []
if df_bound:
where += " AND plan_date >= ? AND plan_date <= ?"
params.extend([df_bound, dt_bound])
rows = conn.execute(
f"""
SELECT {col} AS dim_key,
COUNT(*) AS total,
SUM(CASE WHEN result='win' THEN 1 ELSE 0 END) AS win_count,
SUM(CASE WHEN result='loss' THEN 1 ELSE 0 END) AS loss_count
FROM entry_plans
WHERE {where}
GROUP BY {col}
ORDER BY total DESC, dim_key ASC
""",
params,
).fetchall()
items = []
for r in rows:
total = int(r["total"] or 0)
wins = int(r["win_count"] or 0)
losses = int(r["loss_count"] or 0)
key = str(r["dim_key"] or "")
label = key
if dim == "entry_scheme":
label = ENTRY_SCHEMES.get(key, key)
elif dim == "trend_tf":
label = key
win_rate = round(wins / total * 100, 1) if total else None
items.append(
{
"key": key,
"label": label,
"total": total,
"win_count": wins,
"loss_count": losses,
"win_rate": win_rate,
}
)
return {
"dimension": dim,
"period": period,
"period_label": period_label,
"date_from": df_bound,
"date_to": dt_bound,
"items": items,
}
finally:
conn.close()
def meta_payload(exchanges: list[dict[str, Any]] | None = None) -> dict[str, Any]:
return {
"plan_types": [{"value": k, "label": v} for k, v in PLAN_TYPES.items()],
"trend_timeframes": list(TREND_TIMEFRAMES),
"entry_timeframes": list(ENTRY_TIMEFRAMES),
"directions": [{"value": k, "label": v} for k, v in DIRECTIONS.items()],
"entry_schemes": [{"value": k, "label": v} for k, v in ENTRY_SCHEMES.items()],
"results": [{"value": k, "label": v} for k, v in RESULTS.items()],
"stat_dimensions": [
{"value": "symbol", "label": "币种"},
{"value": "trend_tf", "label": "趋势周期"},
{"value": "entry_scheme", "label": "入场方案"},
],
"exchanges": exchanges or [],
}
+407
View File
@@ -0,0 +1,407 @@
"""中控资金概况:分户日快照(180 交易日)、总资金曲线与回撤。"""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
from lib.hub.hub_trades_lib import current_trading_day
HUB_DIR = Path(__file__).resolve().parent / "manual_trading_hub"
FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json"
LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json"
try:
FUND_HISTORY_DAYS = max(30, int(os.getenv("HUB_FUND_HISTORY_DAYS", "180") or "180"))
except ValueError:
FUND_HISTORY_DAYS = 180
FUND_HISTORY_START_DAY = (os.getenv("HUB_FUND_HISTORY_START_DAY") or "2026-06-09").strip()[:10]
def fund_history_start_day() -> str:
return FUND_HISTORY_START_DAY or "2026-06-09"
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _safe_float(value: Any) -> Optional[float]:
try:
v = float(value)
return v if v >= 0 else None
except (TypeError, ValueError):
return None
def account_total_usdt(funding: Any, trading: Any) -> Optional[float]:
"""资金户 + 交易户;任一侧缺失则不计入(返回 None)。"""
fu = _safe_float(funding)
tu = _safe_float(trading)
if fu is None or tu is None:
return None
return round(fu + tu, 4)
def compute_drawdown(values: list[float]) -> dict[str, Any]:
"""基于资金权益序列计算峰值回撤(U 与 %)。"""
peak = 0.0
max_dd_u = 0.0
peak_at_end = 0.0
for v in values:
if not isinstance(v, (int, float)):
continue
fv = float(v)
if fv > peak:
peak = fv
dd = peak - fv
if dd > max_dd_u:
max_dd_u = dd
peak_at_end = peak
max_dd_u = round(max_dd_u, 4)
peak_at_end = round(peak_at_end, 4)
max_dd_pct = round((max_dd_u / peak_at_end) * 100, 2) if peak_at_end > 0 else None
return {
"peak_usdt": peak_at_end,
"max_drawdown_u": max_dd_u,
"max_drawdown_pct": max_dd_pct,
}
def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def _prune_days(
days: dict,
*,
keep_days: int,
anchor_day: str,
start_day: Optional[str] = None,
) -> dict:
try:
anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d")
except ValueError:
anchor = datetime.now()
rolling_cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d")
start = (start_day or fund_history_start_day()).strip()[:10]
cutoff = max(rolling_cutoff, start) if start else rolling_cutoff
return {k: v for k, v in (days or {}).items() if str(k) >= cutoff}
def _migrate_legacy_store(days: dict) -> dict:
if not LEGACY_FUND_HISTORY_PATH.is_file():
return days
try:
loaded = json.loads(LEGACY_FUND_HISTORY_PATH.read_text(encoding="utf-8"))
legacy_days = loaded.get("days") if isinstance(loaded, dict) else {}
if not isinstance(legacy_days, dict):
return days
merged = dict(days)
for day, block in legacy_days.items():
if day in merged:
continue
if isinstance(block, dict) and block.get("accounts"):
merged[day] = block
return merged
except Exception:
return days
def _load_store() -> dict:
if not FUND_HISTORY_PATH.is_file():
store = {"version": 1, "days": _migrate_legacy_store({})}
if store["days"]:
_atomic_write(FUND_HISTORY_PATH, store)
return store
try:
loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
loaded.setdefault("version", 1)
days = dict(loaded.get("days") or {})
loaded["days"] = _migrate_legacy_store(days)
return loaded
except Exception:
pass
return {"version": 1, "days": {}}
def record_fund_snapshot(
trading_day: str,
accounts: list[dict],
*,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
) -> dict[str, Any]:
"""写入当日各户资金账户/交易账户余额,并裁剪历史。"""
day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour)
start = fund_history_start_day()
if start and day < start:
return _load_store().get("days") or {}
store = _load_store()
days = dict(store.get("days") or {})
row_accounts: dict[str, dict] = {}
for ac in accounts or []:
key = str(ac.get("key") or ac.get("id") or "").strip()
if not key:
continue
if not ac.get("monitored"):
continue
fu = _safe_float(ac.get("funding_usdt"))
tu = _safe_float(ac.get("trading_usdt"))
total = account_total_usdt(fu, tu)
if total is None:
continue
row_accounts[key] = {
"name": ac.get("name"),
"funding_usdt": fu,
"trading_usdt": tu,
"total_usdt": total,
"recorded_at": _now_str(),
}
if row_accounts:
days[day] = {"accounts": row_accounts, "updated_at": _now_str()}
days = _prune_days(
days, keep_days=keep_days, anchor_day=day, start_day=fund_history_start_day()
)
_atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days})
return days
def record_fund_snapshot_from_board(
rows: list[dict],
*,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
) -> dict[str, Any]:
"""监控板行写入当日快照(仅 account_ok 且资金/交易户齐全)。"""
day = current_trading_day(reset_hour=reset_hour)
accounts = []
for row in rows or []:
if not isinstance(row, dict):
continue
if not row.get("account_ok"):
continue
accounts.append(
{
"key": row.get("key") or row.get("id"),
"name": row.get("name"),
"funding_usdt": row.get("funding_usdt"),
"trading_usdt": row.get("trading_usdt"),
"monitored": True,
}
)
return record_fund_snapshot(day, accounts, keep_days=keep_days, reset_hour=reset_hour)
def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]:
store = _load_store()
return _prune_days(
dict(store.get("days") or {}),
keep_days=keep_days,
anchor_day=anchor_day,
start_day=fund_history_start_day(),
)
def _exchange_monitored(ex: dict) -> bool:
return bool(ex.get("enabled")) and not bool(ex.get("env_disabled"))
def _live_row_for_exchange(ex: dict, rows_by_key: dict[str, dict]) -> Optional[dict]:
key = str(ex.get("key") or "").strip()
if not key:
return None
return rows_by_key.get(key)
def _series_from_history(
history: dict[str, dict],
account_keys: list[str],
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for day in sorted(history.keys()):
block = history.get(day) or {}
ac_map = block.get("accounts") or {}
total = 0.0
n = 0
for key in account_keys:
ac = ac_map.get(key) or {}
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
if t is None:
t = _safe_float(ac.get("total_usdt"))
if t is None:
continue
total += t
n += 1
if n > 0:
out.append({"day": day, "total_usdt": round(total, 4)})
return out
def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for day in sorted(history.keys()):
ac = (history.get(day) or {}).get("accounts", {}).get(key) or {}
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
if t is None:
t = _safe_float(ac.get("total_usdt"))
if t is None:
continue
out.append(
{
"day": day,
"total_usdt": t,
"funding_usdt": _safe_float(ac.get("funding_usdt")),
"trading_usdt": _safe_float(ac.get("trading_usdt")),
}
)
return out
def build_fund_overview(
exchanges: list[dict],
*,
board_rows: Optional[list[dict]] = None,
trading_day: Optional[str] = None,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
updated_at: Optional[str] = None,
) -> dict[str, Any]:
day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour)
history = get_fund_history(anchor_day=day, keep_days=keep_days)
rows_by_key: dict[str, dict] = {}
for row in board_rows or []:
if isinstance(row, dict):
k = str(row.get("key") or "").strip()
if k:
rows_by_key[k] = row
monitored_keys: list[str] = []
accounts_out: list[dict[str, Any]] = []
live_total = 0.0
live_known = 0
for ex in exchanges or []:
if not _exchange_monitored(ex):
continue
key = str(ex.get("key") or "").strip()
monitored = True
row = _live_row_for_exchange(ex, rows_by_key)
fu = tu = total = None
data_ok = False
if row and row.get("account_ok"):
fu = _safe_float(row.get("funding_usdt"))
tu = _safe_float(row.get("trading_usdt"))
total = account_total_usdt(fu, tu)
data_ok = total is not None
if data_ok:
live_total += total
live_known += 1
series = _account_series(history, key) if key else []
dd = compute_drawdown([p["total_usdt"] for p in series]) if series else {
"peak_usdt": None,
"max_drawdown_u": None,
"max_drawdown_pct": None,
}
day_delta = None
if series:
if len(series) >= 2:
day_delta = round(series[-1]["total_usdt"] - series[-2]["total_usdt"], 4)
elif data_ok and total is not None:
day_delta = round(total - series[-1]["total_usdt"], 4)
accounts_out.append(
{
"id": ex.get("id"),
"key": key,
"name": ex.get("name") or key,
"monitored": monitored,
"data_ok": data_ok,
"funding_usdt": fu,
"trading_usdt": tu,
"total_usdt": total,
"series": series,
"drawdown": dd,
"day_delta_usdt": day_delta,
}
)
if key:
monitored_keys.append(key)
total_series = _series_from_history(history, monitored_keys)
if live_known > 0:
last_day = total_series[-1]["day"] if total_series else None
live_point = round(live_total, 4)
if last_day == day and total_series:
total_series[-1]["total_usdt"] = live_point
total_series[-1]["live"] = True
else:
total_series.append({"day": day, "total_usdt": live_point, "live": True})
total_dd = compute_drawdown([p["total_usdt"] for p in total_series]) if total_series else {
"peak_usdt": None,
"max_drawdown_u": None,
"max_drawdown_pct": None,
}
total_day_delta = None
if total_series:
if len(total_series) >= 2:
total_day_delta = round(
total_series[-1]["total_usdt"] - total_series[-2]["total_usdt"], 4
)
return {
"ok": True,
"trading_day": day,
"reset_hour": reset_hour,
"keep_days": keep_days,
"history_start_day": fund_history_start_day(),
"updated_at": updated_at,
"totals": {
"monitored_count": len(monitored_keys),
"live_known_count": live_known,
"total_usdt": round(live_total, 4) if live_known > 0 else None,
"day_delta_usdt": total_day_delta,
"series": total_series,
"drawdown": total_dd,
},
"accounts": accounts_out,
}
def format_fund_history_text(
history: dict[str, dict],
*,
account_names: Optional[dict[str, str]] = None,
) -> str:
if not history:
return "(暂无资金历史快照)"
names = account_names or {}
lines = ["【资金快照(资金账户 + 交易账户 USDT)】"]
for day in sorted(history.keys()):
block = history.get(day) or {}
ac_map = block.get("accounts") or {}
if not ac_map:
continue
parts = []
for key, ac in ac_map.items():
label = names.get(key) or ac.get("name") or key
fu = ac.get("funding_usdt")
tu = ac.get("trading_usdt")
tot = ac.get("total_usdt")
if tot is None:
tot = account_total_usdt(fu, tu)
fu_txt = f"{fu}U" if fu is not None else "未知"
tu_txt = f"{tu}U" if tu is not None else "未知"
tot_txt = f"{tot}U" if tot is not None else "未知"
parts.append(f"{label}: 合计{tot_txt}(资金{fu_txt}/交易{tu_txt}")
lines.append(f"- {day}: " + "".join(parts))
return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)"
+98
View File
@@ -0,0 +1,98 @@
"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条)。"""
from __future__ import annotations
import os
import socket
import time
from typing import Any
_state: dict[str, Any] = {
"primed": False,
"net_ts": 0.0,
"net_sent": 0,
"net_recv": 0,
}
def _disk_path() -> str:
raw = (os.getenv("HUB_HOST_DISK_PATH") or "").strip()
if raw:
return raw
if os.name == "nt":
drive = (os.environ.get("SystemDrive") or "C:").strip()
return drive if drive.endswith(("\\", "/")) else drive + "\\"
return "/"
def _safe_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def get_host_status() -> dict[str, Any]:
try:
import psutil
except ImportError:
return {
"ok": False,
"msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil",
}
now = time.time()
if not _state["primed"]:
psutil.cpu_percent(interval=None)
_state["primed"] = True
cpu_pct = float(psutil.cpu_percent(interval=None))
cpu_count = int(psutil.cpu_count(logical=True) or 0)
vm = psutil.virtual_memory()
disk_path = _disk_path()
du = psutil.disk_usage(disk_path)
net = psutil.net_io_counters()
sent_rate = 0.0
recv_rate = 0.0
if net is not None and _state["net_ts"] > 0:
dt = max(0.001, now - float(_state["net_ts"]))
sent_rate = max(0.0, (net.bytes_sent - int(_state["net_sent"])) / dt)
recv_rate = max(0.0, (net.bytes_recv - int(_state["net_recv"])) / dt)
if net is not None:
_state["net_ts"] = now
_state["net_sent"] = int(net.bytes_sent)
_state["net_recv"] = int(net.bytes_recv)
disk_total = _safe_int(du.total)
disk_used = _safe_int(du.used)
disk_pct = round(disk_used / disk_total * 100, 1) if disk_total > 0 else 0.0
boot = float(psutil.boot_time())
return {
"ok": True,
"hostname": socket.gethostname(),
"uptime_sec": max(0, int(now - boot)),
"cpu": {
"percent": round(cpu_pct, 1),
"count": cpu_count,
},
"memory": {
"total_bytes": _safe_int(vm.total),
"used_bytes": _safe_int(vm.used),
"percent": round(float(vm.percent), 1),
},
"disk": {
"path": disk_path,
"total_bytes": disk_total,
"used_bytes": disk_used,
"percent": disk_pct,
},
"network": {
"bytes_sent": _safe_int(net.bytes_sent if net else 0),
"bytes_recv": _safe_int(net.bytes_recv if net else 0),
"sent_rate_bps": round(sent_rate, 1),
"recv_rate_bps": round(recv_rate, 1),
},
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
+881
View File
@@ -0,0 +1,881 @@
"""中控 K 线 SQLite:分周期保留、交易所直拉、分页读取。"""
from __future__ import annotations
import os
import sqlite3
import time
from pathlib import Path
from typing import Any, Callable, Optional
from lib.hub.hub_ohlcv_lib import (
HUB_KLINE_1M_MAX_BARS,
HUB_KLINE_5M_1H_RETENTION_DAYS,
TIMEFRAME_MS,
YEAR_ROLLING_STORED,
chart_chunk_limit,
chart_initial_limit,
chart_memory_cap,
history_cutoff_ms_for_storage,
normalize_chart_timeframe,
normalize_price_tick,
format_price_by_tick,
last_closed_bar_open_ms,
retention_policy_meta,
round_ohlcv_bars_to_tick,
seed_bar_target,
)
HUB_KLINE_MIN_BARS_BEFORE_TAIL = 200
HUB_KLINE_REMOTE_FETCH_CAP = 1500
_DEFAULT_RETENTION_DAYS = 15
def retention_days() -> int:
"""兼容旧配置;新策略见 retention_policy_meta。"""
try:
return max(1, int(os.getenv("HUB_KLINE_RETENTION_DAYS", str(_DEFAULT_RETENTION_DAYS))))
except ValueError:
return _DEFAULT_RETENTION_DAYS
def default_db_path() -> Path:
raw = (os.getenv("HUB_KLINE_DB_PATH") or "").strip()
if raw:
return Path(raw)
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
hub_dir.mkdir(parents=True, exist_ok=True)
return hub_dir / "hub_kline.db"
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
path = db_path or default_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def init_db(db_path: Path | None = None) -> None:
conn = _connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS ohlcv_bars (
exchange_key TEXT NOT NULL,
symbol TEXT NOT NULL,
timeframe TEXT NOT NULL,
open_time_ms INTEGER NOT NULL,
open REAL NOT NULL,
high REAL NOT NULL,
low REAL NOT NULL,
close REAL NOT NULL,
volume REAL NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (exchange_key, symbol, timeframe, open_time_ms)
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_ohlcv_series
ON ohlcv_bars (exchange_key, symbol, timeframe, open_time_ms)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS ohlcv_symbol_meta (
exchange_key TEXT NOT NULL,
symbol TEXT NOT NULL,
price_tick REAL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (exchange_key, symbol)
)
"""
)
finally:
conn.close()
def save_symbol_price_tick(
exchange_key: str,
symbol: str,
price_tick: float | None,
db_path: Path | None = None,
) -> None:
tick = price_tick
if tick is None:
return
try:
t = float(tick)
except (TypeError, ValueError):
return
if t <= 0:
return
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
conn = _connect(db_path)
try:
conn.execute(
"""
INSERT INTO ohlcv_symbol_meta (exchange_key, symbol, price_tick, updated_at)
VALUES (?,?,?,?)
ON CONFLICT(exchange_key, symbol) DO UPDATE SET
price_tick=excluded.price_tick,
updated_at=excluded.updated_at
""",
(ex_k, sym, t, int(time.time())),
)
finally:
conn.close()
def load_symbol_price_tick(
exchange_key: str,
symbol: str,
db_path: Path | None = None,
) -> float | None:
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
conn = _connect(db_path)
try:
row = conn.execute(
"SELECT price_tick FROM ohlcv_symbol_meta WHERE exchange_key=? AND symbol=?",
(ex_k, sym),
).fetchone()
if not row or row["price_tick"] is None:
return None
return float(row["price_tick"])
except (TypeError, ValueError):
return None
finally:
conn.close()
def purge_timeframe_by_days(
timeframe: str,
days: int,
db_path: Path | None = None,
) -> int:
cutoff = int(time.time() * 1000) - max(1, int(days)) * 86400000
tf = normalize_chart_timeframe(timeframe)
conn = _connect(db_path)
try:
cur = conn.execute(
"DELETE FROM ohlcv_bars WHERE timeframe=? AND open_time_ms < ?",
(tf, cutoff),
)
return int(cur.rowcount or 0)
finally:
conn.close()
def purge_1m_bar_cap(db_path: Path | None = None, *, max_bars: int | None = None) -> int:
cap = max(100, int(max_bars or HUB_KLINE_1M_MAX_BARS))
conn = _connect(db_path)
try:
cur = conn.execute(
"""
DELETE FROM ohlcv_bars
WHERE timeframe='1m' AND rowid IN (
SELECT rowid FROM (
SELECT rowid,
ROW_NUMBER() OVER (
PARTITION BY exchange_key, symbol
ORDER BY open_time_ms DESC
) AS rn
FROM ohlcv_bars
WHERE timeframe='1m'
) WHERE rn > ?
)
""",
(cap,),
)
return int(cur.rowcount or 0)
finally:
conn.close()
def clear_series_bars(
exchange_key: str,
symbol: str,
timeframe: str | None = None,
db_path: Path | None = None,
) -> int:
"""删除某交易所+币种 K 线(可指定周期);用于清库后全量重拉。"""
init_db(db_path)
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
if not ex_k or not sym:
return 0
conn = _connect(db_path)
try:
if timeframe:
tf = normalize_chart_timeframe(timeframe)
cur = conn.execute(
"DELETE FROM ohlcv_bars WHERE exchange_key=? AND symbol=? AND timeframe=?",
(ex_k, sym, tf),
)
else:
cur = conn.execute(
"DELETE FROM ohlcv_bars WHERE exchange_key=? AND symbol=?",
(ex_k, sym),
)
return int(cur.rowcount or 0)
finally:
conn.close()
def clear_all_bars(db_path: Path | None = None) -> int:
"""清空 hub K 线库全部 OHLCV 行。"""
init_db(db_path)
conn = _connect(db_path)
try:
cur = conn.execute("DELETE FROM ohlcv_bars")
return int(cur.rowcount or 0)
finally:
conn.close()
def purge_retention(db_path: Path | None = None) -> int:
"""按周期策略清理:5m/15m/1h/2h/4h 一年;1m 保留最近 N 根;1d/1w 不删。"""
n = 0
for tf in sorted(YEAR_ROLLING_STORED):
n += purge_timeframe_by_days(tf, HUB_KLINE_5M_1H_RETENTION_DAYS, db_path)
n += purge_1m_bar_cap(db_path)
return n
def upsert_bars(
exchange_key: str,
symbol: str,
timeframe: str,
bars: list[dict[str, Any]],
db_path: Path | None = None,
) -> int:
if not bars:
return 0
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
tf = normalize_chart_timeframe(timeframe)
now = int(time.time())
conn = _connect(db_path)
n = 0
try:
for b in bars:
try:
oms = int(b["open_time_ms"])
conn.execute(
"""
INSERT INTO ohlcv_bars
(exchange_key, symbol, timeframe, open_time_ms, open, high, low, close, volume, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(exchange_key, symbol, timeframe, open_time_ms) DO UPDATE SET
open=excluded.open,
high=excluded.high,
low=excluded.low,
close=excluded.close,
volume=excluded.volume,
updated_at=excluded.updated_at
""",
(
ex_k,
sym,
tf,
oms,
float(b["open"]),
float(b["high"]),
float(b["low"]),
float(b["close"]),
float(b.get("volume") or 0),
now,
),
)
n += 1
except (KeyError, TypeError, ValueError):
continue
finally:
conn.close()
return n
def load_bars_range(
exchange_key: str,
symbol: str,
timeframe: str,
start_ms: int,
end_ms: int,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
tf = normalize_chart_timeframe(timeframe)
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT open_time_ms, open, high, low, close, volume
FROM ohlcv_bars
WHERE exchange_key=? AND symbol=? AND timeframe=?
AND open_time_ms >= ? AND open_time_ms <= ?
ORDER BY open_time_ms ASC
""",
(ex_k, sym, tf, int(start_ms), int(end_ms)),
).fetchall()
return _rows_to_bars(rows)
finally:
conn.close()
def count_series_bars(
exchange_key: str,
symbol: str,
timeframe: str,
db_path: Path | None = None,
) -> int:
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
tf = normalize_chart_timeframe(timeframe)
conn = _connect(db_path)
try:
row = conn.execute(
"""
SELECT COUNT(*) AS c FROM ohlcv_bars
WHERE exchange_key=? AND symbol=? AND timeframe=?
""",
(ex_k, sym, tf),
).fetchone()
return int(row["c"] or 0) if row else 0
finally:
conn.close()
def _remote_fetch_limit(
*,
need: int,
force_refresh: bool,
storage_tf: str,
tail_only: bool,
) -> int:
if tail_only:
return min(need + 20, 300)
cap = HUB_KLINE_REMOTE_FETCH_CAP
if force_refresh:
return min(seed_bar_target(storage_tf), cap)
return min(max(need + 20, 1), cap)
def _since_ms_for_span(
*,
now_ms: int,
period_ms: int,
span_bars: int,
cutoff_ms: int,
) -> int:
"""拉取窗口起点:跨度必须与 fetch_limit 一致,保证数据能铺到最近。"""
span = max(1, int(span_bars))
return max(int(cutoff_ms), int(now_ms) - int(period_ms) * span)
def load_bars_latest(
exchange_key: str,
symbol: str,
timeframe: str,
limit: int,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
tf = normalize_chart_timeframe(timeframe)
lim = max(1, int(limit))
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT open_time_ms, open, high, low, close, volume
FROM ohlcv_bars
WHERE exchange_key=? AND symbol=? AND timeframe=?
ORDER BY open_time_ms DESC
LIMIT ?
""",
(ex_k, sym, tf, lim),
).fetchall()
return list(reversed(_rows_to_bars(rows)))
finally:
conn.close()
def load_bars_before(
exchange_key: str,
symbol: str,
timeframe: str,
before_ms: int,
limit: int,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
tf = normalize_chart_timeframe(timeframe)
lim = max(1, int(limit))
bms = int(before_ms)
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT open_time_ms, open, high, low, close, volume
FROM ohlcv_bars
WHERE exchange_key=? AND symbol=? AND timeframe=?
AND open_time_ms < ?
ORDER BY open_time_ms DESC
LIMIT ?
""",
(ex_k, sym, tf, bms, lim),
).fetchall()
return list(reversed(_rows_to_bars(rows)))
finally:
conn.close()
def trim_contiguous_tail(
bars: list[dict[str, Any]],
period_ms: int,
*,
max_gap_factor: float = 3.0,
) -> tuple[list[dict[str, Any]], int]:
"""只保留最近一段连续 K 线,丢弃左侧与主段断开的孤立数据。"""
if len(bars) <= 1:
return list(bars), 0
try:
period = max(1, int(period_ms))
except (TypeError, ValueError):
period = 60_000
max_gap = int(period * max_gap_factor)
split = 0
for i in range(len(bars) - 1, 0, -1):
gap = int(bars[i]["open_time_ms"]) - int(bars[i - 1]["open_time_ms"])
if gap > max_gap:
split = i
break
return bars[split:], split
def normalize_contiguous_db_rows(
bars: list[dict[str, Any]],
*,
period_ms: int,
exchange_key: str,
symbol: str,
timeframe: str,
db_path: Path | None = None,
purge_orphans: bool = True,
) -> list[dict[str, Any]]:
"""去掉与主段断开的孤立前缀;可选同步清理库内孤立数据。"""
if len(bars) <= 1:
return list(bars)
trimmed, split_at = trim_contiguous_tail(bars, period_ms)
if split_at > 0 and purge_orphans:
purge_bars_open_before(
exchange_key,
symbol,
timeframe,
int(trimmed[0]["open_time_ms"]),
db_path,
)
return trimmed
def purge_bars_open_before(
exchange_key: str,
symbol: str,
timeframe: str,
open_time_ms: int,
db_path: Path | None = None,
) -> int:
"""删除某品种周期下早于 open_time_ms 的 K 线(清理与主段断开的孤立历史)。"""
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
tf = normalize_chart_timeframe(timeframe)
conn = _connect(db_path)
try:
cur = conn.execute(
"""
DELETE FROM ohlcv_bars
WHERE exchange_key=? AND symbol=? AND timeframe=? AND open_time_ms < ?
""",
(ex_k, sym, tf, int(open_time_ms)),
)
return int(cur.rowcount or 0)
finally:
conn.close()
def _rows_to_bars(rows) -> list[dict[str, Any]]:
return [
{
"open_time_ms": int(r["open_time_ms"]),
"open": float(r["open"]),
"high": float(r["high"]),
"low": float(r["low"]),
"close": float(r["close"]),
"volume": float(r["volume"] or 0),
}
for r in rows
]
def _to_chart_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
for b in bars:
try:
out.append(
{
"time": int(b["open_time_ms"] // 1000),
"open": float(b["open"]),
"high": float(b["high"]),
"low": float(b["low"]),
"close": float(b["close"]),
"volume": float(b.get("volume") or 0),
}
)
except (KeyError, TypeError, ValueError):
continue
return out
def _trim_display_bars(
bars: list[dict[str, Any]],
*,
need: int,
before_ms: int | None,
) -> list[dict[str, Any]]:
if not bars:
return []
if before_ms is not None and int(before_ms) > 0:
bms = int(before_ms)
bars = [b for b in bars if int(b["open_time_ms"]) < bms]
if len(bars) > need:
bars = bars[-need:]
return bars
if len(bars) > need:
bars = bars[-need:]
return bars
def resolve_chart_bars(
exchange_key: str,
symbol: str,
timeframe: str,
remote_fetch: Callable[..., dict[str, Any]],
*,
db_path: Path | None = None,
force_refresh: bool = False,
tail_refresh: bool = False,
clear_db: bool = False,
limit: int | None = None,
before_ms: int | None = None,
) -> dict[str, Any]:
"""
分页读库:首屏 / 左拖 before_ms / 尾部 tail_refresh。
各展示周期均直读交易所同步入库的同名 K 线。
"""
init_db(db_path)
purged = purge_retention(db_path)
cleared = 0
sym = (symbol or "").strip().upper()
ex_k = (exchange_key or "").strip().lower()
display_tf = normalize_chart_timeframe(timeframe)
if not sym or not ex_k:
return {"ok": False, "msg": "缺少 exchange 或 symbol"}
storage_tf = display_tf
is_history = before_ms is not None and int(before_ms) > 0
need = int(
limit
or (chart_chunk_limit(display_tf) if is_history else chart_initial_limit(display_tf))
)
need = max(1, min(need, chart_memory_cap(display_tf)))
now_ms = int(time.time() * 1000)
period_display = TIMEFRAME_MS[display_tf]
period_storage = TIMEFRAME_MS[storage_tf]
series_bar_count = (
count_series_bars(ex_k, sym, storage_tf, db_path) if not is_history else 0
)
if tail_refresh and not is_history:
min_seed = min(chart_initial_limit(display_tf) // 5, HUB_KLINE_MIN_BARS_BEFORE_TAIL)
if series_bar_count < max(1, min_seed):
tail_refresh = False
else:
need = min(need, 30)
cutoff = history_cutoff_ms_for_storage(storage_tf, now_ms)
if clear_db and not is_history and not tail_refresh:
cleared = clear_series_bars(ex_k, sym, storage_tf, db_path)
def load_display_rows() -> list[dict[str, Any]]:
if is_history:
rows = load_bars_before(ex_k, sym, storage_tf, int(before_ms), need, db_path)
return _trim_display_bars(rows, need=need, before_ms=int(before_ms))
return load_bars_latest(ex_k, sym, storage_tf, need, db_path)
db_rows: list[dict[str, Any]] = []
if not force_refresh:
db_rows = load_display_rows()
if not is_history and db_rows:
db_rows = normalize_contiguous_db_rows(
db_rows,
period_ms=period_display,
exchange_key=ex_k,
symbol=sym,
timeframe=storage_tf,
db_path=db_path,
)
last_closed = last_closed_bar_open_ms(display_tf, now_ms)
newest_db = db_rows[-1]["open_time_ms"] if db_rows else None
if is_history:
newest_ok = True
else:
newest_ok = newest_db is not None and int(newest_db) >= int(last_closed) - period_display
need_fetch = force_refresh or (
not is_history and (len(db_rows) < need or not newest_ok)
)
if is_history and len(db_rows) < need:
need_fetch = True
tail_only = False
if tail_refresh and not is_history and db_rows and not force_refresh and not need_fetch:
need_fetch = True
tail_only = True
fetched = 0
price_tick: Optional[float] = None
remote_err: Optional[str] = None
if need_fetch:
if is_history:
bms = int(before_ms)
anchor = bms - period_display
since = max(cutoff, anchor - period_storage * need)
fetch_limit = min(need + 20, 1500)
elif tail_only:
anchor_ms = int(newest_db) if newest_db is not None else now_ms
fetch_limit = _remote_fetch_limit(
need=need, force_refresh=False, storage_tf=storage_tf, tail_only=True
)
since = _since_ms_for_span(
now_ms=anchor_ms,
period_ms=period_storage,
span_bars=5,
cutoff_ms=cutoff,
)
else:
fetch_limit = _remote_fetch_limit(
need=need,
force_refresh=force_refresh,
storage_tf=storage_tf,
tail_only=False,
)
since = _since_ms_for_span(
now_ms=now_ms,
period_ms=period_storage,
span_bars=fetch_limit,
cutoff_ms=cutoff,
)
remote = remote_fetch(
symbol=sym,
timeframe=storage_tf,
since_ms=since,
limit=fetch_limit,
)
if remote.get("ok") and remote.get("bars"):
fetched = upsert_bars(ex_k, sym, storage_tf, remote["bars"], db_path)
price_tick = remote.get("price_tick")
if price_tick is not None:
save_symbol_price_tick(ex_k, sym, price_tick, db_path)
db_rows = load_display_rows()
if not is_history and db_rows:
db_rows = normalize_contiguous_db_rows(
db_rows,
period_ms=period_display,
exchange_key=ex_k,
symbol=sym,
timeframe=storage_tf,
db_path=db_path,
)
if not is_history and not tail_only and db_rows:
newest_ms = int(db_rows[-1]["open_time_ms"])
if newest_ms < int(last_closed) - period_display:
gap_limit = min(
500,
int((now_ms - newest_ms) // period_storage) + 10,
)
if gap_limit > 1:
gap_remote = remote_fetch(
symbol=sym,
timeframe=storage_tf,
since_ms=newest_ms,
limit=gap_limit,
)
if gap_remote.get("ok") and gap_remote.get("bars"):
fetched += upsert_bars(
ex_k, sym, storage_tf, gap_remote["bars"], db_path
)
db_rows = load_display_rows()
db_rows = normalize_contiguous_db_rows(
db_rows,
period_ms=period_display,
exchange_key=ex_k,
symbol=sym,
timeframe=storage_tf,
db_path=db_path,
)
else:
remote_err = remote.get("msg") or remote.get("error") or "实例拉取 K 线失败"
if not db_rows:
if is_history:
exhausted = True
else:
return {"ok": False, "msg": remote_err, "purged": purged}
exhausted = False
if is_history:
if not db_rows:
exhausted = True
elif len(db_rows) < need:
oldest = int(db_rows[0]["open_time_ms"])
if cutoff > 0 and oldest <= cutoff + period_storage:
exhausted = True
elif fetched == 0:
exhausted = True
if price_tick is None:
price_tick = load_symbol_price_tick(ex_k, sym, db_path)
if price_tick is None and not is_history:
try:
tick_probe = remote_fetch(
symbol=sym,
timeframe=storage_tf,
since_ms=None,
limit=3,
)
if tick_probe.get("ok"):
price_tick = tick_probe.get("price_tick")
if price_tick is not None:
save_symbol_price_tick(ex_k, sym, price_tick, db_path)
except Exception:
pass
if not is_history and db_rows:
db_rows = normalize_contiguous_db_rows(
db_rows,
period_ms=period_display,
exchange_key=ex_k,
symbol=sym,
timeframe=storage_tf,
db_path=db_path,
)
if not is_history and len(db_rows) < need:
missing = need - len(db_rows)
backfill_limit = min(missing + 60, HUB_KLINE_REMOTE_FETCH_CAP)
if db_rows:
oldest = int(db_rows[0]["open_time_ms"])
backfill_since = _since_ms_for_span(
now_ms=oldest,
period_ms=period_storage,
span_bars=backfill_limit,
cutoff_ms=cutoff,
)
else:
backfill_since = _since_ms_for_span(
now_ms=now_ms,
period_ms=period_storage,
span_bars=backfill_limit,
cutoff_ms=cutoff,
)
try:
remote_back = remote_fetch(
symbol=sym,
timeframe=storage_tf,
since_ms=backfill_since,
limit=backfill_limit,
)
if remote_back.get("ok") and remote_back.get("bars"):
fetched += upsert_bars(ex_k, sym, storage_tf, remote_back["bars"], db_path)
if remote_back.get("price_tick") is not None:
price_tick = remote_back.get("price_tick")
save_symbol_price_tick(ex_k, sym, price_tick, db_path)
db_rows = load_display_rows()
db_rows = normalize_contiguous_db_rows(
db_rows,
period_ms=period_display,
exchange_key=ex_k,
symbol=sym,
timeframe=storage_tf,
db_path=db_path,
)
elif not remote_err:
remote_err = (
remote_back.get("msg")
or remote_back.get("error")
or "实例补拉 K 线失败"
)
except Exception as e:
if not remote_err:
remote_err = str(e)
price_tick = normalize_price_tick(price_tick)
if db_rows and price_tick is not None:
round_ohlcv_bars_to_tick(db_rows, price_tick)
candles = _to_chart_candles(db_rows)
if not is_history and not candles and not exhausted:
return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged}
oldest_ms = int(db_rows[0]["open_time_ms"]) if db_rows else None
newest_ms = int(db_rows[-1]["open_time_ms"]) if db_rows else None
from_cache = max(0, len(candles) - min(fetched, len(candles))) if fetched else len(candles)
return {
"ok": True,
"symbol": sym,
"exchange_key": ex_k,
"timeframe": display_tf,
"storage_timeframe": storage_tf,
"limit": need,
"before_ms": int(before_ms) if is_history else None,
"oldest_ms": oldest_ms,
"newest_ms": newest_ms,
"exhausted": exhausted,
"source": "remote" if fetched else "db",
"retention_policy": retention_policy_meta(),
"candles": candles,
"from_cache": from_cache,
"fetched": fetched,
"cleared": cleared,
"purged": purged,
"price_tick": price_tick,
"stale": bool(remote_err),
"stale_message": remote_err if remote_err else None,
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
}
def format_ohlcv_detail(bar: dict[str, Any] | None, tick: Optional[float]) -> dict[str, str]:
if not bar:
return {"open": "-", "high": "-", "low": "-", "close": "-", "volume": "-"}
return {
"open": format_price_by_tick(bar.get("open"), tick),
"high": format_price_by_tick(bar.get("high"), tick),
"low": format_price_by_tick(bar.get("low"), tick),
"close": format_price_by_tick(bar.get("close"), tick),
"volume": format_price_by_tick(bar.get("volume"), tick),
}
+311
View File
@@ -0,0 +1,311 @@
"""中控宏观关键数据日历:手动录入 FOMC / CPI / 非农档发布时间,±1h 风控前置窗口。"""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from lib.hub.hub_symbol_archive_lib import parse_wall_clock_ms
DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
MACRO_EVENT_TYPES = ("fomc", "cpi", "employment")
MACRO_EVENT_LABELS: dict[str, str] = {
"fomc": "FOMC 联邦基金利率",
"cpi": "美国 CPI 通胀",
"employment": "就业与劳工数据",
}
WINDOW_BEFORE_MS = int(os.getenv("HUB_MACRO_WINDOW_BEFORE_SEC", str(3600))) * 1000
WINDOW_AFTER_MS = int(os.getenv("HUB_MACRO_WINDOW_AFTER_SEC", str(3600))) * 1000
IMMINENT_BEFORE_MS = int(os.getenv("HUB_MACRO_IMMINENT_BEFORE_SEC", str(1800))) * 1000
LIST_FUTURE_DAYS = int(os.getenv("HUB_MACRO_LIST_FUTURE_DAYS", "60"))
def default_db_path() -> Path:
raw = (os.getenv("HUB_MACRO_CALENDAR_DB_PATH") or "").strip()
if raw:
return Path(raw)
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
hub_dir.mkdir(parents=True, exist_ok=True)
return hub_dir / "hub_macro_calendar.db"
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
path = db_path or default_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def init_db(db_path: Path | None = None) -> None:
conn = _connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS macro_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
event_at_ms INTEGER NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_macro_events_at ON macro_events(event_at_ms)"
)
finally:
conn.close()
def normalize_event_type(raw: str) -> str:
key = (raw or "").strip().lower()
if key not in MACRO_EVENT_TYPES:
raise ValueError(f"事件类型须为: {', '.join(MACRO_EVENT_LABELS.values())}")
return key
def parse_event_at_ms(raw: Any) -> int:
ms = parse_wall_clock_ms(raw, tz=DISPLAY_TZ)
if ms is None:
raise ValueError("发布时间格式错误,请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DDTHH:MM")
return int(ms)
def format_event_at(ms: int) -> str:
dt = datetime.fromtimestamp(ms / 1000, tz=DISPLAY_TZ)
return dt.strftime("%Y-%m-%d %H:%M")
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
ms = int(row["event_at_ms"])
et = str(row["event_type"])
return {
"id": int(row["id"]),
"event_type": et,
"event_type_label": MACRO_EVENT_LABELS.get(et, et),
"event_at_ms": ms,
"event_at": format_event_at(ms),
"note": str(row["note"] or ""),
"created_at_ms": int(row["created_at_ms"]),
"updated_at_ms": int(row["updated_at_ms"]),
}
def _window_bounds(event_at_ms: int) -> tuple[int, int]:
start = int(event_at_ms) - WINDOW_BEFORE_MS
end = int(event_at_ms) + WINDOW_AFTER_MS
return start, end
def enrich_alert(row: dict[str, Any], now_ms: int | None = None) -> dict[str, Any] | None:
now = int(now_ms if now_ms is not None else time.time() * 1000)
event_at_ms = int(row["event_at_ms"])
window_start, window_end = _window_bounds(event_at_ms)
if now < window_start or now > window_end:
return None
imminent = now >= (event_at_ms - IMMINENT_BEFORE_MS) and now <= window_end
mins_to_event = max(0, int((event_at_ms - now) / 60000))
mins_from_event = max(0, int((now - event_at_ms) / 60000))
return {
**row,
"window_start_ms": window_start,
"window_end_ms": window_end,
"window_start": format_event_at(window_start),
"window_end": format_event_at(window_end),
"phase": "imminent" if imminent else "window",
"phase_label": "即将发布" if imminent and now < event_at_ms else "高波动窗口",
"minutes_to_event": mins_to_event if now < event_at_ms else 0,
"minutes_from_event": mins_from_event if now >= event_at_ms else 0,
}
def list_events(
*,
now_ms: int | None = None,
include_expired_hours: int = 24,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
init_db(db_path)
now = int(now_ms if now_ms is not None else time.time() * 1000)
horizon = now + LIST_FUTURE_DAYS * 86400 * 1000
expired_cutoff = now - max(0, int(include_expired_hours)) * 3600 * 1000 - WINDOW_AFTER_MS
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM macro_events
WHERE event_at_ms >= ? AND event_at_ms <= ?
ORDER BY event_at_ms ASC, id ASC
""",
(expired_cutoff, horizon),
).fetchall()
return [_row_to_dict(r) for r in rows]
finally:
conn.close()
def get_event(event_id: int, db_path: Path | None = None) -> dict[str, Any] | None:
init_db(db_path)
conn = _connect(db_path)
try:
row = conn.execute("SELECT * FROM macro_events WHERE id=?", (int(event_id),)).fetchone()
return _row_to_dict(row) if row else None
finally:
conn.close()
def _assert_no_duplicate(
conn: sqlite3.Connection,
event_type: str,
event_at_ms: int,
*,
exclude_id: int | None = None,
) -> None:
if exclude_id is None:
row = conn.execute(
"SELECT id FROM macro_events WHERE event_type=? AND event_at_ms=? LIMIT 1",
(event_type, int(event_at_ms)),
).fetchone()
else:
row = conn.execute(
"""
SELECT id FROM macro_events
WHERE event_type=? AND event_at_ms=? AND id<>?
LIMIT 1
""",
(event_type, int(event_at_ms), int(exclude_id)),
).fetchone()
if row:
raise ValueError("同类型、同发布时间的记录已存在")
def create_event(
event_type: str,
event_at: Any,
*,
note: str = "",
db_path: Path | None = None,
) -> dict[str, Any]:
init_db(db_path)
et = normalize_event_type(event_type)
event_at_ms = parse_event_at_ms(event_at)
note_s = str(note or "").strip()[:500]
now_ms = int(time.time() * 1000)
conn = _connect(db_path)
try:
_assert_no_duplicate(conn, et, event_at_ms)
cur = conn.execute(
"""
INSERT INTO macro_events (event_type, event_at_ms, note, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
""",
(et, event_at_ms, note_s, now_ms, now_ms),
)
eid = int(cur.lastrowid)
finally:
conn.close()
row = get_event(eid, db_path=db_path)
assert row is not None
return row
def update_event(
event_id: int,
*,
event_type: str | None = None,
event_at: Any | None = None,
note: str | None = None,
db_path: Path | None = None,
) -> dict[str, Any] | None:
init_db(db_path)
existing = get_event(event_id, db_path=db_path)
if not existing:
return None
et = normalize_event_type(event_type if event_type is not None else existing["event_type"])
event_at_ms = (
parse_event_at_ms(event_at) if event_at is not None else int(existing["event_at_ms"])
)
note_s = existing["note"] if note is None else str(note or "").strip()[:500]
now_ms = int(time.time() * 1000)
conn = _connect(db_path)
try:
_assert_no_duplicate(conn, et, event_at_ms, exclude_id=int(event_id))
conn.execute(
"""
UPDATE macro_events
SET event_type=?, event_at_ms=?, note=?, updated_at_ms=?
WHERE id=?
""",
(et, event_at_ms, note_s, now_ms, int(event_id)),
)
finally:
conn.close()
return get_event(event_id, db_path=db_path)
def delete_event(event_id: int, db_path: Path | None = None) -> bool:
init_db(db_path)
conn = _connect(db_path)
try:
cur = conn.execute("DELETE FROM macro_events WHERE id=?", (int(event_id),))
return cur.rowcount > 0
finally:
conn.close()
def list_active_alerts(
now_ms: int | None = None,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
now = int(now_ms if now_ms is not None else time.time() * 1000)
lookback = now - WINDOW_BEFORE_MS - IMMINENT_BEFORE_MS
lookahead = now + WINDOW_AFTER_MS
init_db(db_path)
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM macro_events
WHERE event_at_ms >= ? AND event_at_ms <= ?
ORDER BY event_at_ms ASC, id ASC
""",
(lookback, lookahead),
).fetchall()
finally:
conn.close()
alerts: list[dict[str, Any]] = []
for row in rows:
item = enrich_alert(_row_to_dict(row), now_ms=now)
if item:
alerts.append(item)
return alerts
def build_banner_message(alert: dict[str, Any], *, has_positions: bool) -> str:
label = alert.get("event_type_label") or alert.get("event_type") or "宏观数据"
phase = alert.get("phase") or "window"
if has_positions:
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
return (
f"{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
"注意仓位风险:勿加仓,检查止损/减仓"
)
return f"{label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓"
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
return (
f"{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
"建议等待,避免新开仓"
)
return f"{label}」高波动窗口(±1h),建议等待,避免新开仓"
+81
View File
@@ -0,0 +1,81 @@
"""实例 USDT 永续合约信息(与实盘 ccxt 精度一致)。"""
from __future__ import annotations
from typing import Any, Callable, Optional, Tuple
from lib.hub.hub_calculator_market_lib import (
amount_decimals_from_exchange,
normalize_base_symbol,
price_decimals_from_exchange,
resolve_usdt_perp_symbol,
)
from lib.hub.hub_ohlcv_lib import normalize_price_tick, price_tick_from_market
def fetch_usdt_swap_market_info(
*,
base_or_symbol: str,
normalize_symbol_input: Callable[[str], str],
normalize_exchange_symbol: Callable[[str], str],
ensure_markets_loaded: Callable[[], None],
exchange: Any,
exchange_id: str = "",
) -> dict[str, Any]:
"""供各实例 /api/hub/market 调用。"""
raw = str(base_or_symbol or "").strip()
if not raw:
return {"ok": False, "msg": "请输入币种,如 ETH"}
try:
ensure_markets_loaded()
except Exception as exc:
return {"ok": False, "msg": f"加载市场失败: {exc}"}
base_u = normalize_base_symbol(raw)
hub_sym = normalize_symbol_input(raw if base_u else raw)
try:
ex_sym = normalize_exchange_symbol(hub_sym)
except Exception:
ex_sym = hub_sym
sym, err = resolve_usdt_perp_symbol(exchange, base_u or hub_sym)
if err and ex_sym:
markets = getattr(exchange, "markets", None) or {}
if ex_sym in markets:
sym = ex_sym
err = None
if err or not sym:
return {"ok": False, "msg": err or f"未找到 {base_u or raw}/USDT 永续合约"}
market = exchange.market(sym)
try:
contract_size = float(market.get("contractSize") or 1.0)
except (TypeError, ValueError):
contract_size = 1.0
if contract_size <= 0:
contract_size = 1.0
price_tick = normalize_price_tick(price_tick_from_market(exchange, sym))
amt_dec = amount_decimals_from_exchange(exchange, sym)
px_dec = price_decimals_from_exchange(exchange, sym, price_tick)
min_amount = None
try:
min_amount = float((market.get("limits") or {}).get("amount", {}).get("min"))
except (TypeError, ValueError):
min_amount = None
base_out = (market.get("base") or base_u or "").upper() or base_u
return {
"ok": True,
"exchange": (exchange_id or "").strip().lower(),
"base": base_out,
"exchange_symbol": sym,
"display_symbol": f"{base_out}/USDT" if base_out else sym,
"contract_size": contract_size,
"price_tick": price_tick,
"price_decimals": px_dec,
"amount_decimals": amt_dec,
"min_amount": min_amount,
}
+692
View File
@@ -0,0 +1,692 @@
"""中控行情区:各实例 ccxt OHLCV 拉取(hub_bridge /api/hub/ohlcv 共用)。"""
from __future__ import annotations
import math
import os
import time
from typing import Any, Callable, Optional
CHART_TIMEFRAMES = frozenset(
{
"1m",
"5m",
"15m",
"1h",
"2h",
"4h",
"1d",
"1w",
}
)
CHART_TIMEFRAME_ORDER = (
"1m",
"5m",
"15m",
"1h",
"2h",
"4h",
"1d",
"1w",
)
DAILY_PLUS_TIMEFRAMES = frozenset({"1d", "1w"})
# 入库 / 同步真源(各周期直拉交易所,不做本地聚合)
STORED_TIMEFRAMES = frozenset(CHART_TIMEFRAMES)
PERMANENT_STORED_TIMEFRAMES = frozenset({"1d", "1w"})
YEAR_ROLLING_STORED = frozenset({"5m", "15m", "1h", "2h", "4h"})
# 行情区不做展示周期聚合;保留空映射供兼容读取
CHART_DISPLAY_AGGREGATE_FROM: dict[str, str] = {}
SMALL_DISPLAY_TFS = frozenset({"1m", "5m", "15m"})
MID_DISPLAY_TFS = frozenset({"1h", "2h", "4h"})
HUB_KLINE_1M_MAX_BARS = max(1000, int(os.getenv("HUB_KLINE_1M_MAX_BARS", "10000")))
HUB_KLINE_5M_1H_RETENTION_DAYS = max(30, int(os.getenv("HUB_KLINE_5M_1H_RETENTION_DAYS", "365")))
HUB_KLINE_SEED_BARS = max(100, int(os.getenv("HUB_KLINE_SEED_BARS", "500")))
# 交易所无原生周期时的远程拉取 fallback(行情区当前无映射)
OHLCV_AGGREGATE_FROM: dict[str, str] = {}
TIMEFRAME_MS: dict[str, int] = {
"1m": 60_000,
"5m": 5 * 60_000,
"15m": 15 * 60_000,
"1h": 60 * 60_000,
"2h": 2 * 60 * 60_000,
"4h": 4 * 60 * 60_000,
"12h": 12 * 60 * 60_000,
"1d": 24 * 60 * 60_000,
"1w": 7 * 24 * 60 * 60_000,
}
def normalize_chart_timeframe(raw: str | None, default: str = "5m") -> str:
tf = (raw or default).strip().lower()
return tf if tf in CHART_TIMEFRAMES else default
def normalize_perpetual_symbol(symbol: str) -> str:
"""BTC/USDT → BTC/USDT:USDT(与四所 ccxt swap 行情一致)。"""
sym = (symbol or "").strip().upper()
if not sym:
return ""
if ":" in sym:
return sym
if "/" in sym:
base, quote = sym.split("/", 1)
quote_clean = quote.split(":")[0]
return f"{base}/{quote_clean}:{quote_clean}"
return sym
def sync_timeframe_for_display(timeframe: str) -> str:
"""展示周期对应的入库 / 同步周期。"""
tf = normalize_chart_timeframe(timeframe)
return CHART_DISPLAY_AGGREGATE_FROM.get(tf, tf)
def aggregation_source_for_display(timeframe: str) -> str | None:
tf = normalize_chart_timeframe(timeframe)
return CHART_DISPLAY_AGGREGATE_FROM.get(tf)
def aggregate_ratio(display_tf: str, source_tf: str) -> int:
d = normalize_chart_timeframe(display_tf)
s = normalize_chart_timeframe(source_tf)
return max(1, int(TIMEFRAME_MS[d] // TIMEFRAME_MS[s]))
def chart_initial_limit(timeframe: str) -> int:
tf = normalize_chart_timeframe(timeframe)
if tf in SMALL_DISPLAY_TFS:
return 2000
if tf in MID_DISPLAY_TFS:
return 1000
if tf in DAILY_PLUS_TIMEFRAMES:
return 500
return 500
def chart_chunk_limit(timeframe: str) -> int:
tf = normalize_chart_timeframe(timeframe)
if tf in SMALL_DISPLAY_TFS:
return 500
if tf == "1w":
return 150
if tf in MID_DISPLAY_TFS:
return 300
return 200
def chart_memory_cap(timeframe: str) -> int:
tf = normalize_chart_timeframe(timeframe)
if tf in SMALL_DISPLAY_TFS:
return 5000
if tf == "1w":
return 500
return 1000
def bar_limit_for_timeframe(timeframe: str) -> int:
return chart_memory_cap(timeframe)
def storage_retention_days(storage_tf: str) -> int | None:
"""None 表示不按天截断(1m 按根数;1d/1w 永久)。"""
tf = normalize_chart_timeframe(storage_tf)
if tf in YEAR_ROLLING_STORED:
return HUB_KLINE_5M_1H_RETENTION_DAYS
return None
def history_cutoff_ms_for_storage(storage_tf: str, now_ms: int | None = None) -> int:
days = storage_retention_days(storage_tf)
if days is None:
return 0
now = int(now_ms if now_ms is not None else time.time() * 1000)
return max(0, now - int(days) * 86400000)
def seed_bar_target(storage_tf: str) -> int:
tf = normalize_chart_timeframe(storage_tf)
if tf == "1m":
return HUB_KLINE_1M_MAX_BARS
if tf in YEAR_ROLLING_STORED:
period = TIMEFRAME_MS[tf]
return min(
int(86400000 * HUB_KLINE_5M_1H_RETENTION_DAYS / period) + 20,
150000,
)
return HUB_KLINE_SEED_BARS
def retention_policy_meta() -> dict[str, Any]:
year = {"mode": "days", "days": HUB_KLINE_5M_1H_RETENTION_DAYS}
return {
"1m": {"mode": "bars", "max_bars": HUB_KLINE_1M_MAX_BARS},
"5m": dict(year),
"15m": dict(year),
"1h": dict(year),
"2h": dict(year),
"4h": dict(year),
"1d": {"mode": "permanent"},
"1w": {"mode": "permanent"},
"aggregate_from": {},
}
def last_closed_bar_open_ms(timeframe: str, now_ms: int | None = None) -> int:
"""上一根已收盘 K 的 open_time(毫秒 UTC)。"""
tf = normalize_chart_timeframe(timeframe)
period = TIMEFRAME_MS[tf]
now = int(now_ms if now_ms is not None else time.time() * 1000)
current_open = (now // period) * period
return int(current_open - period)
def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int | None = None) -> int:
"""本地库清理/读库窗口:不超过 retention_days。"""
now = int(now_ms if now_ms is not None else time.time() * 1000)
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
retention_cutoff = now - max(1, int(retention_days)) * 86400000
want = now - max(1, int(need)) * period
return max(retention_cutoff, want)
def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) -> int:
"""行情展示拉取起点:按 need 根回看(日线 500 / 日内 1000),不受 DB 保留天数限制。"""
now = int(now_ms if now_ms is not None else time.time() * 1000)
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
return max(0, now - max(1, int(need)) * period)
def _positive_float(value: Any) -> Optional[float]:
if value in (None, ""):
return None
try:
v = float(value)
except (TypeError, ValueError):
return None
return v if v > 0 else None
def _price_tick_from_market_info(info: dict) -> Optional[float]:
"""从 market.info 解析 tick(含币安 PRICE_FILTER.filters)。"""
for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"):
v = _positive_float(info.get(key))
if v is not None:
return v
for key in ("pricePrecision", "price_precision"):
raw = info.get(key)
if raw in (None, ""):
continue
try:
p = float(raw)
except (TypeError, ValueError):
continue
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
return 10 ** (-int(p))
if 0 < p < 1:
return p
filters = info.get("filters")
if isinstance(filters, list):
for f in filters:
if not isinstance(f, dict):
continue
if str(f.get("filterType") or "").upper() != "PRICE_FILTER":
continue
v = _positive_float(f.get("tickSize"))
if v is not None:
return v
return None
def round_price_to_tick(value: Any, tick: Optional[float]) -> Optional[float]:
"""按交易所 tick 对齐价格(K 线/标记线与坐标轴一致)。"""
t = normalize_price_tick(tick)
if t is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return None
n = round(v / t) * t
d = _decimals_from_tick(t)
return float(f"{n:.{d}f}")
def round_ohlcv_bars_to_tick(bars: list[dict[str, Any]], tick: Optional[float]) -> None:
t = normalize_price_tick(tick)
if t is None:
return
for b in bars:
for key in ("open", "high", "low", "close"):
if key in b:
rounded = round_price_to_tick(b.get(key), t)
if rounded is not None:
b[key] = rounded
def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
"""最小价格变动单位(与交易所 tick / price_to_precision 一致)。"""
try:
if not getattr(exchange, "markets", None):
exchange.load_markets()
market = exchange.market(exchange_symbol)
except Exception:
return None
info = market.get("info") or {}
if isinstance(info, dict):
tick = _price_tick_from_market_info(info)
if tick is not None:
return tick
limits = market.get("limits") or {}
price_limits = limits.get("price") or {}
if price_limits.get("min") not in (None, ""):
try:
v = float(price_limits["min"])
if v > 0:
return v
except (TypeError, ValueError):
pass
try:
sample = exchange.price_to_precision(exchange_symbol, 12345.678901234)
s = str(sample).strip()
if "." in s:
frac = s.split(".", 1)[1]
if frac:
return 10 ** (-len(frac))
return 1.0
except Exception:
pass
prec = (market.get("precision") or {}).get("price")
if prec is not None:
try:
p = float(prec)
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
return 10 ** (-int(p))
if 0 < p < 1:
return p
except (TypeError, ValueError):
pass
return None
def normalize_price_tick(tick: Optional[float]) -> Optional[float]:
"""将 tick 对齐为 10^-n,避免浮点噪声导致前端 lightweight-charts unexpected base。"""
if tick is None:
return None
try:
t = float(tick)
except (TypeError, ValueError):
return None
if t <= 0:
return None
if t >= 1:
return t
try:
exp = int(round(-math.log10(t)))
except (ValueError, OverflowError):
return None
exp = max(0, min(12, exp))
return 10 ** (-exp)
def _decimals_from_tick(tick: float) -> int:
if tick >= 1:
return 0
s = f"{tick:.12f}".rstrip("0")
if "." in s:
frac = s.split(".", 1)[1]
if frac:
return min(12, len(frac))
return max(0, min(12, int(round(-math.log10(tick)))))
def format_price_by_tick(value: Any, tick: Optional[float]) -> str:
if value in (None, ""):
return "-"
try:
v = float(value)
except (TypeError, ValueError):
return str(value)
if v == 0:
return "0"
if tick and tick > 0:
return f"{v:.{_decimals_from_tick(float(tick))}f}"
av = abs(v)
if av >= 10000:
d = 2
elif av >= 100:
d = 3
elif av >= 1:
d = 4
elif av >= 0.01:
d = 6
else:
d = 8
text = f"{v:.{d}f}"
return text.rstrip("0").rstrip(".") if "." in text else text
def exchange_supports_timeframe(exchange, timeframe: str) -> bool:
tf = normalize_chart_timeframe(timeframe)
tfs = getattr(exchange, "timeframes", None) or {}
if not tfs:
return True
return tf in tfs
def _median_bar_step_ms(bars: list[dict[str, Any]]) -> Optional[int]:
if len(bars) < 2:
return None
steps: list[int] = []
for i in range(1, min(len(bars), 64)):
step = int(bars[i]["open_time_ms"]) - int(bars[i - 1]["open_time_ms"])
if step > 0:
steps.append(step)
if not steps:
return None
steps.sort()
return steps[len(steps) // 2]
def bars_spacing_matches_timeframe(
bars: list[dict[str, Any]], timeframe: str, *, tolerance: float = 0.08
) -> bool:
if len(bars) < 2:
return True
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
step = _median_bar_step_ms(bars)
if step is None:
return False
return abs(step - period) <= period * tolerance
def align_bar_open_ms(open_time_ms: int, period_ms: int) -> int:
return (int(open_time_ms) // period_ms) * period_ms
def snap_to_bar_grid(ts_ms: int, origin_ms: int, step_ms: int) -> int:
step = max(1, int(step_ms))
origin = int(origin_ms)
if ts_ms <= origin:
return origin
idx = (int(ts_ms) - origin + step - 1) // step
return origin + idx * step
def fill_missing_ohlcv_bars(
bars: list[dict[str, Any]],
period_ms: int,
start_ms: int | None = None,
end_ms: int | None = None,
) -> list[dict[str, Any]]:
"""细周期缺口用上一根收盘价填平,保证聚合后 K 线时间轴连续。"""
by_ts: dict[int, dict[str, Any]] = {}
for b in bars or []:
try:
by_ts[int(b["open_time_ms"])] = b
except (KeyError, TypeError, ValueError):
continue
if not by_ts:
return []
keys = sorted(by_ts.keys())
step_ms = max(1, int(period_ms))
origin = keys[0]
aligned_start = snap_to_bar_grid(
int(start_ms if start_ms is not None else keys[0]), origin, step_ms
)
aligned_end = max(
int(end_ms if end_ms is not None else keys[-1]),
keys[-1],
)
out: list[dict[str, Any]] = []
last: dict[str, Any] | None = None
for ts_key in keys:
if ts_key <= aligned_start:
last = by_ts[ts_key]
ts = aligned_start
while ts <= aligned_end:
cur = by_ts.get(ts)
if cur is not None:
last = cur
out.append(cur)
elif last is not None:
c = float(last["close"])
out.append(
{
"open_time_ms": ts,
"open": c,
"high": c,
"low": c,
"close": c,
"volume": 0.0,
"filled": True,
}
)
ts += step_ms
return out
def aggregate_ohlcv_bars(
bars: list[dict[str, Any]], target_timeframe: str
) -> list[dict[str, Any]]:
"""将细周期 OHLCV 聚合为目标周期(UTC 对齐 bucket)。"""
tf = normalize_chart_timeframe(target_timeframe)
period = TIMEFRAME_MS[tf]
buckets: dict[int, dict[str, Any]] = {}
for b in bars or []:
try:
key = align_bar_open_ms(int(b["open_time_ms"]), period)
o = float(b["open"])
h = float(b["high"])
l = float(b["low"])
c = float(b["close"])
v = float(b.get("volume") or 0)
except (KeyError, TypeError, ValueError):
continue
cur = buckets.get(key)
if cur is None:
buckets[key] = {
"open_time_ms": key,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v,
}
continue
cur["high"] = max(float(cur["high"]), h)
cur["low"] = min(float(cur["low"]), l)
cur["close"] = c
cur["volume"] = float(cur.get("volume") or 0) + v
return [buckets[k] for k in sorted(buckets.keys())]
def _next_since_from_batch(batch: list, period_ms: int) -> int:
last_ts = int(batch[-1][0])
if len(batch) >= 2:
step = int(batch[-1][0]) - int(batch[-2][0])
if step > 0:
return last_ts + step
return last_ts + period_ms
def _paginate_fetch_ohlcv(
exchange,
ex_sym: str,
timeframe: str,
*,
want: int,
since_ms: int | None,
period_ms: int,
chunk_max: int = 300,
) -> list[dict[str, Any]]:
tf = normalize_chart_timeframe(timeframe)
collected: list = []
if since_ms is not None and int(since_ms) > 0:
since = int(since_ms)
else:
since = max(0, int(time.time() * 1000) - want * period_ms)
now_ms = int(time.time() * 1000)
guard = 0
prev_since = None
while len(collected) < want and guard < 80:
guard += 1
if since >= now_ms:
break
req_limit = min(chunk_max, want - len(collected))
try:
batch = exchange.fetch_ohlcv(
ex_sym, timeframe=tf, since=since, limit=req_limit
)
except Exception as e:
err = str(e).lower()
if collected and (
"from" in err
and "to" in err
or "invalid request parameter" in err
):
break
raise
if not batch:
break
collected.extend(batch)
next_since = _next_since_from_batch(batch, period_ms)
if next_since >= now_ms:
break
if prev_since is not None and next_since <= prev_since:
break
prev_since = since
since = next_since
bars = _bars_to_dicts(collected)
uniq: dict[int, dict[str, Any]] = {}
for b in bars:
uniq[int(b["open_time_ms"])] = b
merged = [uniq[k] for k in sorted(uniq.keys())]
if len(merged) > want:
merged = merged[-want:]
return merged
def _bars_to_dicts(ohlcv: list) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for bar in ohlcv or []:
if not bar or len(bar) < 6:
continue
try:
out.append(
{
"open_time_ms": int(bar[0]),
"open": float(bar[1]),
"high": float(bar[2]),
"low": float(bar[3]),
"close": float(bar[4]),
"volume": float(bar[5]),
}
)
except (TypeError, ValueError):
continue
return out
def fetch_ohlcv_for_hub(
*,
symbol: str,
timeframe: str,
since_ms: int | None = None,
limit: int = 500,
normalize_symbol_input: Callable[[Any], str],
normalize_exchange_symbol: Callable[[str], str],
ensure_markets_loaded: Callable[[], None],
exchange,
friendly_error: Callable[[Exception], str] | None = None,
) -> dict[str, Any]:
"""从 ccxt 拉 OHLCV,供 hub_bridge /api/hub/ohlcv 返回。"""
tf = normalize_chart_timeframe(timeframe)
sym = normalize_symbol_input(symbol)
if not sym:
return {"ok": False, "msg": "symbol 不能为空"}
try:
ensure_markets_loaded()
ex_sym = normalize_exchange_symbol(sym)
want = max(1, min(int(limit or bar_limit_for_timeframe(tf)), 1500))
period = TIMEFRAME_MS[tf]
merged: list[dict[str, Any]] = []
src_tf = OHLCV_AGGREGATE_FROM.get(tf)
if exchange_supports_timeframe(exchange, tf):
candidate = _paginate_fetch_ohlcv(
exchange,
ex_sym,
tf,
want=want,
since_ms=since_ms,
period_ms=period,
)
if candidate and bars_spacing_matches_timeframe(candidate, tf):
merged = candidate
if (
not merged
and src_tf
and exchange_supports_timeframe(exchange, src_tf)
):
src_period = TIMEFRAME_MS[normalize_chart_timeframe(src_tf)]
ratio = max(1, int(math.ceil(period / src_period)))
src_want = min(1500, want * ratio + ratio * 4)
src_bars = _paginate_fetch_ohlcv(
exchange,
ex_sym,
src_tf,
want=src_want,
since_ms=since_ms,
period_ms=src_period,
)
if not src_bars or not bars_spacing_matches_timeframe(src_bars, src_tf):
return {
"ok": False,
"msg": f"无法获取 {tf} K 线(细周期 {src_tf} 数据异常)",
}
merged = aggregate_ohlcv_bars(src_bars, tf)
if len(merged) > want:
merged = merged[-want:]
if not merged:
try:
tail = exchange.fetch_ohlcv(
ex_sym, timeframe=tf, limit=min(want, 300)
)
merged = _bars_to_dicts(tail or [])
if len(merged) > want:
merged = merged[-want:]
except Exception:
pass
if not merged:
return {"ok": False, "msg": "交易所未返回 K 线"}
tick = normalize_price_tick(price_tick_from_market(exchange, ex_sym))
round_ohlcv_bars_to_tick(merged, tick)
return {
"ok": True,
"symbol": sym,
"exchange_symbol": ex_sym,
"timeframe": tf,
"price_tick": tick,
"bars": merged,
}
except Exception as e:
msg = friendly_error(e) if friendly_error else str(e)
return {"ok": False, "msg": f"K线加载失败:{msg}"}
+249
View File
@@ -0,0 +1,249 @@
"""ccxt 持仓标记价解析(实例 price_snapshot 与中控子代理共用)。"""
from __future__ import annotations
import math
from typing import Any, Callable
def _finite_or_none(x: Any) -> float | None:
try:
f = float(x)
return f if math.isfinite(f) else None
except (TypeError, ValueError):
return None
def _coerce_float(*values: Any) -> float | None:
for v in values:
if v is None or v == "":
continue
px = _finite_or_none(v)
if px is not None and px > 0:
return px
return None
def position_contracts(p: dict[str, Any]) -> float:
raw = p.get("contracts")
if raw is not None:
try:
return float(raw)
except (TypeError, ValueError):
pass
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
for k in ("positionAmt", "positionamt", "pos", "size"):
if k in info:
try:
v = float(info[k])
if v != 0:
return v
except (TypeError, ValueError):
pass
return 0.0
def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) -> str:
s = (p.get("side") or "").lower()
if s in ("long", "short"):
return s
c = contracts if contracts is not None else position_contracts(p)
if c > 0:
return "long"
if c < 0:
return "short"
return "long"
def parse_position_entry_price(p: dict[str, Any]) -> float | None:
"""四所 ccxt 持仓开仓均价。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
return _coerce_float(
p.get("entryPrice"),
p.get("entry_price"),
p.get("average"),
info.get("entryPrice"),
info.get("entry_price"),
info.get("avgPx"),
info.get("avgEntryPrice"),
info.get("avg_entry_price"),
info.get("avgPrice"),
info.get("openAvgPx"),
)
def estimate_linear_swap_upnl_usdt(
side: str,
entry: float | None,
mark: float | None,
contracts: float | None,
contract_size: float | None = None,
) -> float | None:
"""U 本位线性永续:浮盈 = (标记价 - 开仓价) × 张数 × contractSize(空头取反)。"""
e = _finite_or_none(entry)
m = _finite_or_none(mark)
c = _finite_or_none(contracts)
if e is None or m is None or c is None or c <= 0:
return None
mult = _finite_or_none(contract_size)
if mult is None or mult <= 0:
mult = 1.0
diff = (m - e) if (side or "long").strip().lower() == "long" else (e - m)
return round(diff * abs(c) * mult, 2)
def resolve_position_display_upnl(
side: str,
entry: float | None,
mark: float | None,
contracts: float | None,
contract_size: float | None,
exchange_upnl: float | None,
) -> float | None:
"""展示用浮盈:优先与标记价/张数一致的推算;与交易所值偏差过大时用推算值。"""
computed = estimate_linear_swap_upnl_usdt(
side, entry, mark, contracts, contract_size
)
if computed is None:
return exchange_upnl
if exchange_upnl is None:
return computed
ref = max(abs(computed), 1.0)
if abs(exchange_upnl - computed) / ref > 0.2:
return computed
return exchange_upnl
def _coerce_signed(*values: Any) -> float | None:
"""解析可正可负的数值(未实现盈亏等)。"""
for v in values:
if v is None or v == "":
continue
f = _finite_or_none(v)
if f is not None:
return f
return None
def parse_position_unrealized_pnl(p: dict[str, Any]) -> float | None:
"""四所 ccxt 持仓统一解析未实现盈亏(Gate/OKX/Binance 字段名不一致)。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
return _coerce_signed(
p.get("unrealizedPnl"),
p.get("unrealisedPnl"),
p.get("unrealized_pnl"),
p.get("unrealised_pnl"),
info.get("unrealised_pnl"),
info.get("unrealized_pnl"),
info.get("unrealisedPnl"),
info.get("unrealizedPnl"),
info.get("upl"),
info.get("uplLast"),
)
def enrich_ccxt_position_metrics_out(
position: dict[str, Any],
out: dict[str, Any],
*,
contract_size: float = 1.0,
funds_decimals: int = 2,
) -> dict[str, Any]:
"""
四所 parse_ccxt_position_metrics 产出后统一:
- 标记价用 hub 兜底
- 未实现盈亏 = resolve(交易所值, entry/mark/张数/contractSize 推算)
"""
if not isinstance(position, dict) or not isinstance(out, dict):
return out
mark = _finite_or_none(out.get("mark_price"))
if mark is None or mark <= 0:
mp = parse_position_mark_price(position)
if mp is not None and mp > 0:
out["mark_price"] = round(mp, 8)
mark = mp
exchange_upnl = parse_position_unrealized_pnl(position)
if exchange_upnl is None:
exchange_upnl = _coerce_signed(out.get("unrealized_pnl"))
c = position_contracts(position)
if abs(c) < 1e-12:
return out
side = position_side_from_ccxt(position, c)
entry = parse_position_entry_price(position)
if entry is not None and entry > 0:
out["entry_price"] = round(entry, 8)
cs = contract_size if contract_size and contract_size > 0 else 1.0
upnl = resolve_position_display_upnl(
side, entry, mark, abs(c), cs, exchange_upnl
)
if upnl is not None:
out["unrealized_pnl"] = round(upnl, funds_decimals)
return out
def parse_position_mark_price(p: dict[str, Any]) -> float | None:
"""四所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致)。"""
if not isinstance(p, dict):
return None
info = p.get("info") or {}
if not isinstance(info, dict):
info = {}
mark = _coerce_float(
p.get("markPrice"),
p.get("mark_price"),
p.get("mark"),
info.get("markPx"),
info.get("mark_price"),
info.get("markPrice"),
)
if mark is not None:
return mark
contracts = position_contracts(p)
if abs(contracts) >= 1e-12:
notional = _finite_or_none(p.get("notional"))
if notional is not None and abs(notional) > 0:
return abs(notional) / abs(contracts)
return None
def build_position_marks_list(
positions: list,
*,
format_mark_display: Callable[[str, float], str] | None = None,
) -> list[dict[str, Any]]:
"""从 fetch_positions 结果生成 position_marks,供 price_snapshot / 中控合并。"""
out: list[dict[str, Any]] = []
for p in positions or []:
if not isinstance(p, dict):
continue
c = position_contracts(p)
if abs(c) < 1e-12:
continue
mark = parse_position_mark_price(p)
if mark is None or mark <= 0:
continue
sym = (p.get("symbol") or "").strip()
side = position_side_from_ccxt(p, c)
row: dict[str, Any] = {
"symbol": sym,
"side": side,
"mark_price": mark,
}
if format_mark_display and sym:
try:
row["mark_price_display"] = format_mark_display(sym, mark)
except Exception:
row["mark_price_display"] = f"{mark:g}"
else:
row["mark_price_display"] = f"{mark:g}"
out.append(row)
return out
+166
View File
@@ -0,0 +1,166 @@
"""
实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN)。无 Flask 依赖,供中控 FastAPI 与各实例共用。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import threading
import time
HUB_SSO_TTL_SEC = int(os.getenv("HUB_SSO_TTL_SEC", "7200"))
HUB_EMBED_BOOTSTRAP_TTL_SEC = int(os.getenv("HUB_EMBED_BOOTSTRAP_TTL_SEC", "120"))
_used_nonces: dict[str, float] = {}
_nonce_lock = threading.Lock()
def hub_bridge_token() -> str:
return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
def safe_next_path(raw: str | None) -> str:
p = (raw or "/").strip()
if not p.startswith("/") or p.startswith("//"):
return "/"
if "://" in p:
return "/"
return p
def _sso_secret() -> str:
return hub_bridge_token()
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def _b64url_decode(data: str) -> bytes:
pad = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + pad)
def _prune_used_nonces() -> None:
now = time.time()
with _nonce_lock:
dead = [k for k, exp in _used_nonces.items() if exp <= now]
for k in dead:
del _used_nonces[k]
def mint_hub_sso_token(exchange_key: str, next_path: str = "/") -> str | None:
secret = _sso_secret()
ex = (exchange_key or "").strip().lower()
if not secret or not ex:
return None
payload = {
"ex": ex,
"exp": int(time.time()) + max(60, HUB_SSO_TTL_SEC),
"nonce": secrets.token_urlsafe(16),
"next": safe_next_path(next_path),
}
body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
return f"{body}.{sig}"
def verify_hub_sso_token(
token: str | None, expected_exchange: str
) -> tuple[bool, str, str | None]:
secret = _sso_secret()
expected = (expected_exchange or "").strip().lower()
if not secret or not expected:
return False, "/", "未配置 HUB_BRIDGE_TOKEN"
raw = (token or "").strip()
if "." not in raw:
return False, "/", "token 无效"
body, sig = raw.rsplit(".", 1)
try:
expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expect_sig, sig):
return False, "/", "签名校验失败"
payload = json.loads(_b64url_decode(body).decode())
except Exception:
return False, "/", "token 解析失败"
if not isinstance(payload, dict):
return False, "/", "payload 无效"
if str(payload.get("ex") or "").lower() != expected:
return False, "/", "实例不匹配"
try:
exp = int(payload.get("exp") or 0)
except (TypeError, ValueError):
return False, "/", "exp 无效"
if exp < int(time.time()):
return False, "/", "链接已过期"
nonce = str(payload.get("nonce") or "")
if not nonce:
return False, "/", "nonce 缺失"
_prune_used_nonces()
with _nonce_lock:
if nonce in _used_nonces:
return False, "/", "链接已使用"
_used_nonces[nonce] = float(exp)
return True, safe_next_path(str(payload.get("next") or "/")), None
def mint_hub_embed_bootstrap(exchange_key: str, next_path: str = "/") -> str | None:
"""iframe 内嵌登录引导 token(短效、单次),供 /hub-embed-auth 写入 SameSite=None Cookie。"""
secret = _sso_secret()
ex = (exchange_key or "").strip().lower()
if not secret or not ex:
return None
payload = {
"kind": "embed",
"ex": ex,
"exp": int(time.time()) + max(30, HUB_EMBED_BOOTSTRAP_TTL_SEC),
"nonce": secrets.token_urlsafe(16),
"next": safe_next_path(next_path),
}
body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
return f"{body}.{sig}"
def verify_hub_embed_bootstrap(
token: str | None, expected_exchange: str
) -> tuple[bool, str, str | None]:
secret = _sso_secret()
expected = (expected_exchange or "").strip().lower()
if not secret or not expected:
return False, "/", "未配置 HUB_BRIDGE_TOKEN"
raw = (token or "").strip()
if "." not in raw:
return False, "/", "token 无效"
body, sig = raw.rsplit(".", 1)
try:
expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expect_sig, sig):
return False, "/", "签名校验失败"
payload = json.loads(_b64url_decode(body).decode())
except Exception:
return False, "/", "token 解析失败"
if not isinstance(payload, dict) or payload.get("kind") != "embed":
return False, "/", "token 类型无效"
if str(payload.get("ex") or "").lower() != expected:
return False, "/", "实例不匹配"
try:
exp = int(payload.get("exp") or 0)
except (TypeError, ValueError):
return False, "/", "exp 无效"
if exp < int(time.time()):
return False, "/", "链接已过期"
nonce = str(payload.get("nonce") or "")
if not nonce:
return False, "/", "nonce 缺失"
key = f"embed:{nonce}"
_prune_used_nonces()
with _nonce_lock:
if key in _used_nonces:
return False, "/", "链接已使用"
_used_nonces[key] = float(exp)
return True, safe_next_path(str(payload.get("next") or "/")), None
File diff suppressed because it is too large Load Diff
+638
View File
@@ -0,0 +1,638 @@
"""各实例当日平仓记录查询(供 hub_bridge /api/hub/trades/today 与中控 AI 聚合)。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
from lib.strategy.strategy_trade_labels import (
MONITOR_TYPE_ROLL,
MONITOR_TYPE_TREND_PULLBACK,
entry_reason_for_monitor_type,
)
from lib.trade.time_close_lib import TIME_CLOSE_RESULT
TRADE_COMPLETED_RESULTS = (
"止盈",
"止损",
"保本止盈",
"移动止盈",
"手动平仓",
"强制清仓",
"外部平仓",
TIME_CLOSE_RESULT,
)
def trading_day_from_dt(dt: datetime, reset_hour: int = 8) -> str:
"""与实例 get_trading_day 一致:小时 < reset_hour 归属上一日历日。"""
if dt.hour < reset_hour:
dt = dt - timedelta(days=1)
return dt.strftime("%Y-%m-%d")
def current_trading_day(*, now: datetime | None = None, reset_hour: int = 8) -> str:
return trading_day_from_dt(now or datetime.now(), reset_hour)
def parse_dt_for_trading_day(raw: Any) -> datetime | None:
if raw is None:
return None
s = str(raw).strip().replace("Z", "").replace("T", " ")
if not s:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
return datetime.strptime(s[:ln], fmt)
except ValueError:
continue
return None
def trading_day_window_bounds(trading_day: str, reset_hour: int = 8) -> tuple[str, str]:
"""交易日 [reset_hour, 次日 reset_hour) 对应的北京时间字符串区间(闭区间)。"""
day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d")
start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1) - timedelta(seconds=1)
return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S")
def _row_dict(row, row_to_dict: Optional[Callable] = None) -> dict:
if row is None:
return {}
if row_to_dict:
try:
return dict(row_to_dict(row))
except Exception:
pass
try:
keys = row.keys() if hasattr(row, "keys") else ()
if keys:
return {k: row[k] for k in keys}
except Exception:
pass
try:
return dict(row)
except Exception:
return {}
def _effective_field(d: dict, reviewed_key: str, base_key: str, default: Any = None) -> Any:
rv = d.get(reviewed_key)
if rv is not None and str(rv).strip() != "":
return rv
bv = d.get(base_key)
if bv is not None and str(bv).strip() != "":
return bv
return default
def format_hold_minutes(minutes: Any) -> str:
try:
total = int(minutes or 0)
except (TypeError, ValueError):
return "0分钟"
if total <= 0:
return "0分钟"
hours = total // 60
mins = total % 60
if hours:
return f"{hours}小时{mins}分钟"
return f"{mins}分钟"
def _normalize_monitor_type_label(raw: Any) -> str:
mt = str(raw or "").strip()
if mt in ("trend_pullback", "trend"):
return MONITOR_TYPE_TREND_PULLBACK
if mt in ("roll",):
return MONITOR_TYPE_ROLL
return mt
def effective_entry_type(d: dict) -> str:
"""复盘开仓类型优先,与实例交易记录 effective_entry_reason 一致。"""
er = _effective_field(d, "reviewed_entry_reason", "entry_reason")
if er is not None and str(er).strip():
return str(er).strip()
mt = _normalize_monitor_type_label(d.get("monitor_type"))
er2 = entry_reason_for_monitor_type(mt)
if er2:
return er2
kst = str(d.get("key_signal_type") or "").strip()
if kst:
return kst
legacy = str(d.get("entry_type") or "").strip()
if legacy and legacy not in ("trend_pullback", "roll", "trend"):
return _normalize_monitor_type_label(legacy) or legacy
return mt
def display_entry_type_label(d: dict) -> str:
"""档案/列表展示用开仓类型(不回落为「下单监控」若已有复盘或建档类型)。"""
label = effective_entry_type(d).strip()
if not label:
return ""
return _normalize_monitor_type_label(label) or label
def effective_hold_minutes(
d: dict,
*,
opened_ms: int | None = None,
closed_ms: int | None = None,
) -> int:
hm = _effective_field(d, "reviewed_hold_minutes", "hold_minutes")
if hm is not None and str(hm).strip() != "":
try:
return max(0, int(hm))
except (TypeError, ValueError):
pass
hs = _effective_field(d, "reviewed_hold_seconds", "hold_seconds")
if hs is not None and str(hs).strip() != "":
try:
return max(0, int(int(hs) // 60))
except (TypeError, ValueError):
pass
oms = opened_ms if opened_ms is not None else d.get("opened_at_ms")
cms = closed_ms if closed_ms is not None else d.get("closed_at_ms")
try:
oms_i = int(oms) if oms not in (None, "") else None
cms_i = int(cms) if cms not in (None, "") else None
except (TypeError, ValueError):
oms_i = cms_i = None
if oms_i and cms_i and cms_i > oms_i:
return max(0, int((cms_i - oms_i) // 60_000))
return 0
def _effective_pnl(d: dict) -> float:
reviewed = d.get("reviewed_pnl_amount")
if reviewed is not None and str(reviewed).strip() != "":
try:
return float(reviewed)
except (TypeError, ValueError):
pass
ex = d.get("exchange_realized_pnl")
if ex is not None and str(ex).strip() != "":
try:
return float(ex)
except (TypeError, ValueError):
pass
try:
return float(d.get("pnl_amount") or 0)
except (TypeError, ValueError):
return 0.0
def _trade_close_dt(d: dict) -> datetime | None:
raw = _effective_field(d, "reviewed_closed_at", "closed_at")
if raw is None or str(raw).strip() == "":
raw = d.get("created_at") or d.get("opened_at")
return parse_dt_for_trading_day(raw)
def _normalize_trade_row(
d: dict,
*,
trading_day: str,
reset_hour: int,
) -> dict[str, Any] | None:
effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip()
if effective_result not in TRADE_COMPLETED_RESULTS:
return None
close_dt = _trade_close_dt(d)
if not close_dt:
return None
if trading_day_from_dt(close_dt, reset_hour) != trading_day:
return None
pnl = _effective_pnl(d)
closed_at = _effective_field(d, "reviewed_closed_at", "closed_at")
opened_at = _effective_field(d, "reviewed_opened_at", "opened_at")
return {
"symbol": d.get("symbol"),
"direction": d.get("direction"),
"result": effective_result,
"pnl_amount": round(pnl, 4),
"closed_at": closed_at,
"opened_at": opened_at,
"monitor_type": d.get("monitor_type"),
"actual_rr": d.get("actual_rr"),
"planned_rr": d.get("planned_rr"),
"trade_style": d.get("trade_style"),
"entry_reason": d.get("entry_reason"),
"reviewed": bool(d.get("reviewed_at") or d.get("reviewed_result")),
}
def fetch_trades_for_trading_day(
conn,
trading_day: str,
*,
row_to_dict_fn: Optional[Callable] = None,
reset_hour: int = 8,
limit: int = 200,
) -> list[dict[str, Any]]:
"""返回指定交易日的已平仓记录(与 /records 交易记录一致,复盘字段优先)。"""
day = (trading_day or "").strip()[:10]
if not day:
return []
lim = max(1, min(int(limit or 200), 500))
start_bj, end_bj = trading_day_window_bounds(day, reset_hour)
ts_expr = "REPLACE(COALESCE(reviewed_closed_at, closed_at, created_at, opened_at), 'T', ' ')"
rows = conn.execute(
f"""
SELECT symbol, direction, result, reviewed_result, pnl_amount, reviewed_pnl_amount,
exchange_realized_pnl, closed_at, reviewed_closed_at, opened_at, reviewed_opened_at,
created_at, monitor_type, actual_rr, planned_rr, trade_style, entry_reason,
reviewed_at
FROM trade_records
WHERE {ts_expr} >= ? AND {ts_expr} <= ?
ORDER BY {ts_expr} ASC
LIMIT ?
""",
(start_bj, end_bj, lim * 3),
).fetchall()
out: list[dict[str, Any]] = []
for row in rows:
d = _row_dict(row, row_to_dict_fn)
norm = _normalize_trade_row(d, trading_day=day, reset_hour=reset_hour)
if norm:
out.append(norm)
if len(out) >= lim:
break
return out
def _normalize_archive_trade_row(
d: dict,
*,
exchange_key: str = "",
reset_hour: int = 8,
) -> dict[str, Any] | None:
"""全历史档案用:已平仓记录(不按交易日截断)。"""
effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip()
if effective_result not in TRADE_COMPLETED_RESULTS:
return None
close_dt = _trade_close_dt(d)
if not close_dt:
return None
pnl = _effective_pnl(d)
closed_at = _effective_field(d, "reviewed_closed_at", "closed_at")
opened_at = _effective_field(d, "reviewed_opened_at", "opened_at")
opened_ms = d.get("opened_at_ms")
closed_ms = d.get("closed_at_ms")
if opened_ms in (None, ""):
odt = parse_dt_for_trading_day(opened_at)
opened_ms = int(odt.timestamp() * 1000) if odt else None
if closed_ms in (None, ""):
cdt = close_dt
closed_ms = int(cdt.timestamp() * 1000) if cdt else None
try:
trade_id = int(d.get("id"))
except (TypeError, ValueError):
return None
opened_ms_i = int(opened_ms) if opened_ms else None
closed_ms_i = int(closed_ms) if closed_ms else None
hold_m = effective_hold_minutes(d, opened_ms=opened_ms_i, closed_ms=closed_ms_i)
entry_type = display_entry_type_label(d)
reviewed = bool(
d.get("reviewed_at")
or d.get("reviewed_result")
or d.get("reviewed_opened_at")
or d.get("reviewed_closed_at")
or d.get("reviewed_entry_reason")
or d.get("reviewed_hold_minutes")
)
return {
"id": trade_id,
"exchange_key": (exchange_key or "").strip().lower(),
"symbol": (d.get("symbol") or "").strip().upper(),
"direction": d.get("direction"),
"result": effective_result,
"pnl_amount": round(pnl, 4),
"closed_at": closed_at,
"opened_at": opened_at,
"opened_at_ms": opened_ms_i,
"closed_at_ms": closed_ms_i,
"monitor_type": _normalize_monitor_type_label(d.get("monitor_type")),
"entry_type": entry_type,
"entry_reason": entry_type,
"hold_minutes": hold_m,
"hold_minutes_text": format_hold_minutes(hold_m),
"actual_rr": d.get("actual_rr"),
"planned_rr": d.get("planned_rr"),
"trade_style": d.get("trade_style"),
"trigger_price": d.get("trigger_price"),
"stop_loss": _effective_field(d, "reviewed_stop_loss", "stop_loss"),
"take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"),
"reviewed": reviewed,
"trading_day": trading_day_from_dt(close_dt, reset_hour),
"exchange_turnover_usdt": d.get("exchange_turnover_usdt"),
"exchange_commission_usdt": d.get("exchange_commission_usdt"),
}
_SNAPSHOT_STATUS_TO_RESULT = {
"stopped_sl": "止损",
"stopped_tp": "止盈",
"stopped_manual": "手动平仓",
"stopped_external": "外部平仓",
}
def _table_columns(conn, table: str) -> set[str]:
try:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
except Exception:
return set()
out: set[str] = set()
for r in rows:
try:
out.add(str(r[1]))
except (IndexError, TypeError):
try:
out.add(str(r["name"]))
except Exception:
continue
return out
def _archive_ts_expr(cols: set[str]) -> str:
parts = [c for c in ("reviewed_closed_at", "closed_at", "created_at", "opened_at") if c in cols]
if not parts:
return "''"
return f"REPLACE(COALESCE({', '.join(parts)}), 'T', ' ')"
def _archive_trade_select_sql(cols: set[str]) -> str:
wanted = [
"id",
"symbol",
"direction",
"result",
"reviewed_result",
"pnl_amount",
"reviewed_pnl_amount",
"exchange_realized_pnl",
"closed_at",
"reviewed_closed_at",
"opened_at",
"reviewed_opened_at",
"opened_at_ms",
"closed_at_ms",
"created_at",
"monitor_type",
"key_signal_type",
"actual_rr",
"planned_rr",
"trade_style",
"entry_reason",
"reviewed_entry_reason",
"hold_minutes",
"reviewed_hold_minutes",
"hold_seconds",
"reviewed_hold_seconds",
"trigger_price",
"stop_loss",
"take_profit",
"reviewed_stop_loss",
"reviewed_take_profit",
"reviewed_at",
"trend_plan_id",
"exchange_turnover_usdt",
"exchange_commission_usdt",
]
select_cols = [c for c in wanted if c in cols]
if "id" not in select_cols:
select_cols = ["id"] + select_cols
return ", ".join(select_cols)
def _existing_trend_plan_ids(conn) -> set[int]:
cols = _table_columns(conn, "trade_records")
if "trend_plan_id" not in cols:
return set()
rows = conn.execute(
"SELECT DISTINCT trend_plan_id FROM trade_records WHERE trend_plan_id IS NOT NULL"
).fetchall()
out: set[int] = set()
for row in rows:
d = _row_dict(row)
try:
out.add(int(d.get("trend_plan_id")))
except (TypeError, ValueError):
continue
return out
def _normalize_snapshot_archive_row(
snap: dict,
*,
exchange_key: str = "",
reset_hour: int = 8,
) -> dict[str, Any] | None:
result = str(snap.get("result_label") or "").strip()
if not result:
result = _SNAPSHOT_STATUS_TO_RESULT.get(
str(snap.get("status_at_close") or "").strip(), ""
)
if result not in TRADE_COMPLETED_RESULTS:
return None
closed_at = snap.get("closed_at")
close_dt = parse_dt_for_trading_day(closed_at)
if not close_dt:
return None
opened_at = snap.get("opened_at")
opened_ms = _parse_ms_from_row(snap.get("opened_at"))
closed_ms = _parse_ms_from_row(closed_at)
try:
snap_id = int(snap.get("id"))
except (TypeError, ValueError):
return None
try:
pnl = float(snap.get("pnl_amount") or 0)
except (TypeError, ValueError):
pnl = 0.0
st = str(snap.get("strategy_type") or "").strip()
monitor_type = _normalize_monitor_type_label(
"trend_pullback" if st == "trend_pullback" else ("roll" if st == "roll" else st)
)
hold_m = effective_hold_minutes(
{},
opened_ms=opened_ms,
closed_ms=closed_ms,
)
entry_type = entry_reason_for_monitor_type(monitor_type) or monitor_type
return {
"id": -snap_id,
"exchange_key": (exchange_key or "").strip().lower(),
"symbol": (snap.get("symbol") or "").strip().upper(),
"direction": snap.get("direction"),
"result": result,
"pnl_amount": round(pnl, 4),
"closed_at": closed_at,
"opened_at": opened_at,
"opened_at_ms": opened_ms,
"closed_at_ms": closed_ms,
"monitor_type": monitor_type,
"entry_type": entry_type,
"entry_reason": entry_type,
"hold_minutes": hold_m,
"hold_minutes_text": format_hold_minutes(hold_m),
"from_snapshot": True,
"snapshot_id": snap_id,
"trend_plan_id": snap.get("source_id"),
"reviewed": False,
"trading_day": trading_day_from_dt(close_dt, reset_hour),
}
def _parse_ms_from_row(raw: Any) -> int | None:
if raw in (None, ""):
return None
try:
if isinstance(raw, (int, float)):
v = int(raw)
return v if v > 1_000_000_000_000 else v * 1000
except (TypeError, ValueError):
pass
dt = parse_dt_for_trading_day(raw)
return int(dt.timestamp() * 1000) if dt else None
def _fetch_strategy_snapshots_for_archive(
conn,
*,
exchange_key: str = "",
days: int = 365,
reset_hour: int = 8,
limit: int = 2000,
skip_plan_ids: set[int] | None = None,
) -> list[dict[str, Any]]:
cols = _table_columns(conn, "strategy_trade_snapshots")
if not cols:
return []
lim = max(1, min(int(limit or 2000), 5000))
day_span = max(1, min(int(days or 365), 3650))
cutoff = datetime.now() - timedelta(days=day_span)
cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S")
ts_expr = "REPLACE(COALESCE(closed_at, opened_at, created_at), 'T', ' ')"
rows = conn.execute(
f"""
SELECT * FROM strategy_trade_snapshots
WHERE {ts_expr} >= ?
ORDER BY {ts_expr} DESC
LIMIT ?
""",
(cutoff_s, lim * 2),
).fetchall()
skip = skip_plan_ids or set()
out: list[dict[str, Any]] = []
for row in rows:
d = _row_dict(row)
try:
source_id = int(d.get("source_id") or 0)
except (TypeError, ValueError):
source_id = 0
if source_id > 0 and source_id in skip:
continue
norm = _normalize_snapshot_archive_row(
d, exchange_key=exchange_key, reset_hour=reset_hour
)
if norm:
out.append(norm)
if len(out) >= lim:
break
return out
def fetch_trades_for_archive(
conn,
*,
exchange_key: str = "",
days: int = 365,
row_to_dict_fn: Optional[Callable] = None,
reset_hour: int = 8,
limit: int = 2000,
include_strategy_snapshots: bool = True,
) -> list[dict[str, Any]]:
"""返回近 N 天已平仓记录(trade_records + 未落库的 strategy 快照)。"""
lim = max(1, min(int(limit or 2000), 5000))
day_span = max(1, min(int(days or 365), 3650))
cutoff = datetime.now() - timedelta(days=day_span)
cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S")
cols = _table_columns(conn, "trade_records")
if not cols:
records: list[dict[str, Any]] = []
else:
ts_expr = _archive_ts_expr(cols)
sql = f"""
SELECT {_archive_trade_select_sql(cols)}
FROM trade_records
WHERE {ts_expr} >= ?
ORDER BY {ts_expr} DESC
LIMIT ?
"""
rows = conn.execute(sql, (cutoff_s, lim * 2)).fetchall()
records = []
for row in rows:
d = _row_dict(row, row_to_dict_fn)
norm = _normalize_archive_trade_row(
d, exchange_key=exchange_key, reset_hour=reset_hour
)
if norm:
records.append(norm)
if len(records) >= lim:
break
if not include_strategy_snapshots:
return records
skip_ids = _existing_trend_plan_ids(conn)
for rec in records:
try:
pid = int(rec.get("trend_plan_id") or 0)
except (TypeError, ValueError):
pid = 0
if pid > 0:
skip_ids.add(pid)
snaps = _fetch_strategy_snapshots_for_archive(
conn,
days=days,
exchange_key=exchange_key,
reset_hour=reset_hour,
limit=max(0, lim - len(records)),
skip_plan_ids=skip_ids,
)
merged = records + snaps
merged.sort(
key=lambda x: int(x.get("closed_at_ms") or 0),
reverse=True,
)
return merged[:lim]
def summarize_trades(trades: list[dict]) -> dict[str, Any]:
"""单笔列表 → 笔数 / 盈亏 / 胜败统计。"""
total_pnl = 0.0
win = loss = flat = 0
for t in trades or []:
try:
pnl = float(t.get("pnl_amount") or 0)
except (TypeError, ValueError):
pnl = 0.0
total_pnl += pnl
if pnl > 1e-9:
win += 1
elif pnl < -1e-9:
loss += 1
else:
flat += 1
return {
"closed_count": len(trades or []),
"win_count": win,
"loss_count": loss,
"flat_count": flat,
"total_pnl_u": round(total_pnl, 4),
}
+595
View File
@@ -0,0 +1,595 @@
"""行情区:各交易所 USDT 永续昨日成交额 Top N(每日 8:00 快照)。"""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable
from zoneinfo import ZoneInfo
from lib.hub.hub_trades_lib import trading_day_from_dt
TOP_N_DEFAULT = 20
CACHE_VERSION = 3
LIQUIDITY_RANK_CACHE_VERSION = 1
def volume_rank_reset_hour() -> int:
try:
return max(0, min(23, int(os.getenv("HUB_VOLUME_RANK_RESET_HOUR", "8"))))
except ValueError:
return 8
def volume_rank_timezone() -> ZoneInfo:
name = (os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo("Asia/Shanghai")
def rank_date_label(*, now: datetime | None = None, reset_hour: int | None = None) -> str:
"""8 点更新后展示的「昨日」交易日(与 TRADING_DAY_RESET_HOUR 口径一致)。"""
rh = volume_rank_reset_hour() if reset_hour is None else reset_hour
tz = volume_rank_timezone()
dt = now.astimezone(tz) if now else datetime.now(tz)
cur_td = trading_day_from_dt(dt.replace(tzinfo=None), rh)
cur = datetime.strptime(cur_td, "%Y-%m-%d").date()
return (cur - timedelta(days=1)).isoformat()
def seconds_until_next_reset(
*,
now: datetime | None = None,
reset_hour: int | None = None,
) -> float:
rh = volume_rank_reset_hour() if reset_hour is None else reset_hour
tz = volume_rank_timezone()
dt = now.astimezone(tz) if now else datetime.now(tz)
nxt = dt.replace(hour=rh, minute=0, second=0, microsecond=0)
if dt >= nxt:
nxt += timedelta(days=1)
return max(1.0, (nxt - dt).total_seconds())
def default_cache_path() -> Path:
raw = (os.getenv("HUB_VOLUME_RANK_CACHE_PATH") or "").strip()
if raw:
return Path(raw)
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
hub_dir.mkdir(parents=True, exist_ok=True)
return hub_dir / "hub_volume_rank.json"
def _safe_float(v: Any) -> float | None:
try:
n = float(v)
return n if n == n else None
except (TypeError, ValueError):
return None
def _ticker_base(sym_text: str) -> str:
s = str(sym_text or "").upper().strip()
if ":" in s:
s = s.split(":", 1)[0]
if "/" in s:
return s.split("/", 1)[0].strip()
if "-" in s:
return s.split("-", 1)[0].strip()
if s.endswith("USDT"):
return s[:-4].strip()
return s
def _hub_symbol_from_base(base: str, quote: str = "USDT") -> str:
b = str(base or "").strip().upper()
q = str(quote or "USDT").strip().upper()
return f"{b}/{q}" if b else ""
def _hub_symbol_from_market(market: dict | None, fallback_symbol: str) -> str:
if market:
base = str(market.get("base") or "").strip().upper()
quote = str(market.get("quote") or "USDT").strip().upper()
if base:
return f"{base}/{quote}"
fb = str(fallback_symbol or "").upper().strip()
if ":" in fb:
fb = fb.split(":", 1)[0]
if "/" in fb:
return fb
base = _ticker_base(fb)
return f"{base}/USDT" if base else fb
def _okx_turnover_usdt(row: dict | None) -> float | None:
"""OKX SWAP:成交额(USDT) ≈ volCcy24h(基础币) × last。"""
if not isinstance(row, dict):
return None
base_vol = _safe_float(row.get("volCcy24h"))
if base_vol is None or base_vol <= 0:
return None
last = _safe_float(row.get("last") or row.get("lastPx"))
if last is None or last <= 0:
return None
return float(base_vol * last)
def _quote_volume_from_ticker(
ticker: dict | None,
market: dict | None,
*,
exchange_id: str = "",
) -> float | None:
ex_id = str(exchange_id or "").lower()
t = ticker or {}
info = t.get("info") if isinstance(t.get("info"), dict) else {}
if ex_id == "okx":
row = dict(info)
if row.get("last") is None:
row["last"] = t.get("last")
qv = _okx_turnover_usdt(row)
if qv is not None and qv > 0:
return qv
qv = _safe_float(t.get("quoteVolume"))
if qv is not None and qv > 0:
return qv
if ex_id in ("gateio", "gate"):
for key in (
"volume_24h_quote",
"volume_24h_settle",
"quote_volume",
"vol_24h",
"turnover",
):
qv = _safe_float(info.get(key))
if qv is not None and qv > 0:
return qv
for key in ("quoteVolume", "volCcy24h", "vol24h", "turnover24h", "amount24", "turnover"):
qv = _safe_float(info.get(key))
if qv is not None and qv > 0:
if key == "volCcy24h" and ex_id == "okx":
last = _safe_float(info.get("last") or info.get("lastPx") or t.get("last"))
if last:
return qv * last
return qv
bv = _safe_float(t.get("baseVolume"))
lp = _safe_float(t.get("last")) or _safe_float(t.get("close"))
if bv is not None and lp is not None and bv > 0 and lp > 0:
return bv * lp
if info:
bv = _safe_float(info.get("volCcy24h") or info.get("vol24h") or info.get("volume"))
lp = _safe_float(info.get("last") or info.get("lastPx") or info.get("markPrice"))
if bv is not None and lp is not None and bv > 0 and lp > 0:
return bv * lp
return None
def _is_usdt_linear_swap(market: dict | None, symbol: str) -> bool:
if not market:
su = str(symbol or "").upper()
return "USDT" in su and (":USDT" in su or "/USDT" in su or su.endswith("USDT"))
if not market.get("swap") and market.get("type") not in ("swap", "future"):
return False
if str(market.get("quote") or "").upper() != "USDT":
return False
if market.get("linear") is False:
return False
if market.get("active") is False:
return False
settle = str(market.get("settle") or "").upper()
if settle and settle != "USDT":
return False
return True
def _lookup_ticker(tickers: dict, sym: str, market: dict | None) -> dict | None:
if not tickers:
return None
t = tickers.get(sym)
if t:
return t
if not market:
return None
base = market.get("base")
quote = market.get("quote") or "USDT"
settle = market.get("settle") or quote
candidates = [
sym,
f"{base}/{quote}:{settle}",
f"{base}/{quote}",
f"{base}{quote}",
market.get("id"),
]
for key in candidates:
if not key:
continue
t = tickers.get(key)
if t:
return t
return None
def _merge_scores(scored: dict[str, tuple[str, float]]) -> list[tuple[str, str, float]]:
rows = [(sym, base, vol) for base, (sym, vol) in scored.items() if sym and base and vol > 0]
rows.sort(key=lambda x: x[2], reverse=True)
return rows
def _scores_from_okx(exchange) -> list[tuple[str, str, float]]:
by_base: dict[str, tuple[str, float]] = {}
if hasattr(exchange, "publicGetMarketTickers"):
try:
resp = exchange.publicGetMarketTickers({"instType": "SWAP"})
for row in (resp or {}).get("data") or []:
if not isinstance(row, dict):
continue
inst = str(row.get("instId") or "").upper()
parts = inst.split("-")
if len(parts) < 3 or parts[-1] != "SWAP" or parts[1] != "USDT":
continue
base = parts[0].strip()
if not base:
continue
qv = _okx_turnover_usdt(row)
if qv is None or qv <= 0:
continue
sym = _hub_symbol_from_base(base)
prev = by_base.get(base)
if prev is None or qv > prev[1]:
by_base[base] = (sym, float(qv))
if by_base:
return _merge_scores(by_base)
except Exception:
pass
try:
tickers = exchange.fetch_tickers(params={"instType": "SWAP"})
except Exception:
tickers = exchange.fetch_tickers()
return _scores_from_markets(exchange, tickers or {}, "okx")
def _scores_from_binance(exchange) -> list[tuple[str, str, float]]:
by_base: dict[str, tuple[str, float]] = {}
if hasattr(exchange, "fapiPublicGetTicker24hr"):
try:
rows = exchange.fapiPublicGetTicker24hr()
if isinstance(rows, list):
for row in rows:
if not isinstance(row, dict):
continue
raw = str(row.get("symbol") or "").upper()
if not raw.endswith("USDT"):
continue
base = raw[:-4]
if not base:
continue
qv = _safe_float(row.get("quoteVolume"))
if qv is None or qv <= 0:
bv = _safe_float(row.get("volume"))
lp = _safe_float(row.get("lastPrice") or row.get("weightedAvgPrice"))
if bv and lp:
qv = bv * lp
if qv is None or qv <= 0:
continue
sym = _hub_symbol_from_base(base)
prev = by_base.get(base)
if prev is None or qv > prev[1]:
by_base[base] = (sym, float(qv))
if by_base:
return _merge_scores(by_base)
except Exception:
pass
return []
def _scores_from_gate(exchange) -> list[tuple[str, str, float]]:
by_base: dict[str, tuple[str, float]] = {}
for method_name in ("publicFuturesGetSettleTickers", "publicFuturesGetUsdtTickers"):
fn = getattr(exchange, method_name, None)
if not callable(fn):
continue
try:
rows = fn({"settle": "usdt"})
if isinstance(rows, list):
for row in rows:
if not isinstance(row, dict):
continue
contract = str(row.get("contract") or row.get("name") or "").upper()
if not contract:
continue
base = contract.replace("_USDT", "").replace("USDT", "").strip("_")
if not base:
continue
qv = _safe_float(row.get("volume_24h_quote") or row.get("volume_24h_settle"))
if qv is None or qv <= 0:
bv = _safe_float(row.get("volume_24h_base"))
lp = _safe_float(row.get("last") or row.get("mark_price"))
if bv and lp:
qv = bv * lp
if qv is None or qv <= 0:
continue
sym = _hub_symbol_from_base(base)
prev = by_base.get(base)
if prev is None or qv > prev[1]:
by_base[base] = (sym, float(qv))
if by_base:
return _merge_scores(by_base)
except Exception:
continue
return []
def _scores_from_markets(
exchange,
tickers: dict,
exchange_id: str,
) -> list[tuple[str, str, float]]:
by_base: dict[str, tuple[str, float]] = {}
markets = getattr(exchange, "markets", None) or {}
for sym, mk in markets.items():
try:
if not _is_usdt_linear_swap(mk, sym):
continue
ticker = _lookup_ticker(tickers, sym, mk)
qv = _quote_volume_from_ticker(ticker, mk, exchange_id=exchange_id)
if qv is None or qv <= 0:
continue
hub_sym = _hub_symbol_from_market(mk, sym)
base = _ticker_base(hub_sym)
if not base:
continue
prev = by_base.get(base)
if prev is None or qv > prev[1]:
by_base[base] = (hub_sym, float(qv))
except Exception:
continue
return _merge_scores(by_base)
def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]:
ex_id = str(exchange_id or "").lower()
if ex_id == "okx":
return _scores_from_okx(exchange)
if ex_id == "binance":
return _scores_from_binance(exchange)
if ex_id in ("gateio", "gate", "gate_bot"):
return _scores_from_gate(exchange)
tickers = exchange.fetch_tickers()
return _scores_from_markets(exchange, tickers or {}, ex_id)
def _uses_lightweight_volume_scores(exchange_id: str) -> bool:
ex_id = str(exchange_id or "").lower()
return ex_id in ("okx", "binance", "gateio", "gate", "gate_bot")
def build_usdt_swap_volume_ranks(
exchange,
ensure_markets_loaded: Callable[[], None],
*,
exchange_id: str | None = None,
) -> tuple[dict[str, int], int]:
"""
全市场 USDT 永续 24h 成交额排名base -> rank
优先各所轻量 ticker API避免 fetch_tickers() 拉全市场Gate/Binance 内存优化
"""
ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower()
if not _uses_lightweight_volume_scores(ex_id):
ensure_markets_loaded()
scored = _collect_scores(exchange, ex_id)
ranks: dict[str, int] = {}
for idx, (_sym, base, _qv) in enumerate(scored, 1):
if base and base not in ranks:
ranks[base] = idx
return ranks, len(scored)
def resolve_daily_volume_rank(
target_base: str,
cache: dict[str, Any],
*,
now_ts: float,
ttl_sec: float,
exchange,
ensure_markets_loaded: Callable[[], None],
exchange_id: str | None = None,
cache_version: int = LIQUIDITY_RANK_CACHE_VERSION,
) -> tuple[int | None, int]:
"""关键位门控:按 base 查 24h 成交额全市场排名;cache 带 TTL。"""
cached_ok = (
cache.get("version") == cache_version
and cache.get("updated_at")
and now_ts - float(cache["updated_at"]) < ttl_sec
)
if not cached_ok:
try:
ranks, total = build_usdt_swap_volume_ranks(
exchange,
ensure_markets_loaded,
exchange_id=exchange_id,
)
if total > 0 and ranks:
cache["ranks"] = ranks
cache["total"] = total
cache["version"] = cache_version
cache["updated_at"] = now_ts
except Exception:
pass
ranks = cache.get("ranks") or {}
total = int(cache.get("total") or 0)
base = str(target_base or "").strip().upper()
return ranks.get(base), total
def fetch_usdt_swap_volume_rank(
exchange,
ensure_markets_loaded: Callable[[], None],
*,
top_n: int = TOP_N_DEFAULT,
rank_date: str | None = None,
exchange_id: str | None = None,
) -> dict[str, Any]:
"""从 ccxt 拉全市场 USDT 永续 ticker,按 24h 成交额(USDT) 取 Top N。"""
top_n = max(1, min(int(top_n or TOP_N_DEFAULT), 100))
ensure_markets_loaded()
ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower()
try:
scored = _collect_scores(exchange, ex_id)
except Exception as e:
return {"ok": False, "msg": str(e)}
items = []
for idx, (hub_sym, base, qv) in enumerate(scored[:top_n], 1):
items.append(
{
"rank": idx,
"symbol": hub_sym,
"base": base,
"volume_quote": round(qv, 4),
}
)
return {
"ok": True,
"rank_date": rank_date or rank_date_label(),
"items": items,
"total_symbols": len(scored),
"exchange_id": ex_id,
"fetched_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"),
}
def format_volume_quote(value: float | None) -> str:
n = _safe_float(value)
if n is None or n <= 0:
return ""
if n >= 1e9:
return f"{n / 1e9:.2f}B"
if n >= 1e6:
return f"{n / 1e6:.2f}M"
if n >= 1e3:
return f"{n / 1e3:.2f}K"
return f"{n:.0f}"
def load_volume_rank_cache(path: Path | None = None) -> dict[str, Any]:
p = path or default_cache_path()
if not p.is_file():
return {"version": CACHE_VERSION, "exchanges": {}}
try:
data = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return {"version": CACHE_VERSION, "exchanges": {}}
if int(data.get("version") or 0) < CACHE_VERSION:
return {"version": CACHE_VERSION, "exchanges": {}}
data.setdefault("version", CACHE_VERSION)
data.setdefault("exchanges", {})
return data
except Exception:
return {"version": CACHE_VERSION, "exchanges": {}}
def save_volume_rank_cache(data: dict[str, Any], path: Path | None = None) -> None:
p = path or default_cache_path()
p.parent.mkdir(parents=True, exist_ok=True)
payload = dict(data)
payload["version"] = CACHE_VERSION
payload["updated_at"] = datetime.now(volume_rank_timezone()).isoformat(timespec="seconds")
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def merge_exchange_rank(
cache: dict[str, Any],
exchange_key: str,
payload: dict[str, Any],
) -> dict[str, Any]:
ex_k = str(exchange_key or "").strip().lower()
if not ex_k or not payload.get("ok"):
return cache
exchanges = dict(cache.get("exchanges") or {})
exchanges[ex_k] = {
"rank_date": payload.get("rank_date"),
"items": payload.get("items") or [],
"total_symbols": int(payload.get("total_symbols") or 0),
"fetched_at": payload.get("fetched_at"),
"error": None,
}
out = dict(cache)
out["exchanges"] = exchanges
out["rank_date"] = payload.get("rank_date") or cache.get("rank_date")
return out
def _exchange_rank_row_stale(row: dict[str, Any] | None) -> bool:
if not row:
return True
items = row.get("items") or []
if len(items) < TOP_N_DEFAULT:
return True
total = int(row.get("total_symbols") or 0)
if total > 0 and total < TOP_N_DEFAULT:
return True
return False
def cache_needs_refresh(
cache: dict[str, Any],
*,
expected_rank_date: str | None = None,
required_keys: list[str] | None = None,
) -> bool:
expected = expected_rank_date or rank_date_label()
if int(cache.get("version") or 0) < CACHE_VERSION:
return True
exchanges = cache.get("exchanges") or {}
if not exchanges:
return True
if str(cache.get("rank_date") or "") != expected:
return True
keys = required_keys or list(exchanges.keys())
if not keys:
return True
for key in keys:
ex_k = str(key or "").strip().lower()
if not ex_k:
continue
if _exchange_rank_row_stale(exchanges.get(ex_k)):
return True
return False
def get_cached_rank(
cache: dict[str, Any],
exchange_key: str,
*,
top_n: int = TOP_N_DEFAULT,
) -> dict[str, Any]:
ex_k = str(exchange_key or "").strip().lower()
ex_data = (cache.get("exchanges") or {}).get(ex_k) or {}
items = list(ex_data.get("items") or [])[: max(1, int(top_n))]
stale = _exchange_rank_row_stale(ex_data)
return {
"ok": True,
"exchange_key": ex_k,
"rank_date": ex_data.get("rank_date") or cache.get("rank_date"),
"updated_at": cache.get("updated_at"),
"items": items,
"item_count": len(items),
"expected_count": int(top_n),
"total_symbols": int(ex_data.get("total_symbols") or 0),
"stale": stale,
"error": ex_data.get("error"),
}
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+187
View File
@@ -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,84 @@
"""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"})
@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",
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 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 result = '错过' THEN 1 ELSE 0 END) AS miss_count,
SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN result = '错过' AND COALESCE(miss_reason,'') LIKE '%持仓占用%' THEN 1 ELSE 0 END) AS occupied_miss
FROM trade_records
WHERE {tr_ts} >= ? AND {tr_ts} <= ?
""",
(start_bj, end_bj),
).fetchone()
total = int(row["total"] or 0) if row else 0
miss_count = int(row["miss_count"] or 0) if row else 0
wins = int(row["wins"] or 0) if row else 0
occupied_miss_total = int(row["occupied_miss"] or 0) if row else 0
rate = round(wins / total * 100, 2) if total else 0
return {
"records": [],
"total": total,
"miss_count": miss_count,
"rate": rate,
"occupied_miss_total": occupied_miss_total,
}
def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]:
return {"stats_reset_hour": reset_hour, "segments": []}
+148
View File
@@ -0,0 +1,148 @@
"""中控 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, ...] = (
"key_monitor",
"trade",
"strategy",
"strategy_records",
"records",
"stats",
)
PATH_TO_EMBED_TAB: dict[str, str] = {
"/": "trade",
"/trade": "trade",
"/key_monitor": "key_monitor",
"/strategy": "strategy",
"/strategy/trend": "strategy",
"/strategy/roll": "strategy",
"/strategy/records": "strategy_records",
"/records": "records",
"/stats": "stats",
}
ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = {
"gate": "order_monitor_rule_tips_gate.html",
"gate_bot": "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:
return (exchange_key or "").strip().lower() in ("gate", "gate_bot")
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 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:
app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn
@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
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 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),
}
+19
View File
@@ -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
+452
View File
@@ -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("缺少依赖:Pillowpip 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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,464 @@
{# Hub iframe tab fragment — shared via embed_templates #}
{% macro period_stats(title, s) %}
<div class="stats-period-block">
<h3>{{ title }}</h3>
<div class="sub">{{ s.range_label }}</div>
<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>
</div>
{% endmacro %}
<div class="grid">
{% if 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 }}">
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select id="order-direction" name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
<select name="trade_style" required>
<option value="trend">趋势单</option>
<option value="swing">波段单</option>
</select>
{% if position_sizing_mode != 'full_margin' %}
<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">
{% endif %}
<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>
<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>
<span style="display:flex;align-items:center;padding:0 10px;font-size:.8rem;color:#8fc8ff">成交价自动取交易所实时+成交回报</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="止盈距离=止损距离×盈亏比">
<span id="order-tp-preview" style="display:none;font-size:.8rem;color:#8fc8ff;align-self:center">预估止盈:—</span>
<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>
<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 %}
<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">
<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>
</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">风格: {{ o.trade_style or 'trend' }}</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 {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
{% if 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-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>
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
</div>
<div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
</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' %}
{% endif %}
{% if page == 'records' %}
<div class="card full records-card">
<h2>交易记录 & 错过机会</h2>
<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">
<table>
<tr><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>
{% for r in record %}
<tr id="trade-row-{{ r.id }}">
{% set pnl_val = (r.pnl_amount or 0)|float %}
<td>{{ r.symbol }}</td>
<td>{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}</td>
<td><span class="badge {{ 'direction-long' if r.direction == 'long' else 'direction-short' }}">{{ '做多' if r.direction == 'long' else '做空' }}</span></td>
<td>{{ price_fmt(r.symbol, r.trigger_price) }}</td>
{% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %}
{% set tp_show = r.effective_take_profit or r.take_profit %}
<td>{{ price_fmt(r.symbol, stop_show) }}</td>
<td>{{ price_fmt(r.symbol, tp_show) }}</td>
<td>{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %}</td>
<td>{{ r.leverage or '-' }}</td>
<td>{{ r.effective_hold_minutes or 0 }}</td>
<td>{{ (r.effective_opened_at or '-')[:16] }}</td>
<td>{{ (r.effective_closed_at or r.created_at or '-')[:16] }}</td>
{% set pnl_val = (r.effective_pnl_amount or 0)|float %}
<td><span class="{{ 'pnl-profit' if pnl_val > 0 else ('pnl-loss' if pnl_val < 0 else '') }}">{{ funds_fmt(r.effective_pnl_amount or 0) }}</span>{% if r.display_pnl_source == 'exchange' %}<span style="font-size:.68rem;color:#6ab88a"></span>{% elif r.display_pnl_source != 'reviewed' %}<span style="font-size:.68rem;color:#8892b0"></span>{% endif %}</td>
<td>
{% set effective_result = r.effective_result %}
{% if effective_result in ["止盈","保本止盈","移动止盈"] %}<span class="badge profit">{{ effective_result }}</span>
{% elif effective_result in ["止损","强制清仓","手动平仓"] %}<span class="badge loss">{{ effective_result }}</span>
{% elif effective_result == "时间平仓" %}<span class="badge miss">{{ effective_result }}</span>
{% else %}<span class="badge miss">{{ effective_result }}</span>{% endif %}
</td>
<td>
<button
type="button"
class="table-del"
style="background:#1f3a5a;color:#8fc8ff;margin-right:6px"
onclick='fillJournalFromTrade({{ {
"symbol": r.symbol,
"monitor_type": r.monitor_type,
"key_signal_type": r.key_signal_type or "",
"direction": r.direction,
"trigger_price": r.trigger_price,
"stop_loss": r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss,
"take_profit": r.effective_take_profit or r.take_profit,
"opened_at": r.effective_opened_at,
"closed_at": r.effective_closed_at,
"pnl_amount": r.effective_pnl_amount,
"result": r.effective_result,
"risk_amount": r.risk_amount
}|tojson|safe }})'
>填入复盘</button>
<button
type="button"
class="table-del review-edit-btn"
style="background:#1f3a5a;color:#8fc8ff;margin-right:6px"
onclick='editTradeRecordReview({{ {
"id": r.id,
"opened_at": r.effective_opened_at,
"closed_at": r.effective_closed_at,
"stop_loss": r.effective_stop_loss or r.initial_stop_loss or r.stop_loss,
"take_profit": r.effective_take_profit or r.take_profit,
"pnl_amount": r.effective_pnl_amount,
"result": r.effective_result,
"miss_reason": r.effective_miss_reason,
"effective_entry_reason": r.effective_entry_reason or ""
}|tojson|safe }})'
disabled
>核对修改</button>
<button type="button" class="table-del" onclick="deleteTradeRecord({{ r.id }})">删除</button>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="card miss-card" style="opacity:.78">
<h2>记录错过机会</h2>
<form action="/add_miss" method="post" class="form-row">
<input name="symbol" placeholder="品种" required>
<select name="type" required>
<option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option>
<option value="关键支撑阻力">关键支撑阻力</option>
</select>
<select name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="tp" step="0.0001" placeholder="入场价" required>
<input name="sl" step="any" placeholder="止损" required>
<input name="tgt" step="any" placeholder="止盈" required>
<input name="reason" placeholder="错过原因" required>
<button type="submit">记录</button>
</form>
</div>
<div class="card journal-card">
<h2>交易复盘记录上传(含截图)</h2>
<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">
<div class="form-grid">
<input type="datetime-local" name="open_datetime" required>
<input type="datetime-local" name="close_datetime" required>
<input name="coin" placeholder="BTC" required>
<input name="tf" placeholder="5m" required>
<input name="pnl" placeholder="盈亏(U)" required>
<select name="entry_reason" id="journal-entry-reason" required title="固定五种或选其他手写">
<option value="">开仓类型(必选)</option>
{% for er in entry_reason_options %}
<option value="{{ er }}">{{ er }}</option>
{% endfor %}
<option value="{{ entry_reason_other_value }}">其他(自定义,见下方说明框)</option>
</select>
<input type="text" name="entry_reason_custom" id="journal-entry-reason-custom" maxlength="2000" placeholder="选「其他」时在此填写开仓类型说明" autocomplete="off" style="display:none">
<input name="expect_rr" placeholder="预期RR">
<input name="real_rr" placeholder="实际RR">
<select name="early_exit_trigger" required title="平仓如何触发">
<option value="">离场触发(必选)</option>
<option value="止盈">止盈</option>
<option value="保本止盈">保本止盈</option>
<option value="移动止盈">移动止盈</option>
<option value="时间平仓">时间平仓</option>
<option value="手动平仓">手动平仓</option>
<option value="止损">止损</option>
<option value="其他">其他</option>
</select>
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
</div>
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;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" checked>
保存时自动生成 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:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
<div class="form-row" style="margin-top:8px">
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
</div>
<div class="mood-grid" style="margin-top:8px">
<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" style="width:100%;margin-top:8px" 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 class="panel-list" style="margin-top:10px">
<div class="panel-item">
<strong>交易复盘记录</strong>
<div id="journal-list"></div>
</div>
<div class="panel-item">
<strong>AI历史复盘</strong>
<div id="review-list"></div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% 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="stat-box" style="margin-bottom:10px">
<div class="stat-item"><div class="label">持仓占用导致错过(累计)</div><div class="value">{{ occupied_miss_total }}</div></div>
</div>
<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 %}>
{{ period_stats("日统计", seg.day) }}
{{ period_stats("周统计", seg.week) }}
{{ period_stats("月统计", seg.month) }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
+126
View File
@@ -0,0 +1,126 @@
<!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=47"></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=2">
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
<script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
</head>
<body
data-embed-shell="1"
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 class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div>
<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 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>
<nav class="top-nav embed-top-nav" aria-label="实例导航">
<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>
<a href="/strategy" data-embed-tab="strategy" class="{% if initial_tab == 'strategy' %}active{% endif %}">策略交易</a>
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if initial_tab == 'strategy_records' %}active{% endif %}">策略交易记录</a>
<a href="/records" data-embed-tab="records" class="{% if initial_tab == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}">统计分析</a>
</nav>
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
<div class="list-window-bar">
<span style="color:#cfd3ef">列表筛选(<strong>UTC</strong>,默认当日):{{ list_window.label }}</span>
<label>预设
<select id="win-preset-select" onchange="toggleListWindowCustom()">
<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" style="{% if list_window.preset != 'custom' %}display:none{% endif %}">
<label>起(UTC) <input type="datetime-local" id="win-from-utc" value="{{ list_window.start_utc.strftime('%Y-%m-%dT%H:%M') }}"></label>
<label>止(UTC) <input type="datetime-local" id="win-to-utc" value="{{ list_window.end_utc.strftime('%Y-%m-%dT%H:%M') }}"></label>
</span>
<button type="button" style="padding:6px 12px" onclick="applyListWindow()">应用</button>
<span style="color:#8892b0;font-size:.75rem">统计页仍按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切日</span>
</div>
<div class="export-bar instance-desktop-only">
<span style="color:#9aa">数据导出(v{{ data_export_version }} CSVUTF-8;交易记录含开仓类型列,复盘单独导出):</span>
<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>
<div class="stat-box instance-desktop-only">
<div class="stat-item"><div class="label">交易所</div><div class="value">{{ exchange_display }}</div></div>
<div class="stat-item"><div class="label">总交易</div><div class="value" id="stat-total">{{ total }}</div></div>
<div class="stat-item"><div class="label">错过次数</div><div class="value" id="stat-miss">{{ miss_count }}</div></div>
<div class="stat-item"><div class="label">胜率</div><div class="value" id="stat-rate">{{ rate }}%</div></div>
<div class="stat-item"><div class="label">资金账户(USDT)</div><div class="value" id="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div></div>
<div class="stat-item"><div class="label">交易日</div><div class="value">{{ trading_day }}</div></div>
<div class="stat-item"><div class="label">当日资金(交易账户)</div><div class="value" id="current-capital">{{ funds_fmt(current_capital) }}U</div></div>
</div>
{% if include_transfer_block %}
{% include 'gate_transfer_block.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>
<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=4"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=2"></script>
<script src="/static/ai_review_render.js?v=2"></script>
<script src="/static/form_submit_guard.js?v=2"></script>
<script src="/static/manual_order_rr_preview.js?v=5"></script>
<script src="/static/strategy_roll.js?v=5"></script>
<script src="/static/key_monitor_form.js?v=2"></script>
{% include 'embed_boot_scripts.html' %}
<script src="/static/instance_embed.js?v=5"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
@@ -0,0 +1,145 @@
"""假突破关键位监控:BTC/ETH 限价挂单(共享计算与校验)。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Optional
FALSE_BREAKOUT_MONITOR_TYPE = "假突破"
FALSE_BREAKOUT_SYMBOLS = frozenset({"BTC/USDT", "ETH/USDT"})
FALSE_BREAKOUT_OFFSET_PCT = 0.1
FALSE_BREAKOUT_SL_PCT = 0.5
FALSE_BREAKOUT_RR = 1.5
FALSE_BREAKOUT_VALIDITY_HOURS = 24
def is_false_breakout_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() == FALSE_BREAKOUT_MONITOR_TYPE
def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool:
from lib.key_monitor.fib_key_monitor_lib import is_fib_key_monitor_type
return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type)
def normalize_false_breakout_symbol(symbol: Optional[str]) -> Optional[str]:
s = (symbol or "").strip().upper()
if not s:
return None
if "/" not in s:
s = f"{s}/USDT"
return s if s in FALSE_BREAKOUT_SYMBOLS else None
def storage_bounds_from_key_price(direction: str, key_price: float) -> tuple[float, float]:
k = float(key_price)
if k <= 0:
raise ValueError("关键价位须为正数")
d = (direction or "long").strip().lower()
if d == "short":
return k, k * 0.9999
if d == "long":
return k * 1.0001, k
raise ValueError("方向须为 long 或 short")
def key_price_from_row(direction: str, upper: Any, lower: Any) -> Optional[float]:
d = (direction or "long").strip().lower()
try:
if d == "short":
v = float(upper)
else:
v = float(lower)
except (TypeError, ValueError):
return None
return v if v > 0 else None
def calc_false_breakout_plan(direction: str, key_price: float) -> Optional[tuple[float, float, float]]:
try:
k = float(key_price)
except (TypeError, ValueError):
return None
if k <= 0:
return None
d = (direction or "long").strip().lower()
off = FALSE_BREAKOUT_OFFSET_PCT / 100.0
sl_pct = FALSE_BREAKOUT_SL_PCT / 100.0
rr = float(FALSE_BREAKOUT_RR)
if d == "short":
entry = k * (1 + off)
sl = entry * (1 + sl_pct)
risk = sl - entry
if risk <= 0:
return None
tp = entry - risk * rr
return entry, sl, tp
if d == "long":
entry = k * (1 - off)
sl = entry * (1 - sl_pct)
risk = entry - sl
if risk <= 0:
return None
tp = entry + risk * rr
return entry, sl, tp
return None
def _parse_created_at(raw: Any) -> Optional[datetime]:
s = str(raw or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(s[:26], fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")[:32])
except ValueError:
return None
def is_false_breakout_expired(
created_at: Any,
now: datetime,
*,
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
) -> bool:
dt = _parse_created_at(created_at)
if dt is None:
return False
return now >= dt + timedelta(hours=hours)
def expires_at_text(created_at: Any, *, hours: int = FALSE_BREAKOUT_VALIDITY_HOURS) -> str:
dt = _parse_created_at(created_at)
if dt is None:
return ""
return (dt + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
def false_breakout_gate_preview(
*,
entry_display: str,
limit_order_id: Any = None,
created_at: Any = None,
now: Optional[datetime] = None,
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
) -> dict[str, Any]:
"""假突破门控预览:限价挂单状态,不使用箱体/收敛的量破幅二确门控。"""
now_dt = now or datetime.now()
expired = is_false_breakout_expired(created_at, now_dt, hours=hours)
exp_txt = expires_at_text(created_at, hours=hours)
status = "已过期" if expired else "等待成交"
metrics_parts: list[str] = []
oid = str(limit_order_id or "").strip()
if oid:
metrics_parts.append(f"限价单:{oid}")
if exp_txt != "":
metrics_parts.append(f"截至:{exp_txt}")
return {
"summary": f"假突破 挂E={entry_display} {status}",
"metrics": " ".join(metrics_parts),
"gate_ok": not expired,
}
+140
View File
@@ -0,0 +1,140 @@
"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用)。"""
from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES
FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"})
KEY_MONITOR_TRADE_TYPE = "关键位监控"
FIB_RATIO_BY_TYPE = {
"斐波回调0.618": 0.618,
"斐波回调0.786": 0.786,
}
def is_fib_key_monitor_type(monitor_type):
return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES
def fib_ratio_from_type(monitor_type):
return FIB_RATIO_BY_TYPE.get((monitor_type or "").strip())
def calc_fib_plan(direction, upper, lower, ratio):
"""
上沿 H下沿 LH > L
做多 H 向下回撤 ratioE = H - ratio*(H-L)SL=LTP=H
做空 L 向上反弹 ratioE = L + ratio*(H-L)SL=HTP=L
返回 (entry, stop_loss, take_profit) None
"""
try:
h = float(upper)
l = float(lower)
r = float(ratio)
except (TypeError, ValueError):
return None
if h <= l or r <= 0 or r >= 1:
return None
span = h - l
direction = (direction or "long").strip().lower()
if direction == "short":
entry = l + r * span
return entry, h, l
entry = h - r * span
return entry, l, h
def stored_key_signal_type(monitor_type):
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破/触价开仓)。"""
mt = (monitor_type or "").strip()
if mt in FIB_KEY_MONITOR_TYPES:
return mt
if mt in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
return mt if mt != "触价开仓" else "回调触价开仓"
if mt in KEY_MONITOR_AUTO_TYPES:
return mt
return None
KEY_ENTRY_REASON_BY_SIGNAL = {
"箱体突破": "关键位箱体突破",
"收敛突破": "关键位收敛突破",
"斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破",
"回调触价开仓": "关键位回调触价开仓",
"突破触价开仓": "关键位突破触价开仓",
"触价开仓": "关键位触价开仓",
"趋势回调": "趋势回调",
}
def entry_reason_from_key_signal(key_signal_type):
return KEY_ENTRY_REASON_BY_SIGNAL.get((key_signal_type or "").strip())
def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
"""平仓写入 trade_records 时保留箱体/收敛/斐波/假突破来源。"""
kst = (key_signal_type or "").strip()
if kst in FIB_KEY_MONITOR_TYPES:
return kst
if kst in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
return kst if kst != "触价开仓" else "回调触价开仓"
if box_auto_types and kst in box_auto_types:
return kst
return None
def backfill_missing_key_signal_types(conn, *, monitor_type: str = KEY_MONITOR_TRADE_TYPE) -> int:
"""补全历史 trade_records / order_monitors 中缺失的箱体/收敛 key_signal_type。"""
mt = (monitor_type or KEY_MONITOR_TRADE_TYPE).strip()
updated = 0
for signal in KEY_MONITOR_AUTO_TYPES:
entry_reason = KEY_ENTRY_REASON_BY_SIGNAL.get(signal)
if entry_reason:
cur = conn.execute(
"""UPDATE trade_records SET key_signal_type=?
WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')
AND TRIM(COALESCE(entry_reason, ''))=?""",
(signal, mt, entry_reason),
)
updated += int(cur.rowcount or 0)
rows = conn.execute(
"""SELECT id, symbol, opened_at FROM trade_records
WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')""",
(mt,),
).fetchall()
for row in rows:
# init_db 连接未设 row_factory,结果为 tuple
rid, sym, opened_at = row[0], row[1], row[2]
opened = (opened_at or "").strip()
for signal in KEY_MONITOR_AUTO_TYPES:
hist = conn.execute(
"""SELECT monitor_type FROM key_monitor_history
WHERE symbol=? AND monitor_type=? AND close_reason='auto_opened'
AND (?='' OR closed_at <= ?)
ORDER BY closed_at DESC LIMIT 1""",
(sym, signal, opened, opened),
).fetchone()
if not hist:
continue
conn.execute(
"UPDATE trade_records SET key_signal_type=? WHERE id=?",
(signal, rid),
)
updated += 1
break
return updated
def fib_invalidate_by_mark(direction, mark_price, upper, lower):
"""先触达止盈侧(标记价)则失效。多:mark>=H;空:mark<=L。"""
try:
m = float(mark_price)
h = float(upper)
l = float(lower)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "short":
return m <= l
return m >= h
@@ -0,0 +1,61 @@
"""
全仓杠杆模式下撤销已添加的箱体/收敛/斐波关键位监控并微信说明
"""
from __future__ import annotations
from typing import Any, Callable, Iterable, Optional
from lib.key_monitor.fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type
from lib.key_monitor.false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type
from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES
from lib.trade.position_sizing_lib import is_full_margin_mode, mode_label_zh
def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool:
mt = (monitor_type or "").strip()
if mt in KEY_MONITOR_AUTO_TYPES:
return True
if is_fib_key_monitor_type(mt):
return True
return is_false_breakout_key_monitor_type(mt)
def purge_disallowed_key_monitors(
conn: Any,
*,
sizing_mode: str,
select_rows: Callable[[Any], Iterable[Any]],
cancel_fib_limit: Callable[[Any], None],
delete_monitor: Callable[[Any, int], None],
send_wechat: Callable[[str], None],
row_symbol: Callable[[Any], str] = lambda r: str(r["symbol"] or ""),
row_monitor_type: Callable[[Any], str] = lambda r: str(r["monitor_type"] or ""),
row_id: Callable[[Any], int] = lambda r: int(r["id"]),
) -> int:
if not is_full_margin_mode(sizing_mode):
return 0
removed = []
for row in select_rows(conn):
mt = row_monitor_type(row)
if not monitor_type_disallowed_in_full_margin(mt):
continue
sym = row_symbol(row)
kid = row_id(row)
if is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt):
try:
cancel_fib_limit(row)
except Exception:
pass
delete_monitor(conn, kid)
removed.append((sym, mt, kid))
if removed:
lines = [f"· {s} {t} (#{i})" for s, t, i in removed[:12]]
if len(removed) > 12:
lines.append(f"… 共 {len(removed)}")
send_wechat(
"# ⚠️ 全仓杠杆模式:已自动撤销关键位监控\n"
f"计仓模式:{mode_label_zh(sizing_mode)}(仅 env 可切换,须无仓)\n"
"已撤销:箱体突破 / 收敛突破 / 斐波回调 / 假突破监控(不可与全仓杠杆并存)\n"
+ "\n".join(lines)
)
return len(removed)
+390
View File
@@ -0,0 +1,390 @@
"""
关键位监控阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
KEY_MONITOR_RS_TYPE = "关键支撑阻力"
KEY_MONITOR_RS_LEGACY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
KEY_MONITOR_RS_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
KEY_DIRECTION_WATCH = "watch"
def is_rs_key_monitor_type(monitor_type: str) -> bool:
return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES
def rs_monitor_type_label(monitor_type: str) -> str:
"""展示用:旧库里的阻力/支撑合并为「关键支撑阻力」。"""
if is_rs_key_monitor_type(monitor_type):
return KEY_MONITOR_RS_TYPE
return (monitor_type or "").strip()
def rs_monitor_type_for_storage(monitor_type: str) -> str:
if is_rs_key_monitor_type(monitor_type):
return KEY_MONITOR_RS_TYPE
return (monitor_type or "").strip()
def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float:
"""突破 K 收盘相对关键位的越过幅度(%)。未越过对应边界时返回 0。"""
direction = (direction or "long").strip().lower()
c = float(close)
if direction == "long":
u = float(upper)
if u <= 0 or c <= u:
return 0.0
return (c - u) / u * 100.0
lo = float(lower)
if lo <= 0 or c >= lo:
return 0.0
return (lo - c) / lo * 100.0
def auto_amp_ok(
direction: str,
close_b: float,
upper: float,
lower: float,
min_pct: float,
) -> tuple[bool, float]:
breach = calc_breakout_breach_pct(direction, close_b, upper, lower)
return breach > float(min_pct), breach
def auto_confirm_ok(direction: str, cfm_close: float, upper: float, lower: float) -> bool:
"""确认 K 收盘须在箱体外(不得回到 [lower, upper] 内)。"""
direction = (direction or "long").strip().lower()
c = float(cfm_close)
if direction == "long":
return c > float(upper)
return c < float(lower)
BOX_BREAKOUT_CLOSE_OPPOSITE = "box_opposite_break"
def box_breakout_invalidate_by_mark(
direction: str, mark_price: float, upper: float, lower: float
) -> bool:
"""箱体/收敛:标记价先突破反向边界则失效。多:mark<=L;空:mark>=H。"""
try:
m = float(mark_price)
h = float(upper)
lo = float(lower)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "short":
return m >= h
return m <= lo
def box_breakout_invalidate_edge_label(direction: str) -> str:
direction = (direction or "long").strip().lower()
return "下沿" if direction == "long" else "上沿"
def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]:
"""
阻力/支撑人工盯盘最近 5m 收盘突破上沿或下沿严格 > / <
上沿优先同一根 K 不可能同时满足两者
"""
u, lo, c = float(upper), float(lower), float(close)
if c > u:
return {
"break_side": "upper",
"direction": "long",
"edge_price": u,
"key_price": u,
"break_label": "向上突破上沿",
}
if c < lo:
return {
"break_side": "lower",
"direction": "short",
"edge_price": lo,
"key_price": lo,
"break_label": "向下突破下沿",
}
return None
def rs_break_from_direction(direction: str, upper: float, lower: float) -> Optional[dict[str, Any]]:
"""已触发后根据入库方向还原突破边(long=上沿,short=下沿)。"""
d = (direction or "").strip().lower()
if d == "long":
return {
"break_side": "upper",
"direction": "long",
"edge_price": float(upper),
"key_price": float(upper),
"break_label": "向上突破上沿",
}
if d == "short":
return {
"break_side": "lower",
"direction": "short",
"edge_price": float(lower),
"key_price": float(lower),
"break_label": "向下突破下沿",
}
return None
def rs_break_infer_from_close(close: float, upper: float, lower: float) -> dict[str, Any]:
"""
续发提醒时价格已回到箱体内按收盘价相对箱体中线推断首次突破边
保证第 2/3 次企业微信提醒仍能发出
"""
mid = (float(upper) + float(lower)) / 2.0
if float(close) >= mid:
br = rs_break_from_direction("long", upper, lower)
else:
br = rs_break_from_direction("short", upper, lower)
if br:
return br
return {
"break_side": "upper",
"direction": "long",
"edge_price": float(upper),
"key_price": float(upper),
"break_label": "向上突破上沿",
}
def _parse_notify_datetime(raw: Optional[str]) -> Optional[datetime]:
s = str(raw or "").strip()
if not s:
return None
try:
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
return dt
except Exception:
pass
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(s[:19], fmt)
except Exception:
continue
return None
def claim_rs_level_notify(
conn: Any,
monitor_id: int,
notify_index: int,
direction: str,
notified_at: str,
bar_ts: Optional[int],
*,
prior_count: Optional[int] = None,
) -> bool:
"""
原子占位仅在 notification_count 仍为 prior_count 时推进到 notify_index
须在发送企业微信之前调用并 commit避免 (2/3) 重复刷屏
"""
prior = int(prior_count if prior_count is not None else notify_index - 1)
if prior < 0 or notify_index != prior + 1:
return False
bar_val: Optional[int] = None
if bar_ts is not None:
try:
bar_val = int(bar_ts)
except (TypeError, ValueError):
bar_val = None
cur = conn.execute(
"UPDATE key_monitors SET notification_count=?, direction=?, last_notified_at=?, last_rs_bar_ts=? "
"WHERE id=? AND COALESCE(notification_count,0)=?",
(notify_index, direction, notified_at, bar_val, int(monitor_id), prior),
)
return int(cur.rowcount or 0) > 0
def parse_last_rs_bar_ts(row: Any) -> Optional[int]:
if row is None:
return None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
raw = row["last_rs_bar_ts"] if "last_rs_bar_ts" in keys else None
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def run_rs_level_alert_tick(
row: Any,
close: float,
bar_ts: Optional[int],
now_dt: datetime,
*,
default_max_notify: int,
default_interval_min: int,
) -> Optional[dict[str, Any]]:
"""
判定本轮回合是否应推送阻力/支撑提醒
首条仅在新闭合 K 越线时触发发送前须 claim_rs_level_notify 占位防轮询/多进程重复
"""
up, lo = float(row["upper"]), float(row["lower"])
if up <= lo:
return None
count = int(row["notification_count"] or 0)
max_n = max(1, int(row["max_notify"] or default_max_notify))
interval = max(1, int(row["notify_interval_min"] or default_interval_min))
if count >= max_n:
return None
bar_ts_i: Optional[int] = None
if bar_ts is not None:
try:
bar_ts_i = int(bar_ts)
except (TypeError, ValueError):
bar_ts_i = None
last_bar_i = parse_last_rs_bar_ts(row)
if count == 0:
br = detect_rs_box_break(close, up, lo)
if not br:
return None
if bar_ts_i is not None and last_bar_i is not None and bar_ts_i == last_bar_i:
return None
return {
"break_info": br,
"notify_index": 1,
"prior_count": 0,
"notify_max": max_n,
"interval_min": interval,
"bar_ts": bar_ts_i,
}
if not notify_interval_elapsed(row["last_notified_at"], interval, now_dt):
return None
br = resolve_rs_break_for_alert(count, row["direction"], close, up, lo)
if not br:
return None
return {
"break_info": br,
"notify_index": count + 1,
"prior_count": count,
"notify_max": max_n,
"interval_min": interval,
"bar_ts": bar_ts_i,
}
def resolve_rs_break_for_alert(
notification_count: int,
direction: Optional[str],
close: float,
upper: float,
lower: float,
) -> Optional[dict[str, Any]]:
"""
阻力/支撑提醒首次用 5m 收盘越线判定后续用已存方向兼容 direction=watch
"""
count = int(notification_count or 0)
up, lo, c = float(upper), float(lower), float(close)
if count <= 0:
return detect_rs_box_break(c, up, lo)
br = rs_break_from_direction(direction, up, lo)
if br:
return br
d = (direction or "").strip().lower()
if d not in ("", KEY_DIRECTION_WATCH):
return None
br = detect_rs_box_break(c, up, lo)
if br:
return br
return rs_break_infer_from_close(c, up, lo)
def notify_interval_elapsed(
last_notified_at: Optional[str],
interval_min: int,
now_dt: datetime,
) -> bool:
if not last_notified_at:
return False
last_dt = _parse_notify_datetime(last_notified_at)
if last_dt is None:
return False
return (now_dt - last_dt).total_seconds() >= max(1, int(interval_min)) * 60
def format_auto_amp_line(amp_ok: bool, amp_pct: float, min_pct: float) -> str:
return (
f"突破越过幅度:{'通过' if amp_ok else '不通过'}"
f"{round(float(amp_pct), 4)}%,要求 > {min_pct}%"
)
def format_auto_confirm_line(confirm_ok: bool, cfm_close, edge_price, direction: str) -> str:
side = "箱外上方" if (direction or "").lower() == "long" else "箱外下方"
return (
f"第二根确认:{'通过' if confirm_ok else '不通过'}"
f"(确认收盘 {cfm_close},须收于{side},关键位 {edge_price}"
)
def key_monitor_rule_template_context(
*,
kline_timeframe: str,
key_breakout_amp_min_pct: float,
key_volume_ma_bars: int,
key_volume_ratio_min: float,
key_auto_min_planned_rr: float,
key_daily_volume_rank_max: int,
key_confirm_breakout_bar: int,
key_confirm_bar: int,
key_alert_max_times: int,
key_alert_interval_minutes: int,
key_stop_outside_breakout_pct: float,
key_trend_stop_outside_pct: float,
false_breakout_validity_hours: int,
trigger_entry_validity_hours: int | None = None,
) -> dict[str, Any]:
"""关键位监控页规则说明表格(Jinja key_rule_ctx)。"""
from lib.key_monitor.false_breakout_key_monitor_lib import (
FALSE_BREAKOUT_OFFSET_PCT,
FALSE_BREAKOUT_RR,
FALSE_BREAKOUT_SL_PCT,
)
from lib.key_monitor.trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS
te_hours = (
int(trigger_entry_validity_hours)
if trigger_entry_validity_hours is not None
else TRIGGER_ENTRY_VALIDITY_HOURS
)
return {
"tf": (kline_timeframe or "5m").strip(),
"amp_min_pct": key_breakout_amp_min_pct,
"vol_ma_bars": key_volume_ma_bars,
"vol_ratio_min": key_volume_ratio_min,
"min_rr": key_auto_min_planned_rr,
"vol_rank_max": key_daily_volume_rank_max,
"breakout_bar": key_confirm_breakout_bar,
"confirm_bar": key_confirm_bar,
"alert_max": key_alert_max_times,
"alert_interval_min": key_alert_interval_minutes,
"stop_outside_pct": key_stop_outside_breakout_pct,
"trend_stop_outside_pct": key_trend_stop_outside_pct,
"fb_offset_pct": FALSE_BREAKOUT_OFFSET_PCT,
"fb_sl_pct": FALSE_BREAKOUT_SL_PCT,
"fb_rr": FALSE_BREAKOUT_RR,
"fb_valid_hours": false_breakout_validity_hours,
"trigger_entry_validity_hours": te_hours,
}
+14
View File
@@ -0,0 +1,14 @@
"""关键位监控表结构迁移(四所共用)。"""
from __future__ import annotations
from typing import Any
def ensure_key_monitor_schema(conn: Any) -> None:
for sql in (
"ALTER TABLE key_monitors ADD COLUMN last_mark_price REAL",
):
try:
conn.execute(sql)
except Exception:
pass
+139
View File
@@ -0,0 +1,139 @@
"""关键位箱体/收敛:止盈止损方案(Binance / Gate / OKX 共用)。"""
KEY_SL_TP_MODES = frozenset({"standard", "box_1p5", "trend_manual"})
KEY_SL_TP_MODE_LABELS = {
"standard": "标准突破",
"box_1p5": "箱体1R·止盈1.5H",
"trend_manual": "趋势单·自填止盈",
}
KEY_MONITOR_AUTO_TYPES_FOR_FORM = frozenset({"箱体突破", "收敛突破"})
def normalize_sl_tp_mode(raw):
m = (raw or "standard").strip().lower()
if m in ("box_1p5", "box15", "box-1.5", "box_1.5"):
return "box_1p5"
if m in ("trend_manual", "trend", "manual"):
return "trend_manual"
if m in KEY_SL_TP_MODES:
return m
return "standard"
def sl_tp_mode_label(mode):
return KEY_SL_TP_MODE_LABELS.get(normalize_sl_tp_mode(mode), normalize_sl_tp_mode(mode))
def sl_tp_mode_from_row(row, default="standard"):
try:
if hasattr(row, "keys") and "sl_tp_mode" in row.keys():
raw = row["sl_tp_mode"]
else:
raw = row.get("sl_tp_mode") if isinstance(row, dict) else None
except Exception:
raw = None
return normalize_sl_tp_mode(raw if raw not in (None, "") else default)
def breakeven_enabled_from_row(row, default=0):
try:
if hasattr(row, "keys") and "breakeven_enabled" in row.keys():
v = row["breakeven_enabled"]
else:
v = row.get("breakeven_enabled") if isinstance(row, dict) else None
except Exception:
v = None
if v is None:
return int(default) != 0
return int(v) != 0
def parse_breakeven_enabled_form(form_value):
return 1 if (form_value or "").strip().lower() in ("1", "true", "on", "yes") else 0
def plan_key_sl_tp(
mode,
direction,
upper,
lower,
checks,
*,
outside_pct,
trend_outside_pct,
manual_take_profit=None,
):
"""
以确认 K 收盘 E 当前价计算计划 SL/TP
返回 (E, sl_raw, tp_raw, box_h) None几何无效 / 模式3缺止盈
"""
try:
E = float(checks["confirm_close"])
H = abs(float(upper) - float(lower))
except (TypeError, ValueError, KeyError):
return None
if H <= 0:
return None
direction = (direction or "long").strip().lower()
mode = normalize_sl_tp_mode(mode)
if mode == "box_1p5":
if direction == "long":
sl_raw = E - H
tp_raw = E + 1.5 * H
else:
sl_raw = E + H
tp_raw = E - 1.5 * H
return E, sl_raw, tp_raw, H
if mode == "trend_manual":
try:
br_hi = float(checks["breakout_high"])
br_lo = float(checks["breakout_low"])
tp_raw = float(manual_take_profit)
except (TypeError, ValueError, KeyError):
return None
m = float(trend_outside_pct) / 100.0
if direction == "long":
sl_raw = br_lo * (1.0 - m) if br_lo > 0 else 0.0
if tp_raw <= E or sl_raw <= 0:
return None
else:
sl_raw = br_hi * (1.0 + m) if br_hi > 0 else 0.0
if tp_raw >= E or sl_raw <= 0:
return None
return E, sl_raw, tp_raw, H
# standard:突破 K 极值外侧 + 止盈 E±1×H
try:
br_hi = float(checks["breakout_high"])
br_lo = float(checks["breakout_low"])
except (TypeError, ValueError, KeyError):
return None
om = float(outside_pct) / 100.0
if direction == "long":
sl_raw = br_lo * (1.0 - om) if br_lo > 0 else 0.0
tp_raw = E + H
else:
sl_raw = br_hi * (1.0 + om) if br_hi > 0 else 0.0
tp_raw = E - H
return E, sl_raw, tp_raw, H
def sl_tp_plan_summary_text(mode, direction, E, sl_raw, tp_raw, box_h, *, outside_pct, trend_outside_pct):
"""微信/页面用一行计划 SL/TP 说明。"""
mode = normalize_sl_tp_mode(mode)
direction = (direction or "long").strip().lower()
if mode == "box_1p5":
return (
f"方案:{sl_tp_mode_label(mode)}E={E}SL=E∓1×H({box_h})TP=E∓1.5×H"
)
if mode == "trend_manual":
return (
f"方案:{sl_tp_mode_label(mode)}E={E}SL=突破K极值外{trend_outside_pct}%TP={tp_raw}(录入)"
)
return (
f"方案:{sl_tp_mode_label(mode)}E={E}SL=突破K外{outside_pct}%TP=E±1×H({box_h})"
)
@@ -0,0 +1,296 @@
"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable, Optional
from lib.key_monitor.false_breakout_key_monitor_lib import (
_parse_created_at,
expires_at_text,
is_false_breakout_expired,
)
from lib.strategy.strategy_trend_lib import trend_dca_level_reached
# 回调触价(原「触价开仓」)
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE = "回调触价开仓"
LEGACY_TRIGGER_ENTRY_MONITOR_TYPE = "触价开仓"
# 突破触价:标记价穿越 E 后立即市价开仓
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE = "突破触价开仓"
TRIGGER_ENTRY_MONITOR_TYPES = frozenset(
{
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
LEGACY_TRIGGER_ENTRY_MONITOR_TYPE,
}
)
TRIGGER_ENTRY_VALIDITY_HOURS = 24
TRIGGER_ENTRY_CLOSE_FILLED = "trigger_entry_filled"
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE = "trigger_tp_invalidate"
TRIGGER_ENTRY_CLOSE_SL_INVALIDATE = "trigger_sl_invalidate"
TRIGGER_ENTRY_CLOSE_EXPIRED = "trigger_entry_expired"
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED = "trigger_exchange_failed"
KEY_ENTRY_REASON_CALLBACK = "关键位回调触价开仓"
KEY_ENTRY_REASON_BREAKOUT = "关键位突破触价开仓"
KEY_ENTRY_REASON_TRIGGER_LEGACY = "关键位触价开仓"
def normalize_trigger_entry_monitor_type(monitor_type: Optional[str]) -> str:
mt = (monitor_type or "").strip()
if mt == LEGACY_TRIGGER_ENTRY_MONITOR_TYPE:
return CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
return mt
def is_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() in TRIGGER_ENTRY_MONITOR_TYPES
def is_callback_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
mt = normalize_trigger_entry_monitor_type(monitor_type)
return mt == CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
def is_breakout_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE
def key_entry_reason_for_monitor_type(monitor_type: Optional[str]) -> str:
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
return KEY_ENTRY_REASON_BREAKOUT
if is_trigger_entry_key_monitor_type(monitor_type):
return KEY_ENTRY_REASON_CALLBACK
return KEY_ENTRY_REASON_TRIGGER_LEGACY
def trigger_entry_reached(direction: str, mark_price: float, entry: float) -> bool:
"""回调触价:多=价跌至 E;空=价涨至 E。"""
return trend_dca_level_reached(direction, mark_price, entry)
def breakout_trigger_entry_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
entry: float,
) -> bool:
"""突破触价:多=向上穿越 E;空=向下穿越 E。"""
try:
m = float(mark)
e = float(entry)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m > e
return pm <= e and m > e
if pm is None:
return m < e
return pm >= e and m < e
def trigger_should_fire(
monitor_type: Optional[str],
direction: str,
mark: float,
entry: float,
prev_mark: Optional[float] = None,
) -> bool:
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
return breakout_trigger_entry_crossed(direction, prev_mark, mark, entry)
return trigger_entry_reached(direction, mark, entry)
def trigger_entry_invalidate_by_tp(direction: str, mark_price: float, take_profit: float) -> bool:
"""未开仓前标记价先触达止盈侧则失效。"""
try:
m = float(mark_price)
tp = float(take_profit)
except (TypeError, ValueError):
return False
d = (direction or "long").strip().lower()
if d == "short":
return m <= tp
return m >= tp
def trigger_entry_invalidate_by_sl(direction: str, mark_price: float, stop_loss: float) -> bool:
"""突破触价:未到 E 先触达止损侧则失效。"""
try:
m = float(mark_price)
sl = float(stop_loss)
except (TypeError, ValueError):
return False
d = (direction or "long").strip().lower()
if d == "long":
return m <= sl
return m >= sl
def trigger_entry_invalidate(
monitor_type: Optional[str],
direction: str,
mark: float,
stop_loss: float,
take_profit: float,
) -> Optional[str]:
if trigger_entry_invalidate_by_tp(direction, mark, take_profit):
return "tp"
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
if trigger_entry_invalidate_by_sl(direction, mark, stop_loss):
return "sl"
return None
def validate_trigger_entry_geometry(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
mark_at_add: Optional[float] = None,
*,
monitor_type: Optional[str] = None,
) -> Optional[str]:
"""返回错误文案;合法则 None。"""
try:
e = float(entry)
sl = float(stop_loss)
tp = float(take_profit)
except (TypeError, ValueError):
return "入场价、止损、止盈须为有效数字"
if e <= 0 or sl <= 0 or tp <= 0:
return "入场价、止损、止盈须大于 0"
d = (direction or "long").strip().lower()
mt = normalize_trigger_entry_monitor_type(monitor_type)
label = "突破触价开仓" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调触价开仓"
if d == "long":
if not (sl < e < tp):
return "做多:须满足 止损 < 入场价 < 止盈"
if mark_at_add is not None:
m = float(mark_at_add)
if m >= tp:
return f"做多:当前价已不低于止盈,无法添加{label}"
if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m >= e:
return "做多:当前价须低于入场价(等待向上突破)"
elif d == "short":
if not (tp < e < sl):
return "做空:须满足 止盈 < 入场价 < 止损"
if mark_at_add is not None:
m = float(mark_at_add)
if m <= tp:
return f"做空:当前价已不高于止盈,无法添加{label}"
if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m <= e:
return "做空:当前价须高于入场价(等待向下跌破)"
else:
return "方向须为 long 或 short"
return None
def validate_trigger_entry_rr(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
min_rr: float,
calc_rr_ratio: Callable[..., Optional[float]],
) -> Optional[str]:
rr = calc_rr_ratio(direction, entry, stop_loss, take_profit)
if rr is None or rr <= float(min_rr):
fmt = f"{rr:.4f}" if rr is not None else "无法计算"
return f"计划盈亏比 {fmt}:1 未达要求(>{float(min_rr)}:1"
return None
def is_trigger_entry_expired(
created_at: Any,
now: datetime,
*,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> bool:
return is_false_breakout_expired(created_at, now, hours=hours)
def trigger_entry_expires_at_text(
created_at: Any,
*,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> str:
return expires_at_text(created_at, hours=hours)
def count_pending_trigger_entries(conn: Any, trading_day: str) -> int:
td = (trading_day or "").strip()
if not td:
return 0
placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
row = conn.execute(
f"SELECT COUNT(*) FROM key_monitors WHERE monitor_type IN ({placeholders}) AND session_date=?",
(*TRIGGER_ENTRY_MONITOR_TYPES, td),
).fetchone()
return int(row[0] if row else 0)
def check_trigger_entry_intent_limit(
conn: Any,
trading_day: str,
opens_today: int,
hard_limit: int,
) -> tuple[bool, str]:
"""当日开仓意图:已成交次数 + 待触发触价条数。"""
if int(hard_limit) <= 0:
return True, ""
pending = count_pending_trigger_entries(conn, trading_day)
total = int(opens_today) + pending
if total >= int(hard_limit):
return (
False,
f"本交易日开仓意图已达上限(已开 {int(opens_today)} + 待触发 {pending} / 硬上限 {int(hard_limit)}",
)
return True, ""
def trigger_entry_gate_preview(
*,
monitor_type: Optional[str] = None,
entry_display: str,
take_profit_display: str,
created_at: Any = None,
now: Optional[datetime] = None,
expired: bool = False,
tp_invalidated: bool = False,
sl_invalidated: bool = False,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> dict[str, Any]:
now_dt = now or datetime.now()
is_exp = expired or is_trigger_entry_expired(created_at, now_dt, hours=hours)
exp_txt = trigger_entry_expires_at_text(created_at, hours=hours)
mt = normalize_trigger_entry_monitor_type(monitor_type)
if tp_invalidated:
status = "止盈侧失效"
elif sl_invalidated:
status = "止损侧失效"
elif is_exp:
status = "已过期"
elif mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE:
status = "突破待触发"
else:
status = "回调待触发"
mode = "突破" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调"
metrics_parts: list[str] = [f"TP:{take_profit_display}"]
if exp_txt != "":
metrics_parts.append(f"截至:{exp_txt}")
return {
"summary": f"{mode}触价 E={entry_display} {status}",
"metrics": " ".join(metrics_parts),
"gate_ok": not is_exp and not tp_invalidated and not sl_invalidated,
}
# 兼容旧 import
TRIGGER_ENTRY_MONITOR_TYPE = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
KEY_ENTRY_REASON_TRIGGER = KEY_ENTRY_REASON_CALLBACK
+22
View File
@@ -0,0 +1,22 @@
"""Repository path helpers for lib/ assets."""
from __future__ import annotations
from pathlib import Path
LIB_DIR = Path(__file__).resolve().parent
REPO_ROOT = LIB_DIR.parent
def strategy_templates_dir(repo_root: str | Path | None = None) -> str:
root = Path(repo_root) if repo_root is not None else REPO_ROOT
return str(root / "lib" / "strategy" / "templates")
def embed_templates_dir(repo_root: str | Path | None = None) -> str:
root = Path(repo_root) if repo_root is not None else REPO_ROOT
return str(root / "lib" / "instance" / "templates")
def common_static_dir(repo_root: str | Path | None = None) -> str:
root = Path(repo_root) if repo_root is not None else REPO_ROOT
return str(root / "lib" / "common" / "static")
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+230
View File
@@ -0,0 +1,230 @@
"""各交易所 app 模块 → strategy_register 配置(统一工厂)。"""
from __future__ import annotations
import sys
from typing import Any
def resolve_trading_app_module(app_module: Any = None) -> Any:
"""
须在 login_required 定义之后调用
PM2 / python app.py __name__ __main__请传入 sys.modules[__name__]
"""
if app_module is None:
main = sys.modules.get("__main__")
if main is not None and hasattr(main, "login_required"):
m = main
else:
import inspect
m = None
for fr in inspect.stack():
g = fr.frame.f_globals
if callable(g.get("login_required")) and callable(g.get("get_db")):
m = g
break
if m is None:
raise RuntimeError(
"策略交易注册失败:请使用 install_strategy_trading(app, repo_root, app_module=sys.modules[__name__])"
)
else:
m = app_module
if not hasattr(m, "login_required"):
raise RuntimeError(
"策略交易注册须在 login_required 定义之后执行(将 install_strategy_trading 放在 app.py 末尾)"
)
return m
def build_strategy_config(
app_module: Any = None, *, trend_enabled: bool = False, trend_disabled_note: str = ""
) -> dict:
m = resolve_trading_app_module(app_module)
def get_trading_capital_usdt(conn):
if hasattr(m, "get_exchange_capitals"):
_, tc = m.get_exchange_capitals(force=True)
if tc is not None:
return float(tc)
if hasattr(m, "get_available_trading_usdt"):
snap = m.get_available_trading_usdt()
if snap is not None:
return float(snap)
day = m.get_trading_day(m.app_now())
row = m.ensure_session(conn, day)
return float(row["current_capital"])
def get_position(ex_sym, direction):
qty = m.get_live_position_contracts(ex_sym, direction)
entry = None
try:
rows = m.exchange.fetch_positions([ex_sym])
for p in rows or []:
matcher = getattr(m, "_row_matches_monitor_direction", None)
if matcher and not matcher(direction, p):
continue
contracts = getattr(m, "_position_row_effective_contracts", lambda x: abs(float(x.get("contracts") or 0)))(p)
if contracts <= 0:
continue
coerce = getattr(m, "_coerce_float", None)
if coerce:
entry = coerce(
p.get("entryPrice"),
p.get("average"),
(p.get("info") or {}).get("entryPrice"),
)
if entry:
break
except Exception:
pass
return {"contracts": float(qty or 0), "entry_price": entry}
def amount_to_precision(ex_sym, amount):
try:
return float(m.exchange.amount_to_precision(ex_sym, float(amount)))
except Exception:
return None
def price_to_precision(ex_sym, price):
try:
return float(m.exchange.price_to_precision(ex_sym, float(price)))
except Exception:
return None
def market_add(ex_sym, direction, amount, leverage):
return m.place_exchange_order(ex_sym, direction, amount, leverage, stop_loss=None, take_profit=None)
def limit_add(ex_sym, direction, amount, price, leverage):
m.exchange.set_leverage(int(leverage), ex_sym)
side = "buy" if direction == "long" else "sell"
if hasattr(m, "build_okx_order_params"):
params = m.build_okx_order_params(direction, reduce_only=False)
elif hasattr(m, "build_binance_order_params"):
params = m.build_binance_order_params(direction, reduce_only=False)
elif hasattr(m, "build_gate_order_params"):
params = m.build_gate_order_params(direction, reduce_only=False)
else:
params = {}
return m.exchange.create_order(
ex_sym, "limit", side, float(amount), float(price), params if params is not None else {}
)
def replace_tpsl(ex_sym, direction, sl, tp, order_row):
row = order_row or {"symbol": ex_sym, "exchange_symbol": ex_sym, "direction": direction}
m.replace_active_monitor_tpsl_on_exchange(row, sl, tp)
def count_trends(conn):
try:
return int(
conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
)
except Exception:
return 0
def friendly_error(err):
fn = getattr(m, "friendly_exchange_error", None) or getattr(
m, "friendly_okx_error", None
)
if not callable(fn):
return str(err)
try:
snap = m.get_available_trading_usdt()
except Exception:
snap = None
try:
return fn(err, available_usdt=snap)
except TypeError:
return fn(err)
def limit_order_status(ex_sym, order_id):
fn = getattr(m, "fib_limit_order_status", None)
if callable(fn):
return fn(ex_sym, order_id)
return "unknown"
def cancel_limit_order(ex_sym, order_id):
fn = getattr(m, "cancel_fib_limit_order", None)
if callable(fn):
try:
return fn(ex_sym, order_id)
except Exception:
pass
if not order_id:
return False
try:
m.exchange.cancel_order(str(order_id), ex_sym)
return True
except Exception:
return False
def get_mark_price(symbol):
fn = getattr(m, "get_symbol_mark_price", None) or getattr(m, "get_price", None)
if not callable(fn):
return None
try:
return fn(symbol)
except Exception:
return None
def wechat_account_label():
fn = getattr(m, "_wechat_account_label", None)
if callable(fn):
try:
return fn()
except Exception:
pass
return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or ""
def wechat_direction_text(direction):
fn = getattr(m, "_wechat_direction_text", None)
if callable(fn):
try:
return fn(direction)
except Exception:
pass
d = (direction or "long").strip().lower()
return "做多" if d == "long" else "做空"
def send_wechat(content):
fn = getattr(m, "send_wechat_msg", None)
if callable(fn):
fn(content)
note = trend_disabled_note or (
"趋势回调(自动补仓)请在 Gate 趋势机器人实例使用:/strategy/trend"
)
return {
"app_module": m,
"exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""),
"trend_enabled": trend_enabled,
"trend_disabled_note": note,
"login_required": m.login_required,
"get_db": m.get_db,
"normalize_symbol_input": m.normalize_symbol_input,
"normalize_exchange_symbol": m.normalize_exchange_symbol,
"get_price": m.get_price,
"get_trading_capital_usdt": get_trading_capital_usdt,
"get_position": get_position,
"amount_to_precision": amount_to_precision,
"price_to_precision": price_to_precision,
"market_add": market_add,
"limit_add": limit_add,
"replace_tpsl": replace_tpsl,
"ensure_live_ready": m.ensure_exchange_live_ready,
"default_risk_percent": float(getattr(m, "RISK_PERCENT", 2)),
"default_leverage": m.infer_leverage,
"friendly_error": friendly_error,
"app_now_str": m.app_now_str,
"resolve_fill_price": m.resolve_order_entry_price,
"price_fmt": m.format_price_for_symbol,
"count_active_trend_plans": count_trends if trend_enabled else count_trends,
"limit_order_status": limit_order_status,
"cancel_limit_order": cancel_limit_order,
"get_mark_price": get_mark_price,
"send_wechat": send_wechat,
"format_price": getattr(m, "format_price_for_symbol", None),
"wechat_account_label": wechat_account_label,
"wechat_direction_text": wechat_direction_text,
}
+164
View File
@@ -0,0 +1,164 @@
"""策略交易相关表结构(各所 crypto.db 共用 schema)。"""
ROLL_GROUPS_SQL = """
CREATE TABLE IF NOT EXISTS roll_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_monitor_id INTEGER,
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL,
initial_take_profit REAL,
initial_stop_loss REAL,
current_stop_loss REAL,
risk_percent REAL DEFAULT 2,
leg_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
created_at TEXT,
updated_at TEXT
)
"""
ROLL_LEGS_SQL = """
CREATE TABLE IF NOT EXISTS roll_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
roll_group_id INTEGER NOT NULL,
leg_index INTEGER NOT NULL,
add_mode TEXT NOT NULL,
fib_upper REAL,
fib_lower REAL,
limit_price REAL,
fill_price REAL,
amount REAL,
new_stop_loss REAL,
exchange_order_id TEXT,
status TEXT DEFAULT 'filled',
created_at TEXT,
FOREIGN KEY (roll_group_id) REFERENCES roll_groups(id)
)
"""
TREND_PLANS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT DEFAULT 'active',
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL DEFAULT 'long',
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL DEFAULT 5,
snapshot_available_usdt REAL,
snapshot_at TEXT,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER DEFAULT 5,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
legs_done INTEGER DEFAULT 0,
first_order_done INTEGER DEFAULT 0,
last_mark_price REAL,
avg_entry_price REAL,
order_amount_open REAL,
opened_at TEXT,
opened_at_ms INTEGER,
session_date TEXT,
message TEXT,
initial_stop_loss REAL,
breakeven_applied INTEGER DEFAULT 0,
breakeven_applied_at TEXT
)
"""
TREND_PREVIEWS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_previews (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
created_at TEXT
)
"""
TREND_PREVIEW_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
preview_id TEXT NOT NULL UNIQUE,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
preview_created_at TEXT,
outcome TEXT DEFAULT 'open',
executed_plan_id INTEGER
)
"""
def init_strategy_tables(conn) -> None:
from lib.strategy.strategy_snapshot_lib import init_strategy_snapshot_table
conn.execute(ROLL_GROUPS_SQL)
conn.execute(ROLL_LEGS_SQL)
conn.execute(TREND_PLANS_SQL)
conn.execute(TREND_PREVIEWS_SQL)
conn.execute(TREND_PREVIEW_SNAPSHOTS_SQL)
init_strategy_snapshot_table(conn)
for ddl in (
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER",
"ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER",
"ALTER TABLE order_monitors ADD COLUMN trend_plan_id INTEGER",
"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT",
"ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT",
"ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
):
try:
conn.execute(ddl)
except Exception:
pass
+48
View File
@@ -0,0 +1,48 @@
"""交易所策略适配器接口(各所 app 注入 ccxt 实现)。"""
from __future__ import annotations
from typing import Any, Optional, Protocol
class StrategyExchangeAdapter(Protocol):
exchange_key: str
def normalize_symbol(self, raw: str) -> str: ...
def normalize_exchange_symbol(self, symbol: str) -> str: ...
def get_mark_price(self, symbol: str) -> Optional[float]: ...
def get_position(self, exchange_symbol: str, direction: str) -> dict[str, Any]:
"""返回 {contracts, entry_price, leverage?}。"""
...
def amount_to_precision(self, exchange_symbol: str, amount: float) -> Optional[float]: ...
def price_to_precision(self, exchange_symbol: str, price: float) -> Optional[float]: ...
def market_add(
self, exchange_symbol: str, direction: str, amount: float, leverage: int
) -> dict[str, Any]: ...
def limit_add(
self,
exchange_symbol: str,
direction: str,
amount: float,
price: float,
leverage: int,
) -> dict[str, Any]: ...
def cancel_order(self, exchange_symbol: str, order_id: str) -> None: ...
def replace_position_tpsl(
self,
exchange_symbol: str,
direction: str,
stop_loss: float,
take_profit: float,
order_monitor_row: Any = None,
) -> None: ...
def ensure_live_ready(self) -> tuple[bool, str]: ...
@@ -0,0 +1,4 @@
"""Binance USDT-M 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+9
View File
@@ -0,0 +1,9 @@
"""
Gate.io USDT 永续 策略交易交易所侧能力
实现方式 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入
ccxt 下单精度 TP/SL本文件为文档与类型锚点避免在四个 app 重复实现滚仓公式
"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+4
View File
@@ -0,0 +1,4 @@
"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+72
View File
@@ -0,0 +1,72 @@
"""策略交易记录页:已结束趋势 / 顺势加仓快照(四所统一)。"""
from __future__ import annotations
import json
from typing import Any
from flask import flash, redirect, url_for
from lib.strategy.strategy_snapshot_lib import (
STRATEGY_SNAPSHOTS_MAX_ROWS,
dedupe_strategy_snapshots,
list_strategy_snapshots_split,
)
def load_strategy_records_page(
conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS
) -> dict[str, Any]:
try:
if dedupe_strategy_snapshots(conn):
conn.commit()
except Exception:
pass
trend, roll, symbols = list_strategy_snapshots_split(conn, limit=limit)
return {
"strategy_trend_records": trend,
"strategy_roll_records": roll,
"strategy_record_symbols": symbols,
"strategy_records_limit": limit,
"strategy_snapshots": trend + roll,
}
def register_strategy_records(app, cfg: dict[str, Any]) -> None:
login_required = cfg["login_required"]
get_db = cfg["get_db"]
def _lr(f):
return login_required(f)
@_lr
@app.route("/strategy/records")
def strategy_records_page():
m = cfg.get("app_module")
fn = getattr(m, "render_main_page", None)
if not callable(fn):
flash("render_main_page 未配置")
return redirect(url_for("strategy_trading_page"))
return fn("strategy_records")
@_lr
@app.route("/strategy/records/<int:snap_id>")
def strategy_records_detail(snap_id: int):
conn = get_db()
row = conn.execute(
"SELECT * FROM strategy_trade_snapshots WHERE id=?",
(int(snap_id),),
).fetchone()
conn.close()
if not row:
flash("未找到该策略快照")
return redirect(url_for("strategy_records_page"))
try:
snap = json.loads(row["snapshot_json"] or "{}")
except Exception:
snap = {}
dca = snap.get("dca_levels") or []
flash(
f"快照 #{snap_id} {row['strategy_type']} {row['symbol']} "
f"{row['result_label']} · 补仓档 {len(dca)} 项(详情见列表页)"
)
return redirect(url_for("strategy_records_page"))
+621
View File
@@ -0,0 +1,621 @@
"""策略交易:Flask 路由注册(顺势加仓 + 趋势回调页)。逻辑在 strategy_*_lib。"""
from __future__ import annotations
from lib.paths import strategy_templates_dir
import html as html_module
import os
import re
from typing import Any, Optional
from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
from jinja2 import ChoiceLoader, FileSystemLoader
from lib.strategy.strategy_db import init_strategy_tables
from lib.strategy.strategy_roll_lib import BREAKOUT_MODE, FIB_MODES, MARKET_MODE, preview_roll
from lib.strategy.strategy_roll_monitor_lib import (
cancel_roll_pending_leg,
count_filled_roll_legs,
count_pending_roll_legs,
sync_roll_after_external_close,
)
def _dedupe_strategy_snapshots_on_startup(cfg: dict[str, Any]) -> None:
"""启动时清理历史重复快照(同计划同结果仅保留最新一条)。"""
get_db = cfg.get("get_db")
if not callable(get_db):
return
try:
from lib.strategy.strategy_snapshot_lib import dedupe_strategy_snapshots
conn = get_db()
try:
removed = dedupe_strategy_snapshots(conn)
if removed:
conn.commit()
print(
f"[strategy] deduped {removed} duplicate strategy_trade_snapshots",
flush=True,
)
finally:
conn.close()
except Exception as e:
print(f"[strategy] snapshot dedupe skipped: {e}", flush=True)
def install_strategy_trading(app: Flask, repo_root: str, app_module: Any = None, **build_kw) -> None:
"""在 app.py 末尾调用(login_required 已定义后)。仅注册 POST API;页面由各 app 的 render_main_page 渲染。"""
from lib.strategy.strategy_config import build_strategy_config
build_kw.pop("render_trend_page", None)
attach_strategy_templates(app, repo_root)
cfg = build_strategy_config(app_module, **build_kw)
register_strategy_trading(app, cfg)
from lib.strategy.strategy_records_register import register_strategy_records
register_strategy_records(app, cfg)
app.extensions["strategy_roll_cfg"] = cfg
_dedupe_strategy_snapshots_on_startup(cfg)
def attach_strategy_templates(app: Flask, repo_root: str) -> None:
strat_dir = strategy_templates_dir(repo_root)
if not os.path.isdir(strat_dir):
return
existing = app.jinja_loader
loaders = [FileSystemLoader(strat_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_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None:
"""cfg 由各市面 app 注入回调(仅 API / DB 差异)。"""
login_required = cfg["login_required"]
def _lr(f):
return login_required(f)
@_lr
@app.route("/strategy/roll/preview", methods=["POST"])
def strategy_roll_preview():
data = request.get_json(silent=True) or request.form
err = _roll_preview_response(cfg, data, json_mode=request.is_json)
if request.is_json:
return jsonify(err)
if err.get("ok"):
p = err["preview"]
flash(
f"预览:约 {p.get('add_amount_display', '-')} 张,"
f"合并均价 {p.get('avg_entry_after', '-')}"
f"打到止损约 {p.get('loss_at_sl_usdt', '-')}U"
)
else:
flash(err.get("msg") or "预览失败")
return redirect(url_for("strategy_trading_page"))
@_lr
@app.route("/strategy/roll/execute", methods=["POST"])
def strategy_roll_execute():
data = request.form
try:
ok, msg = _roll_execute(cfg, data)
except Exception as e:
fe = cfg.get("friendly_error")
msg = fe(e) if callable(fe) else str(e)
ok = False
flash(msg)
return redirect(url_for("strategy_trading_page"))
@_lr
@app.route("/strategy/roll/cancel/<int:leg_id>", methods=["POST"])
def strategy_roll_cancel_leg(leg_id: int):
conn = cfg["get_db"]()
try:
init_strategy_tables(conn)
ok, msg = cancel_roll_pending_leg(cfg, conn, leg_id)
finally:
conn.close()
if request.is_json:
return jsonify({"ok": ok, "msg": msg})
flash(msg)
return redirect(url_for("strategy_trading_page"))
@_lr
@app.route("/strategy/roll/docs")
def strategy_roll_docs():
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "顺势加仓滚仓说明.md")
if not os.path.isfile(path):
flash("滚仓说明文档不存在")
return redirect(url_for("strategy_trading_page"))
with open(path, encoding="utf-8") as f:
raw = f.read()
return render_template(
"strategy_roll_docs.html",
doc_html=_roll_doc_markdown_to_html(raw),
exchange_display=cfg.get("exchange_display") or "",
)
def _roll_doc_markdown_to_html(text: str) -> str:
"""轻量 Markdown → HTML(仅供滚仓说明页)。"""
lines = text.splitlines()
out: list[str] = []
i = 0
in_code = False
code_buf: list[str] = []
def flush_code() -> None:
nonlocal code_buf
if code_buf:
out.append(
"<pre><code>"
+ html_module.escape("\n".join(code_buf))
+ "</code></pre>"
)
code_buf = []
def inline_md(s: str) -> str:
s = html_module.escape(s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
return s
while i < len(lines):
line = lines[i]
if line.strip().startswith("```"):
if in_code:
in_code = False
flush_code()
else:
in_code = True
i += 1
continue
if in_code:
code_buf.append(line)
i += 1
continue
if line.startswith("# "):
out.append(f"<h1>{inline_md(line[2:].strip())}</h1>")
elif line.startswith("## "):
out.append(f"<h2>{inline_md(line[3:].strip())}</h2>")
elif line.startswith("### "):
out.append(f"<h3>{inline_md(line[4:].strip())}</h3>")
elif line.strip() == "---":
out.append("<hr>")
elif line.startswith("|") and "|" in line[1:]:
rows: list[str] = []
while i < len(lines) and lines[i].startswith("|"):
rows.append(lines[i])
i += 1
if len(rows) >= 2 and re.match(r"^\|[\s\-:|]+\|$", rows[1].strip()):
out.append("<table>")
hdr = [c.strip() for c in rows[0].strip("|").split("|")]
out.append("<tr>" + "".join(f"<th>{inline_md(c)}</th>" for c in hdr) + "</tr>")
for row in rows[2:]:
cells = [c.strip() for c in row.strip("|").split("|")]
out.append("<tr>" + "".join(f"<td>{inline_md(c)}</td>" for c in cells) + "</tr>")
out.append("</table>")
continue
elif re.match(r"^[-*]\s+", line):
out.append("<ul>")
while i < len(lines) and re.match(r"^[-*]\s+", lines[i]):
item = re.sub(r"^[-*]\s+", "", lines[i])
out.append(f"<li>{inline_md(item)}</li>")
i += 1
out.append("</ul>")
continue
elif line.strip():
out.append(f"<p>{inline_md(line.strip())}</p>")
i += 1
flush_code()
return "\n".join(out)
def _row_to_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def _count_active_trends(conn, cfg: dict) -> int:
fn = cfg.get("count_active_trend_plans")
if callable(fn):
return int(fn(conn) or 0)
try:
return int(
conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
)
except Exception:
return 0
def _risk_from_monitor(mon: dict, cfg: dict) -> tuple[Optional[float], Optional[str]]:
try:
rp = float(mon.get("risk_percent") or cfg.get("default_risk_percent", 2))
except (TypeError, ValueError):
return None, "监控单风险%无效"
if rp <= 0:
return None, "监控单风险%须大于0"
return rp, None
def _contract_size(cfg: dict, ex_sym: str) -> float:
get_cs = cfg.get("get_contract_size")
if callable(get_cs):
try:
return float(get_cs(ex_sym) or 1.0)
except Exception:
pass
return 1.0
def _roll_context(cfg: dict, data: dict) -> tuple[Optional[dict], Optional[str]]:
m = cfg.get("app_module")
if m is not None:
try:
from lib.trade.position_sizing_lib import OPEN_SOURCE_ROLL, assert_open_source_allowed
mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk"
ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL)
if not ok_src:
return None, src_msg
except Exception:
pass
get_db = cfg["get_db"]
symbol = cfg["normalize_symbol_input"](data.get("symbol") or "")
if not symbol:
return None, "请选择或填写币种"
direction = (data.get("direction") or "long").strip().lower()
ex_sym = cfg["normalize_exchange_symbol"](symbol)
conn = get_db()
init_strategy_tables(conn)
if _count_active_trends(conn, cfg) > 0:
conn.close()
return None, "存在运行中的趋势回调计划,请先结束后再滚仓"
mon = _get_active_monitor(conn, cfg, symbol, direction)
if not mon:
conn.close()
return None, "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓"
rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
if pending > 0:
conn.close()
return None, "已有监控中的滚仓腿,请等待成交/失效或先删除后再提交"
conn_cap = get_db()
try:
capital = float(cfg["get_trading_capital_usdt"](conn_cap))
finally:
conn_cap.close()
risk_pct, risk_err = _risk_from_monitor(mon, cfg)
if risk_err:
conn.close()
return None, risk_err
pos = cfg["get_position"](ex_sym, direction)
qty = float(pos.get("contracts") or 0)
if qty <= 0:
conn.close()
return None, "交易所无该方向持仓,无法滚仓"
entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0)
if entry <= 0:
conn.close()
return None, "无法获取持仓均价"
mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
mark = mark_fn(symbol) if callable(mark_fn) else cfg["get_price"](symbol)
ctx = {
"conn": conn,
"mon": mon,
"rg": rg,
"legs_done": legs_done,
"symbol": symbol,
"direction": direction,
"ex_sym": ex_sym,
"qty": qty,
"entry": entry,
"mark": float(mark) if mark else None,
"capital": capital,
"risk_pct": float(risk_pct),
"tp0": float(mon.get("take_profit") or rg.get("initial_take_profit") or 0),
"contract_size": _contract_size(cfg, ex_sym),
}
return ctx, None
def _parse_roll_form(data: dict, ctx: dict) -> tuple[Optional[dict], Optional[str]]:
add_mode = (data.get("add_mode") or MARKET_MODE).strip().lower()
raw_sl = data.get("new_stop_loss") or data.get("sl")
if raw_sl in (None, ""):
return None, "请填写新止损价"
try:
new_sl = float(raw_sl)
except (TypeError, ValueError):
return None, "止损价格式错误"
if new_sl <= 0:
return None, "止损价须大于0"
fib_u = fib_l = bp = None
try:
if data.get("fib_upper") not in (None, ""):
fib_u = float(data.get("fib_upper"))
if data.get("fib_lower") not in (None, ""):
fib_l = float(data.get("fib_lower"))
if data.get("breakthrough_price") not in (None, ""):
bp = float(data.get("breakthrough_price"))
except (TypeError, ValueError):
return None, "价格参数格式错误"
add_price = ctx.get("mark")
if add_mode == MARKET_MODE:
if add_price is None or add_price <= 0:
return None, "无法获取市价快照"
elif add_mode in FIB_MODES:
if fib_u is None or fib_l is None:
return None, "斐波须填写上沿 H 与下沿 L"
elif add_mode == BREAKOUT_MODE:
if bp is None:
return None, "突破加仓须填写突破价"
add_price = ctx.get("mark")
else:
return None, "加仓方式无效"
return {
"add_mode": add_mode,
"new_stop_loss": new_sl,
"fib_upper": fib_u,
"fib_lower": fib_l,
"breakthrough_price": bp,
"add_price": add_price,
}, None
def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict:
ctx, err = _roll_context(cfg, data)
if err:
return {"ok": False, "msg": err}
parsed, perr = _parse_roll_form(data, ctx)
if perr:
ctx["conn"].close()
return {"ok": False, "msg": perr}
conn = ctx["conn"]
try:
preview, perr2 = preview_roll(
direction=ctx["direction"],
symbol=ctx["symbol"],
qty_existing=ctx["qty"],
entry_existing=ctx["entry"],
initial_take_profit=ctx["tp0"],
add_mode=parsed["add_mode"],
new_stop_loss=parsed["new_stop_loss"],
risk_percent=ctx["risk_pct"],
capital_base_usdt=ctx["capital"],
add_price=parsed["add_price"],
fib_upper=parsed["fib_upper"],
fib_lower=parsed["fib_lower"],
breakthrough_price=parsed["breakthrough_price"],
legs_done=ctx["legs_done"],
contract_size=ctx["contract_size"],
)
finally:
conn.close()
if perr2:
return {"ok": False, "msg": perr2}
amt_raw = float(preview["add_amount_raw"])
amt_p = cfg["amount_to_precision"](ctx["ex_sym"], amt_raw)
preview["add_amount_display"] = amt_p if amt_p is not None else amt_raw
preview["risk_display"] = f"{ctx['risk_pct']:g}%≈{ctx['capital'] * ctx['risk_pct'] / 100:.2f}U"
price_fmt = cfg.get("price_fmt")
if callable(price_fmt):
preview["add_price_display"] = price_fmt(ctx["symbol"], preview["add_price"])
preview["new_sl_display"] = price_fmt(ctx["symbol"], preview["new_stop_loss"])
preview["tp_display"] = price_fmt(ctx["symbol"], preview["initial_take_profit"])
return {"ok": True, "preview": preview}
def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
get_db = cfg["get_db"]
conn = None
try:
ok_live, reason = cfg["ensure_live_ready"]()
if not ok_live:
return False, reason or "实盘未就绪"
prev = _roll_preview_response(cfg, data)
if not prev.get("ok"):
return False, prev.get("msg") or "预览失败"
preview = prev["preview"]
symbol = cfg["normalize_symbol_input"](data.get("symbol") or "")
direction = preview["direction"]
ex_sym = cfg["normalize_exchange_symbol"](symbol)
add_mode = preview["add_mode"]
new_sl = float(preview["new_stop_loss"])
tp0 = float(preview["initial_take_profit"])
lev_fn = cfg.get("default_leverage")
if not callable(lev_fn):
lev_fn = lambda _s: 5
leverage = int(data.get("leverage") or 0) or int(lev_fn(symbol))
conn = get_db()
init_strategy_tables(conn)
mon = _get_active_monitor(conn, cfg, symbol, direction)
if not mon:
return False, "监控单已不存在"
rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
if pending > 0:
return False, "已有监控中的滚仓腿,请先删除或等待结束"
if add_mode == MARKET_MODE:
amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"]))
if amount is None or amount <= 0:
return False, "加仓张数低于交易所最小精度"
order = cfg["market_add"](ex_sym, direction, amount, leverage)
fill = float(
cfg.get("resolve_fill_price", lambda o, s, p: p)(
order, ex_sym, preview["add_price"]
)
or preview["add_price"]
)
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
cfg["replace_tpsl"](ex_sym, direction, new_sl, tp0, mon)
conn.execute(
"""INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price,
breakthrough_price, fill_price, amount, new_stop_loss, exchange_order_id,
status, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
rg["id"],
legs_done + 1,
preview["add_mode_label"],
preview.get("fib_upper"),
preview.get("fib_lower"),
None,
preview.get("breakthrough_price"),
fill,
amount,
new_sl,
oid,
"filled",
cfg["app_now_str"](),
),
)
conn.execute(
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(legs_done + 1, new_sl, cfg["app_now_str"](), rg["id"]),
)
conn.execute(
"UPDATE order_monitors SET stop_loss=? WHERE id=?",
(new_sl, mon["id"]),
)
conn.commit()
_maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new)
return True, f"市价加仓第 {legs_done + 1} 腿已成交,止损已更新,止盈仍为首仓"
# 程序监控:斐波 / 突破
limit_px = None
if add_mode in FIB_MODES:
px_fn = cfg.get("price_to_precision")
limit_px = float(preview["add_price"])
if callable(px_fn):
limit_px = float(px_fn(ex_sym, limit_px) or limit_px)
mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
last_mark = mark_fn(symbol) if callable(mark_fn) else preview["add_price"]
conn.execute(
"""INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price,
breakthrough_price, new_stop_loss, last_mark_price, status, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
rg["id"],
legs_done + 1,
preview["add_mode_label"],
preview.get("fib_upper"),
preview.get("fib_lower"),
limit_px,
preview.get("breakthrough_price"),
new_sl,
last_mark,
"pending",
cfg["app_now_str"](),
),
)
conn.commit()
_maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new)
return True, f"已提交{preview['add_mode_label']}监控,触价后将市价加仓并更新止损"
except Exception as e:
fe = cfg.get("friendly_error")
return False, fe(e) if callable(fe) else str(e)
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
def _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, *, roll_is_new: bool) -> None:
if not roll_is_new:
return
try:
from lib.strategy.strategy_wechat_notify import notify_roll_group_started
notify_roll_group_started(
cfg,
group_id=int(rg["id"]),
symbol=symbol,
direction=direction,
order_monitor_id=int(mon["id"]),
initial_take_profit=tp0,
initial_stop_loss=float(mon.get("stop_loss") or new_sl),
)
except Exception:
pass
def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optional[dict]:
row = conn.execute(
"SELECT * FROM order_monitors WHERE status='active' AND symbol=? AND direction=? ORDER BY id DESC LIMIT 1",
(symbol, direction),
).fetchone()
return _row_to_dict(row) if row else None
def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, int, bool]:
"""返回 (roll_group, filled_legs, pending_legs, is_new_group)。"""
row = conn.execute(
"SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active' ORDER BY id DESC LIMIT 1",
(mon["id"],),
).fetchone()
if row:
d = _row_to_dict(row)
gid = int(d["id"])
filled = count_filled_roll_legs(conn, gid)
pending = count_pending_roll_legs(conn, gid)
return d, filled, pending, False
now = mon.get("created_at") or ""
cur = conn.execute(
"""INSERT INTO roll_groups (
order_monitor_id, symbol, exchange_symbol, direction,
initial_take_profit, initial_stop_loss, current_stop_loss,
risk_percent, leg_count, status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
mon["id"],
mon["symbol"],
mon.get("exchange_symbol"),
mon["direction"],
mon.get("take_profit"),
mon.get("stop_loss"),
mon.get("stop_loss"),
mon.get("risk_percent") or 2,
0,
"active",
now,
now,
),
)
gid = int(cur.lastrowid)
return (
{
"id": gid,
"leg_count": 0,
"initial_take_profit": mon.get("take_profit"),
"initial_stop_loss": mon.get("stop_loss"),
"symbol": mon.get("symbol"),
"direction": mon.get("direction"),
},
0,
0,
True,
)
def roll_sync_after_external_close(cfg: dict, conn, symbol: str, direction: str) -> dict:
"""供 hub / del_order 调用的滚仓同步入口。"""
return sync_roll_after_external_close(
cfg, conn, symbol, direction, reason="手动平仓,滚仓监控已结束"
)
+385
View File
@@ -0,0 +1,385 @@
"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;程序监控触价市价成交。"""
from __future__ import annotations
from typing import Any, Optional, Tuple
from lib.key_monitor.fib_key_monitor_lib import calc_fib_plan, fib_invalidate_by_mark
ROLL_MAX_LEGS_LONG = 3
ROLL_MAX_LEGS_SHORT = 3
MARKET_MODE = "market"
FIB_MODES = frozenset({"fib_618", "fib_786"})
BREAKOUT_MODE = "breakout"
MODE_LABELS = {
MARKET_MODE: "市价加仓",
"fib_618": "斐波0.618",
"fib_786": "斐波0.786",
BREAKOUT_MODE: "突破加仓",
}
def fib_ratio_from_mode(mode: str) -> Optional[float]:
m = (mode or "").strip().lower()
if m in ("fib_618", "618", "0.618"):
return 0.618
if m in ("fib_786", "786", "0.786"):
return 0.786
return None
def mode_label(mode: str) -> str:
m = (mode or MARKET_MODE).strip().lower()
return MODE_LABELS.get(m, m)
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
"""H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧。"""
ratio = fib_ratio_from_mode(mode)
if ratio is None:
return None, "斐波档位无效"
h, l = float(upper), float(lower)
if h <= l:
return None, "上沿须大于下沿"
direction = (direction or "long").strip().lower()
if direction == "short":
plan = calc_fib_plan("short", h, l, ratio)
else:
plan = calc_fib_plan("long", h, l, ratio)
if not plan:
return None, "无法计算斐波限价"
entry, _sl, _tp = plan
return float(entry), None
def max_roll_legs(direction: str) -> int:
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
def avg_entry_after_add(
qty_existing: float,
entry_existing: float,
add_qty: float,
add_price: float,
) -> float:
q1 = float(qty_existing)
e1 = float(entry_existing)
q2 = float(add_qty)
e2 = float(add_price)
total = q1 + q2
if total <= 0:
return 0.0
return (q1 * e1 + q2 * e2) / total
def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float:
return float(capital_base_usdt) * (float(risk_percent) / 100.0)
def solve_add_amount_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""
合并持仓打到 new_stop 时总亏损 risk_budget方案 C
long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL)
short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2)
"""
try:
q1 = float(qty_existing)
e1 = float(entry_existing)
e2 = float(add_price)
sl = float(new_stop)
b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower()
if direction == "short":
denom = sl - e2
numer = b / cs - q1 * (sl - e1)
if denom <= 0:
return None, "做空:新止损须高于加仓价"
else:
denom = e2 - sl
numer = b / cs - q1 * (e1 - sl)
if denom <= 0:
return None, "做多:新止损须低于加仓价"
q2 = numer / denom
if q2 <= 0:
return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)"
return q2, None
def loss_at_stop_usdt(
direction: str,
avg: float,
qty: float,
stop: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(stop) - float(avg)) * float(qty) * cs
return (float(avg) - float(stop)) * float(qty) * cs
def reward_at_tp_usdt(
direction: str,
avg: float,
take_profit: float,
qty: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(avg) - float(take_profit)) * float(qty) * cs
return (float(take_profit) - float(avg)) * float(qty) * cs
def roll_fib_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
limit_price: float,
) -> bool:
"""斐波:多=向下穿越限价;空=向上穿越限价。"""
try:
m = float(mark)
lv = float(limit_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m <= lv
return pm > lv and m <= lv
if pm is None:
return m >= lv
return pm < lv and m >= lv
def roll_breakout_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
breakthrough_price: float,
) -> bool:
"""突破:多=向上穿越突破价;空=向下穿越突破价。"""
try:
m = float(mark)
bp = float(breakthrough_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m > bp
return pm <= bp and m > bp
if pm is None:
return m < bp
return pm >= bp and m < bp
def roll_fib_invalidate(direction: str, mark: float, upper: float, lower: float) -> bool:
"""斐波 pending 失效:止盈侧突破(多 mark>=H;空 mark<=L)。"""
return fib_invalidate_by_mark(direction, mark, upper, lower)
def roll_breakout_invalidate(direction: str, mark: float, stop_loss: float) -> bool:
"""突破 pending 失效:未到突破价先触达止损侧(多 mark<=S;空 mark>=S)。"""
try:
m = float(mark)
sl = float(stop_loss)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
return m <= sl
return m >= sl
def validate_roll_geometry(
direction: str,
add_mode: str,
*,
new_stop_loss: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
entry_existing: float = 0.0,
initial_take_profit: float = 0.0,
mark_price: Optional[float] = None,
) -> Optional[str]:
direction = (direction or "long").strip().lower()
mode = (add_mode or MARKET_MODE).strip().lower()
try:
sl = float(new_stop_loss)
tp = float(initial_take_profit)
e1 = float(entry_existing or 0)
except (TypeError, ValueError):
return "止损/止盈格式错误"
if sl <= 0 or tp <= 0:
return "止损与首仓止盈须大于0"
if direction == "long":
if e1 > 0 and tp <= e1:
return "做多:首仓止盈须高于当前持仓均价"
else:
if e1 > 0 and tp >= e1:
return "做空:首仓止盈须低于当前持仓均价"
if mode == MARKET_MODE:
if add_price is None or float(add_price) <= 0:
return "市价加仓需要有效参考价"
entry_add = float(add_price)
elif mode in FIB_MODES:
if fib_upper is None or fib_lower is None:
return "斐波须填写上沿 H 与下沿 L"
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return err
if entry_add is None or entry_add <= 0:
return "无法计算斐波限价"
elif mode == BREAKOUT_MODE:
if breakthrough_price is None:
return "突破加仓须填写突破价"
try:
bp = float(breakthrough_price)
except (TypeError, ValueError):
return "突破价格式错误"
if bp <= 0:
return "突破价须大于0"
entry_add = bp
if direction == "long":
if sl >= bp:
return "做多:止损须低于突破价"
if mark_price is not None and float(mark_price) >= bp:
return "做多:当前价须低于突破价(等待向上突破)"
else:
if sl <= bp:
return "做空:止损须高于突破价"
if mark_price is not None and float(mark_price) <= bp:
return "做空:当前价须高于突破价(等待向下跌破)"
else:
return "加仓方式无效"
if mode != BREAKOUT_MODE:
entry_add = float(entry_add) # type: ignore[arg-type]
if direction == "long":
if sl >= entry_add:
return "做多:新止损须低于加仓价"
else:
if sl <= entry_add:
return "做空:新止损须高于加仓价"
return None
def preview_roll(
*,
direction: str,
symbol: str,
qty_existing: float,
entry_existing: float,
initial_take_profit: float,
add_mode: str,
new_stop_loss: Optional[float] = None,
risk_percent: float,
capital_base_usdt: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
legs_done: int = 0,
contract_size: float = 1.0,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or MARKET_MODE).strip().lower()
if new_stop_loss is None:
return None, "请填写新止损价"
try:
sl = float(new_stop_loss)
except (TypeError, ValueError):
return None, "止损价格式错误"
if sl <= 0:
return None, "止损须大于0"
geom_err = validate_roll_geometry(
direction,
mode,
new_stop_loss=sl,
add_price=add_price,
fib_upper=fib_upper,
fib_lower=fib_lower,
breakthrough_price=breakthrough_price,
entry_existing=entry_existing,
initial_take_profit=initial_take_profit,
mark_price=add_price if mode == BREAKOUT_MODE else add_price,
)
if geom_err:
return None, geom_err
if mode == MARKET_MODE:
entry_add = float(add_price) # validated
elif mode in FIB_MODES:
entry_add, _ = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
entry_add = float(entry_add or 0)
else:
entry_add = float(breakthrough_price or 0)
risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent)
q2_raw, err = solve_add_amount_for_total_risk(
direction,
qty_existing,
entry_existing,
entry_add,
sl,
risk_budget,
contract_size,
)
if err:
return None, err
q2 = float(q2_raw)
new_qty = qty_existing + q2
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
cs = float(contract_size or 1.0)
loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs)
reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs)
return {
"symbol": symbol,
"direction": direction,
"add_mode": mode,
"add_mode_label": mode_label(mode),
"add_price": round(entry_add, 10),
"new_stop_loss": round(sl, 10),
"breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None,
"initial_take_profit": float(initial_take_profit),
"risk_percent": float(risk_percent),
"risk_budget_usdt": round(risk_budget, 4),
"add_amount_raw": q2,
"qty_existing": float(qty_existing),
"entry_existing": float(entry_existing),
"qty_after": new_qty,
"avg_entry_after": round(new_avg, 10),
"loss_at_sl_usdt": round(loss_sl, 4),
"reward_at_tp_usdt": round(reward_tp, 4),
"legs_done": int(legs_done),
"leg_index_next": int(legs_done) + 1,
"fib_upper": fib_upper,
"fib_lower": fib_lower,
"contract_size": cs,
}, None
+520
View File
@@ -0,0 +1,520 @@
"""滚仓程序监控:斐波/突破触价市价成交、失效、外部平仓同步(各所共用)。"""
from __future__ import annotations
from typing import Any, Optional
from lib.strategy.strategy_roll_lib import (
BREAKOUT_MODE,
FIB_MODES,
MARKET_MODE,
mode_label,
roll_breakout_invalidate,
roll_breakout_trigger_crossed,
roll_fib_invalidate,
roll_fib_trigger_crossed,
calc_risk_budget_usdt,
max_roll_legs,
preview_roll,
solve_add_amount_for_total_risk,
)
from lib.strategy.strategy_db import init_strategy_tables
ROLL_LEG_STATUS_LABELS = {
"pending": "监控中",
"filled": "已成交",
"cancelled": "已删除",
"invalidated": "已失效",
}
def roll_leg_status_label(status: Optional[str]) -> str:
s = (status or "").strip().lower()
return ROLL_LEG_STATUS_LABELS.get(s, status or "")
def check_roll_monitors(cfg: dict[str, Any]) -> None:
get_db = cfg["get_db"]
conn = get_db()
try:
init_strategy_tables(conn)
_reconcile_roll_groups(conn, cfg)
_check_pending_roll_legs(conn, cfg)
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
def sync_roll_after_external_close(
cfg: dict, conn, symbol: str, direction: str, *, reason: str = "持仓已平"
) -> dict[str, Any]:
"""中控/实例手动平仓后:取消 pending 腿并关闭 active 滚仓组(保留 filled 历史)。"""
norm = cfg.get("normalize_symbol_input")
sym = norm(symbol) if callable(norm) else (symbol or "").strip()
if not sym:
return {"ok": False, "msg": "symbol 无效", "closed_groups": 0, "cancelled_legs": 0}
direction = (direction or "long").strip().lower()
init_strategy_tables(conn)
rows = conn.execute(
"""SELECT g.* FROM roll_groups g
WHERE g.status='active' AND g.symbol=? AND g.direction=?""",
(sym, direction),
).fetchall()
closed = cancelled = 0
for row in rows:
g = _row_dict(row)
cancelled += _cancel_pending_legs_for_group(conn, cfg, g, status="cancelled")
cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), int(g["id"])),
)
if getattr(cur, "rowcount", 0):
closed += 1
try:
from lib.strategy.strategy_wechat_notify import notify_roll_group_ended
notify_roll_group_ended(
cfg,
group_id=int(g["id"]),
symbol=sym,
direction=direction,
reason=reason,
leg_count=int(g.get("leg_count") or 0),
)
except Exception:
pass
try:
from lib.strategy.strategy_snapshot_lib import save_roll_group_snapshot
save_roll_group_snapshot(cfg, conn, g, result_label="结束")
except Exception:
pass
return {
"ok": True,
"symbol": sym,
"direction": direction,
"closed_groups": closed,
"cancelled_legs": cancelled,
}
def cancel_roll_pending_leg(cfg: dict, conn, leg_id: int) -> tuple[bool, str]:
"""用户删除 pending 滚仓腿(不可修改,仅删除)。"""
init_strategy_tables(conn)
row = conn.execute(
"SELECT l.*, g.symbol, g.direction, g.status AS group_status FROM roll_legs l "
"INNER JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.id=?",
(int(leg_id),),
).fetchone()
if not row:
return False, "滚仓腿不存在"
leg = _row_dict(row)
if (leg.get("status") or "").strip().lower() != "pending":
return False, "仅监控中的腿可删除"
_cancel_roll_leg_order(cfg, {"symbol": leg.get("symbol"), "exchange_symbol": leg.get("exchange_symbol")}, leg)
conn.execute(
"UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'",
(int(leg_id),),
)
conn.commit()
return True, "已删除滚仓监控"
def count_filled_roll_legs(conn, roll_group_id: int) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='filled'",
(int(roll_group_id),),
).fetchone()
return int(row[0] if row else 0)
def count_pending_roll_legs(conn, roll_group_id: int) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(int(roll_group_id),),
).fetchone()
return int(row[0] if row else 0)
def _row_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def _now(cfg: dict) -> str:
fn = cfg.get("app_now_str")
return fn() if callable(fn) else ""
def _cancel_pending_legs_for_group(conn, cfg: dict, group: dict, *, status: str = "cancelled") -> int:
gid = int(group["id"])
n = 0
for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(gid,),
).fetchall():
ld = _row_dict(leg)
_cancel_roll_leg_order(cfg, group, ld)
conn.execute(
"UPDATE roll_legs SET status=? WHERE id=? AND status='pending'",
(status, ld["id"]),
)
n += 1
return n
def _close_roll_group(conn, cfg: dict, group: dict, *, reason: str = "下单监控已结案或交易所无同向持仓") -> None:
gid = int(group["id"])
_cancel_pending_legs_for_group(conn, cfg, group, status="cancelled")
cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), gid),
)
if getattr(cur, "rowcount", 0):
try:
from lib.strategy.strategy_wechat_notify import notify_roll_group_ended
notify_roll_group_ended(
cfg,
group_id=gid,
symbol=group.get("symbol") or "",
direction=group.get("direction") or "long",
reason=reason,
leg_count=int(group.get("leg_count") or 0),
)
except Exception:
pass
try:
from lib.strategy.strategy_snapshot_lib import save_roll_group_snapshot
save_roll_group_snapshot(cfg, conn, group, result_label="结束")
except Exception:
pass
def _reconcile_roll_groups(conn, cfg: dict) -> None:
rows = conn.execute(
"""SELECT g.*, m.status AS monitor_status
FROM roll_groups g
LEFT JOIN order_monitors m ON m.id = g.order_monitor_id
WHERE g.status='active'"""
).fetchall()
for row in rows:
g = _row_dict(row)
symbol = g.get("symbol") or ""
direction = (g.get("direction") or "long").strip().lower()
ex_sym = g.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol)
mon_ok = (row["monitor_status"] or "").strip().lower() == "active"
pos = cfg["get_position"](ex_sym, direction)
qty = float(pos.get("contracts") or 0)
if not mon_ok or qty <= 0:
_close_roll_group(conn, cfg, g)
def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None:
oid = (leg.get("exchange_order_id") or "").strip()
if not oid:
return
symbol = group.get("symbol") or ""
ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol)
cancel = cfg.get("cancel_limit_order")
if callable(cancel):
try:
cancel(ex_sym, oid)
except Exception:
pass
def _contract_size(cfg: dict, ex_sym: str) -> float:
get_cs = cfg.get("get_contract_size")
if callable(get_cs):
try:
return float(get_cs(ex_sym) or 1.0)
except Exception:
pass
return 1.0
def _resolve_add_mode(leg: dict) -> str:
raw = (leg.get("add_mode") or "").strip().lower()
if raw in (MARKET_MODE, "market", "市价", "市价加仓"):
return MARKET_MODE
if "786" in raw or raw == "fib_786":
return "fib_786"
if "618" in raw or raw == "fib_618":
return "fib_618"
if raw in (BREAKOUT_MODE, "突破", "突破加仓"):
return BREAKOUT_MODE
if raw.startswith("fib"):
return raw.replace(".", "_").replace("0.", "0")
return raw or MARKET_MODE
def _check_pending_roll_legs(conn, cfg: dict) -> None:
rows = conn.execute(
"""SELECT l.*, g.symbol, g.exchange_symbol, g.direction, g.initial_take_profit,
g.order_monitor_id, g.risk_percent, g.leg_count
FROM roll_legs l
INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active'
WHERE l.status='pending'"""
).fetchall()
for row in rows:
leg = _row_dict(row)
group = {
"id": leg["roll_group_id"],
"symbol": leg["symbol"],
"exchange_symbol": leg["exchange_symbol"],
"direction": leg["direction"],
"initial_take_profit": leg["initial_take_profit"],
"order_monitor_id": leg["order_monitor_id"],
"risk_percent": leg.get("risk_percent"),
"leg_count": leg.get("leg_count"),
}
_process_pending_roll_leg(conn, cfg, group, leg)
def _process_pending_roll_leg(conn, cfg: dict, group: dict, leg: dict) -> None:
symbol = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol)
mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
mark = mark_fn(symbol) if callable(mark_fn) else None
if mark is None:
return
mark_f = float(mark)
prev_mark = leg.get("last_mark_price")
try:
prev_f = float(prev_mark) if prev_mark not in (None, "") else None
except (TypeError, ValueError):
prev_f = None
mode = _resolve_add_mode(leg)
sl = float(leg.get("new_stop_loss") or 0)
fib_u, fib_l = leg.get("fib_upper"), leg.get("fib_lower")
bp = leg.get("breakthrough_price")
if mode in FIB_MODES and fib_u is not None and fib_l is not None:
if roll_fib_invalidate(direction, mark_f, float(fib_u), float(fib_l)):
_invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止盈侧突破")
return
elif mode == BREAKOUT_MODE and sl > 0:
if roll_breakout_invalidate(direction, mark_f, sl):
_invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止损侧突破")
return
triggered = False
if mode in FIB_MODES:
lp = leg.get("limit_price")
if lp is not None and roll_fib_trigger_crossed(direction, prev_f, mark_f, float(lp)):
triggered = True
elif mode == BREAKOUT_MODE and bp is not None:
if roll_breakout_trigger_crossed(direction, prev_f, mark_f, float(bp)):
triggered = True
conn.execute(
"UPDATE roll_legs SET last_mark_price=? WHERE id=? AND status='pending'",
(mark_f, int(leg["id"])),
)
if triggered:
_execute_pending_roll_leg(conn, cfg, group, leg, ex_sym, direction, mark_f)
return
def _execute_pending_roll_leg(
conn,
cfg: dict,
group: dict,
leg: dict,
ex_sym: str,
direction: str,
mark: float,
) -> None:
leg_id = int(leg["id"])
gid = int(group["roll_group_id"]) if "roll_group_id" in leg else int(group["id"])
mon_id = group.get("order_monitor_id")
mon = None
if mon_id:
row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone()
mon = _row_dict(row) if row else None
if not mon or (mon.get("status") or "").strip().lower() != "active":
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="监控单已失效")
return
pos = cfg["get_position"](ex_sym, direction) or {}
qty = float(pos.get("contracts") or 0)
entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0)
if qty <= 0 or entry <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="无持仓")
return
filled = count_filled_roll_legs(conn, gid)
if filled >= max_roll_legs(direction):
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="滚仓次数已满")
return
try:
risk_pct = float(mon.get("risk_percent") or group.get("risk_percent") or 2)
except (TypeError, ValueError):
risk_pct = 2.0
conn_cap = cfg["get_db"]()
try:
capital = float(cfg["get_trading_capital_usdt"](conn_cap))
finally:
conn_cap.close()
cs = _contract_size(cfg, ex_sym)
sl = float(leg.get("new_stop_loss") or 0)
tp0 = float(group.get("initial_take_profit") or mon.get("take_profit") or 0)
mode = _resolve_add_mode(leg)
q2_raw, err = solve_add_amount_for_total_risk(
direction, qty, entry, mark, sl, calc_risk_budget_usdt(capital, risk_pct), cs
)
if err or q2_raw is None or float(q2_raw) <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason=err or "无法计算加仓张数")
return
amount = cfg["amount_to_precision"](ex_sym, float(q2_raw))
if amount is None or float(amount) <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="加仓张数低于交易所最小精度")
return
lev_fn = cfg.get("default_leverage")
if not callable(lev_fn):
lev_fn = lambda _s: 5
leverage = int(lev_fn(group.get("symbol") or ""))
try:
order = cfg["market_add"](ex_sym, direction, float(amount), leverage)
fill = float(
cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, mark) or mark
)
except Exception as e:
fe = cfg.get("friendly_error")
msg = fe(e) if callable(fe) else str(e)
_notify_roll_fail(cfg, group, leg, mark, msg)
return
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
cfg["replace_tpsl"](ex_sym, direction, sl, tp0, mon)
conn.execute(
"""UPDATE roll_legs SET status='filled', fill_price=?, amount=?, exchange_order_id=?,
new_stop_loss=? WHERE id=? AND status='pending'""",
(fill, float(amount), oid, sl, leg_id),
)
conn.execute(
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(filled + 1, sl, _now(cfg), gid),
)
conn.execute(
"UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'",
(sl, mon["id"]),
)
notify = cfg.get("send_wechat")
if callable(notify):
sym = group.get("symbol") or ""
mode_lbl = leg.get("add_mode") or mode_label(mode)
fmt = cfg.get("format_price")
px_txt = fmt(sym, fill) if callable(fmt) else str(fill)
sl_txt = fmt(sym, sl) if callable(fmt) else str(sl)
acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction)
notify(
f"# ✅ {sym} 滚仓触价成交\n"
f"**账户:{acct}**\n"
f"- 方式:{mode_lbl}{dir_txt}\n"
f"- 成交价:{px_txt}|张数:{amount}\n"
f"- 新止损:{sl_txt}(止盈仍为首仓)\n"
)
def _invalidate_roll_leg(
conn,
cfg: dict,
group: dict,
leg: dict,
mark: float,
*,
reason: str = "",
) -> None:
leg_id = int(leg["id"])
cur = conn.execute("SELECT status FROM roll_legs WHERE id=?", (leg_id,)).fetchone()
if not cur or (cur[0] or "").strip().lower() in ("invalidated", "filled", "cancelled"):
return
_cancel_roll_leg_order(cfg, group, leg)
conn.execute(
"UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'",
(leg_id,),
)
_send_roll_invalidate_wechat(cfg, group, leg, mark, reason=reason)
def _notify_roll_fail(cfg: dict, group: dict, leg: dict, mark: float, reason: str) -> None:
notify = cfg.get("send_wechat")
if not callable(notify):
return
sym = group.get("symbol") or ""
mode = leg.get("add_mode") or "滚仓"
acct = _wechat_account(cfg)
notify(
f"# ❌ {sym} 滚仓触价成交失败\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}\n"
f"- 原因:{reason}\n"
)
def _send_roll_invalidate_wechat(
cfg: dict, group: dict, leg: dict, mark: float, *, reason: str = ""
) -> None:
notify = cfg.get("send_wechat")
if not callable(notify):
return
sym = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
mode = leg.get("add_mode") or "滚仓监控"
fmt = cfg.get("format_price")
mark_txt = fmt(sym, mark) if callable(fmt) else str(mark)
acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction)
detail = reason or "条件不满足"
notify(
f"# ⚠️ {sym} 滚仓监控失效\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}{dir_txt}\n"
f"- 标记价 {mark_txt}{detail}\n"
f"- 本条监控已结案,可重新提交\n"
)
def _wechat_account(cfg: dict) -> str:
fn = cfg.get("wechat_account_label")
if callable(fn):
try:
return str(fn())
except Exception:
pass
return str(cfg.get("exchange_display") or "")
def _wechat_dir(cfg: dict, direction: str) -> str:
fn = cfg.get("wechat_direction_text")
if callable(fn):
try:
return str(fn(direction))
except Exception:
pass
return "做多" if (direction or "long").strip().lower() == "long" else "做空"
+402
View File
@@ -0,0 +1,402 @@
"""顺势加仓 UI:滚仓腿合并均价与止盈盈利展示(实例页 + 中控)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
from flask import Flask
FILLED_LEG_STATUSES = frozenset({"filled", "done", "complete"})
def reward_at_tp_usdt(
direction: str,
avg_entry: float,
take_profit: float,
qty: float,
*,
contract_size: float = 1.0,
) -> Optional[float]:
"""与 strategy_roll_lib.preview_roll 一致:线性合约 U 本位盈利。"""
try:
avg = float(avg_entry)
tp = float(take_profit)
q = float(qty)
cs = float(contract_size or 1.0)
except (TypeError, ValueError):
return None
if avg <= 0 or tp <= 0 or q <= 0:
return None
direction = (direction or "long").strip().lower()
if direction == "short":
return (avg - tp) * q * cs
return (tp - avg) * q * cs
def leg_fill_price(leg: dict) -> Optional[float]:
if not isinstance(leg, dict):
return None
for key in ("fill_price", "limit_price"):
try:
v = float(leg.get(key) or 0)
if v > 0:
return v
except (TypeError, ValueError):
continue
return None
def leg_is_filled(leg: dict) -> bool:
st = str(leg.get("status") or "").strip().lower()
return st in FILLED_LEG_STATUSES
def infer_initial_position(
qty_live: float,
entry_live: float,
filled_legs: list[dict],
*,
monitor: dict | None = None,
) -> tuple[Optional[float], Optional[float]]:
"""由当前持仓与各腿成交价反推首仓张数/均价。"""
try:
qty_live = float(qty_live)
entry_live = float(entry_live)
except (TypeError, ValueError):
qty_live = entry_live = 0.0
legs = [
lg
for lg in filled_legs or []
if isinstance(lg, dict) and leg_is_filled(lg) and leg_fill_price(lg) and float(lg.get("amount") or 0) > 0
]
add_sum = sum(float(lg.get("amount") or 0) for lg in legs)
leg_notional = sum(float(lg.get("amount") or 0) * float(leg_fill_price(lg) or 0) for lg in legs)
q0 = qty_live - add_sum
if q0 > 1e-12 and entry_live > 0 and qty_live > 0:
e0 = (entry_live * qty_live - leg_notional) / q0
if e0 > 0:
return q0, e0
mon = monitor if isinstance(monitor, dict) else {}
try:
trig = float(mon.get("trigger_price") or 0)
except (TypeError, ValueError):
trig = 0.0
try:
mon_amt = float(mon.get("order_amount") or mon.get("amount") or 0)
except (TypeError, ValueError):
mon_amt = 0.0
if trig > 0:
q_base = q0 if q0 > 1e-12 else (mon_amt if mon_amt > 0 else max(qty_live - add_sum, 0))
if q_base > 0:
return q_base, trig
return None, None
def compute_roll_chain_metrics(
group: dict,
legs: list[dict],
*,
qty_live: Optional[float] = None,
entry_live: Optional[float] = None,
monitor: dict | None = None,
contract_size: float = 1.0,
) -> tuple[dict[Any, dict], dict]:
"""
返回 (leg_metrics_by_id, group_metrics)
leg_metrics: leg id -> {avg_entry_after, reward_at_tp_usdt}
group_metrics: 最后一腿后的 {avg_entry, reward_at_tp_usdt}
"""
per_leg: dict[Any, dict] = {}
group_out: dict[str, Any] = {"avg_entry": None, "reward_at_tp_usdt": None}
if not isinstance(group, dict):
return per_leg, group_out
direction = (group.get("direction") or "long").strip().lower()
try:
tp = float(group.get("initial_take_profit") or 0)
except (TypeError, ValueError):
tp = 0.0
sorted_legs = sorted(
[lg for lg in legs or [] if isinstance(lg, dict)],
key=lambda x: int(x.get("leg_index") or 0),
)
filled = [lg for lg in sorted_legs if leg_is_filled(lg)]
q0 = e0 = None
if qty_live is not None and entry_live is not None:
q0, e0 = infer_initial_position(float(qty_live), float(entry_live), filled, monitor=monitor)
if q0 is None or e0 is None:
return per_leg, group_out
qty = float(q0)
avg = float(e0)
if tp > 0:
group_out["avg_entry"] = avg
group_out["reward_at_tp_usdt"] = reward_at_tp_usdt(
direction, avg, tp, qty, contract_size=contract_size
)
for leg in sorted_legs:
if not leg_is_filled(leg):
continue
try:
amt = float(leg.get("amount") or 0)
except (TypeError, ValueError):
continue
px = leg_fill_price(leg)
if not px or amt <= 0:
continue
prev_qty = qty
qty = prev_qty + amt
avg = (prev_qty * avg + amt * px) / qty
reward = reward_at_tp_usdt(direction, avg, tp, qty, contract_size=contract_size) if tp > 0 else None
lid = leg.get("id")
if lid is None:
lid = f"{group.get('id')}|{leg.get('leg_index')}"
per_leg[lid] = {
"avg_entry_after": round(avg, 10),
"reward_at_tp_usdt": round(reward, 4) if reward is not None else None,
}
group_out["avg_entry"] = round(avg, 10)
group_out["reward_at_tp_usdt"] = round(reward, 4) if reward is not None else None
return per_leg, group_out
def _row_to_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def _resolve_roll_live(cfg: dict, group: dict, monitor: dict | None) -> tuple[Optional[float], Optional[float], float]:
"""读取交易所持仓张数、均价、contract_size。"""
m = cfg.get("app_module")
ex_sym = group.get("exchange_symbol")
sym = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
if not ex_sym and m is not None:
norm = getattr(m, "normalize_exchange_symbol", None)
if callable(norm):
try:
ex_sym = norm(sym)
except Exception:
ex_sym = sym
cs = 1.0
get_cs = cfg.get("get_contract_size")
if not callable(get_cs) and m is not None:
get_cs = getattr(m, "get_contract_size", None)
if callable(get_cs):
try:
cs = float(get_cs(ex_sym or sym) or 1.0)
except Exception:
cs = 1.0
get_pos = cfg.get("get_position")
if not callable(get_pos):
return None, None, cs
try:
pos = get_pos(ex_sym or sym, direction) or {}
qty = float(pos.get("contracts") or 0)
entry = float(pos.get("entry_price") or 0)
if qty > 0 and entry > 0:
return qty, entry, cs
except Exception:
pass
metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) if m else None
if callable(metrics_fn):
try:
met = metrics_fn(ex_sym or sym, direction)
if isinstance(met, dict):
qty = float(met.get("contracts") or met.get("size") or 0)
entry = float(met.get("entry_price") or 0)
if qty > 0 and entry > 0:
return qty, entry, cs
except Exception:
pass
if monitor:
try:
trig = float(monitor.get("trigger_price") or 0)
amt = float(monitor.get("order_amount") or monitor.get("amount") or 0)
if trig > 0 and amt > 0:
return amt, trig, cs
except (TypeError, ValueError):
pass
return None, None, cs
def enrich_roll_page_data(conn, page_data: dict, cfg: dict | None) -> dict:
"""为 roll_groups / roll_legs 附加 avg_entry、reward_at_tp 展示字段。"""
if not isinstance(page_data, dict) or not cfg:
return page_data
groups = list(page_data.get("roll_groups") or [])
legs = list(page_data.get("roll_legs") or [])
if not groups:
return page_data
monitors_by_id: dict[int, dict] = {}
try:
for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall():
od = _row_to_dict(row)
mid = od.get("id")
if mid is not None:
monitors_by_id[int(mid)] = od
except Exception:
pass
legs_by_gid: dict[int, list] = {}
for leg in legs:
if not isinstance(leg, dict):
continue
try:
gid = int(leg.get("roll_group_id"))
except (TypeError, ValueError):
continue
legs_by_gid.setdefault(gid, []).append(leg)
price_fmt = cfg.get("price_fmt")
for g in groups:
if not isinstance(g, dict) or g.get("id") is None:
continue
gid = int(g["id"])
mon = monitors_by_id.get(int(g.get("order_monitor_id") or 0))
qty, entry, cs = _resolve_roll_live(cfg, g, mon)
per_leg, group_metrics = compute_roll_chain_metrics(
g,
legs_by_gid.get(gid, []),
qty_live=qty,
entry_live=entry,
monitor=mon,
contract_size=cs,
)
g["avg_entry"] = group_metrics.get("avg_entry")
g["reward_at_tp_usdt"] = group_metrics.get("reward_at_tp_usdt")
if callable(price_fmt) and g.get("avg_entry") is not None:
try:
g["avg_entry_display"] = price_fmt(g.get("symbol"), g["avg_entry"])
except Exception:
pass
for leg in legs_by_gid.get(gid, []):
lid = leg.get("id")
if lid is None:
lid = f"{gid}|{leg.get('leg_index')}"
metrics = per_leg.get(lid) or per_leg.get(leg.get("id"))
if not metrics:
continue
leg["avg_entry_after"] = metrics.get("avg_entry_after")
leg["reward_at_tp_usdt"] = metrics.get("reward_at_tp_usdt")
if callable(price_fmt) and leg.get("avg_entry_after") is not None:
try:
leg["avg_entry_display"] = price_fmt(g.get("symbol"), leg["avg_entry_after"])
except Exception:
pass
page_data["roll_groups"] = groups
page_data["roll_legs"] = legs
return page_data
def enrich_roll_groups_for_hub(rolls: list[dict], conn, cfg: dict | None) -> list[dict]:
"""中控 monitor API:每组附带当前均价、止盈盈利与最近滚仓腿。"""
if not rolls or not cfg:
return rolls
out = []
gid_list = []
for g in rolls:
if isinstance(g, dict) and g.get("id") is not None:
try:
gid_list.append(int(g["id"]))
except (TypeError, ValueError):
pass
legs_by_gid: dict[int, list] = {gid: [] for gid in gid_list}
if gid_list:
placeholders = ",".join("?" for _ in gid_list)
try:
rows = conn.execute(
f"SELECT * FROM roll_legs WHERE roll_group_id IN ({placeholders}) ORDER BY id DESC",
gid_list,
).fetchall()
for row in rows:
leg = _row_to_dict(row)
try:
legs_by_gid[int(leg.get("roll_group_id"))].append(leg)
except (TypeError, ValueError):
pass
except Exception:
pass
monitors_by_id: dict[int, dict] = {}
try:
for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall():
od = _row_to_dict(row)
if od.get("id") is not None:
monitors_by_id[int(od["id"])] = od
except Exception:
pass
price_fmt = cfg.get("price_fmt")
for g in rolls:
if not isinstance(g, dict):
continue
gd = dict(g)
try:
gid = int(gd.get("id"))
except (TypeError, ValueError):
out.append(gd)
continue
mon = monitors_by_id.get(int(gd.get("order_monitor_id") or 0))
group_legs = legs_by_gid.get(gid, [])
qty, entry, cs = _resolve_roll_live(cfg, gd, mon)
per_leg, group_metrics = compute_roll_chain_metrics(
gd,
group_legs,
qty_live=qty,
entry_live=entry,
monitor=mon,
contract_size=cs,
)
gd.update(group_metrics)
if callable(price_fmt) and gd.get("avg_entry") is not None:
try:
gd["avg_entry_display"] = price_fmt(gd.get("symbol"), gd["avg_entry"])
except Exception:
pass
recent = []
for leg in sorted(group_legs, key=lambda x: int(x.get("leg_index") or 0), reverse=True)[:6]:
ld = dict(leg)
lid = ld.get("id")
if lid is None:
lid = f"{gid}|{ld.get('leg_index')}"
metrics = per_leg.get(lid) or per_leg.get(ld.get("id"))
if metrics:
ld.update(metrics)
if callable(price_fmt) and ld.get("avg_entry_after") is not None:
try:
ld["avg_entry_display"] = price_fmt(gd.get("symbol"), ld["avg_entry_after"])
except Exception:
pass
recent.append(ld)
gd["recent_legs"] = recent
out.append(gd)
return out
def patch_roll_hub_enrich(app: Flask, cfg: dict) -> None:
"""hub_bridge install 后:/api/hub/monitor 的 rolls 附带均价/止盈盈利。"""
ctx = dict(app.config.get("HUB_CTX") or {})
prev: Callable | None = ctx.get("enrich_monitor")
def enrich_monitor(keys=None, orders=None, trends=None, rolls=None):
payload: dict[str, Any] = {}
if callable(prev):
try:
prev_out = prev(keys=keys, orders=orders, trends=trends, rolls=rolls)
if isinstance(prev_out, dict):
payload.update(prev_out)
except Exception:
pass
if rolls:
get_db = cfg.get("get_db")
if callable(get_db):
conn = get_db()
try:
payload["rolls"] = enrich_roll_groups_for_hub(list(rolls), conn, cfg)
finally:
try:
conn.close()
except Exception:
pass
return payload
ctx["enrich_monitor"] = enrich_monitor
app.config["HUB_CTX"] = ctx
+529
View File
@@ -0,0 +1,529 @@
"""策略结束快照:趋势回调 / 顺势加仓(四所共用)。"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Callable, Optional
STRATEGY_TREND = "trend_pullback"
STRATEGY_ROLL = "roll"
STRATEGY_SNAPSHOTS_MAX_ROWS = 100
# 同一趋势计划只允许一条「结束类」快照(中控全平 + 监控止损 + 实例结束计划)
FINAL_TREND_CLOSE_RANK = {
"手动平仓": 3,
"止盈": 2,
"止损": 1,
}
FINAL_TREND_CLOSE_LABELS = tuple(FINAL_TREND_CLOSE_RANK.keys())
STRATEGY_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS strategy_trade_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_type TEXT NOT NULL,
source_id INTEGER,
symbol TEXT,
exchange_symbol TEXT,
direction TEXT,
result_label TEXT,
status_at_close TEXT,
opened_at TEXT,
closed_at TEXT,
pnl_amount REAL,
snapshot_json TEXT NOT NULL,
created_at TEXT
)
"""
def init_strategy_snapshot_table(conn) -> None:
conn.execute(STRATEGY_SNAPSHOTS_SQL)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_closed "
"ON strategy_trade_snapshots(closed_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_type "
"ON strategy_trade_snapshots(strategy_type, source_id)"
)
def _row_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def _json_dumps(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def build_trend_dca_levels(plan: dict) -> list[dict]:
"""首仓 + 补仓档位列表(供策略页 / 中控)。"""
out: list[dict] = []
p = plan or {}
try:
legs_done = int(p.get("legs_done") or 0)
except (TypeError, ValueError):
legs_done = 0
try:
dca_legs = int(p.get("dca_legs") or 0)
except (TypeError, ValueError):
dca_legs = 0
first_done = int(p.get("first_order_done") or 0) != 0
try:
grid = json.loads(p.get("grid_prices_json") or "[]")
if not isinstance(grid, list):
grid = []
except Exception:
grid = []
try:
leg_amounts = json.loads(p.get("leg_amounts_json") or "[]")
if not isinstance(leg_amounts, list):
leg_amounts = []
except Exception:
leg_amounts = []
out.append(
{
"i": 0,
"leg_key": "first",
"label": "首仓",
"price": None,
"contracts": p.get("first_order_amount"),
"status": "done" if first_done else "pending",
"status_label": "已开仓" if first_done else "待开仓",
}
)
n = max(len(grid), len(leg_amounts), dca_legs)
for idx in range(n):
leg_i = idx + 1
price = grid[idx] if idx < len(grid) else None
contracts = leg_amounts[idx] if idx < len(leg_amounts) else None
done = leg_i <= legs_done
out.append(
{
"i": leg_i,
"leg_key": f"dca_{leg_i}",
"label": f"补仓{leg_i}",
"price": price,
"contracts": contracts,
"status": "done" if done else "pending",
"status_label": "已补仓" if done else "待补仓",
}
)
return out
def attach_trend_dca_levels(plan: dict) -> dict:
from lib.strategy.strategy_trend_lib import enrich_trend_dca_levels_with_tp
d = dict(plan or {})
levels = build_trend_dca_levels(d)
d["dca_levels"] = enrich_trend_dca_levels_with_tp(d, levels)
return d
def _snapshot_key_exists(
conn, strategy_type: str, source_id: int, result_label: str
) -> bool:
if source_id <= 0:
return False
label = (result_label or "").strip()
row = conn.execute(
"""SELECT 1 FROM strategy_trade_snapshots
WHERE strategy_type=? AND source_id=? AND result_label=?
LIMIT 1""",
(strategy_type, int(source_id), label),
).fetchone()
return row is not None
def _final_trend_close_rank(result_label: str) -> int:
return int(FINAL_TREND_CLOSE_RANK.get((result_label or "").strip(), 0))
def _purge_weaker_trend_final_snapshots(
conn, plan_id: int, result_label: str
) -> None:
"""写入更高优先级结束快照时,删除同计划较弱的结束记录。"""
rank = _final_trend_close_rank(result_label)
if rank <= 0 or plan_id <= 0:
return
for label, lr in FINAL_TREND_CLOSE_RANK.items():
if lr < rank:
conn.execute(
"""DELETE FROM strategy_trade_snapshots
WHERE strategy_type=? AND source_id=? AND result_label=?""",
(STRATEGY_TREND, int(plan_id), label),
)
def dedupe_strategy_snapshots(conn) -> int:
"""删除重复快照:同结果去重 + 同计划仅保留最高优先级结束类记录。"""
init_strategy_snapshot_table(conn)
removed = 0
cur = conn.execute(
"""DELETE FROM strategy_trade_snapshots
WHERE id IN (
SELECT s1.id FROM strategy_trade_snapshots s1
INNER JOIN strategy_trade_snapshots s2
ON s1.strategy_type = s2.strategy_type
AND s1.source_id = s2.source_id
AND s1.result_label = s2.result_label
AND s1.id < s2.id
)"""
)
removed += int(getattr(cur, "rowcount", 0) or 0)
rows = conn.execute(
f"""SELECT id, source_id, result_label FROM strategy_trade_snapshots
WHERE strategy_type=? AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""",
(STRATEGY_TREND, *FINAL_TREND_CLOSE_LABELS),
).fetchall()
by_plan: dict[int, list] = {}
for row in rows:
d = _row_dict(row)
try:
pid = int(d.get("source_id") or 0)
except (TypeError, ValueError):
pid = 0
if pid <= 0:
continue
by_plan.setdefault(pid, []).append(d)
drop_ids: list[int] = []
for snaps in by_plan.values():
if len(snaps) <= 1:
continue
best = max(
snaps,
key=lambda s: (
_final_trend_close_rank(str(s.get("result_label") or "")),
int(s.get("id") or 0),
),
)
keep_id = int(best.get("id") or 0)
for s in snaps:
sid = int(s.get("id") or 0)
if sid and sid != keep_id:
drop_ids.append(sid)
if drop_ids:
placeholders = ",".join("?" * len(drop_ids))
cur2 = conn.execute(
f"DELETE FROM strategy_trade_snapshots WHERE id IN ({placeholders})",
drop_ids,
)
removed += int(getattr(cur2, "rowcount", 0) or 0)
return removed
def save_trend_plan_snapshot(
cfg: dict,
conn,
plan_row: Any,
*,
result_label: str,
exit_price: float | None = None,
pnl_amount: float | None = None,
closed_at: str | None = None,
) -> None:
init_strategy_snapshot_table(conn)
row = _row_dict(plan_row)
plan_id = int(row.get("id") or 0)
if plan_id <= 0:
return
label = (result_label or "").strip()
close_rank = _final_trend_close_rank(label)
if close_rank > 0:
existing = conn.execute(
f"""SELECT result_label FROM strategy_trade_snapshots
WHERE strategy_type=? AND source_id=? AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""",
(STRATEGY_TREND, plan_id, *FINAL_TREND_CLOSE_LABELS),
).fetchall()
for ex in existing:
ex_label = str(_row_dict(ex).get("result_label") or "")
if _final_trend_close_rank(ex_label) >= close_rank:
return
_purge_weaker_trend_final_snapshots(conn, plan_id, label)
elif _snapshot_key_exists(conn, STRATEGY_TREND, plan_id, label):
return
m = cfg.get("app_module")
close_ts = (closed_at or "").strip() or (
m.app_now_str()
if m is not None and hasattr(m, "app_now_str")
else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
)
payload = attach_trend_dca_levels(row)
payload["result_label"] = result_label
payload["exit_price"] = exit_price
payload["pnl_amount"] = pnl_amount
payload["status_at_close"] = row.get("status")
conn.execute(
"""INSERT INTO strategy_trade_snapshots (
strategy_type, source_id, symbol, exchange_symbol, direction,
result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
STRATEGY_TREND,
plan_id,
row.get("symbol"),
row.get("exchange_symbol"),
row.get("direction"),
result_label,
row.get("status"),
row.get("opened_at"),
close_ts,
pnl_amount,
_json_dumps(payload),
close_ts,
),
)
prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS)
def save_roll_group_snapshot(
cfg: dict,
conn,
group: dict,
*,
result_label: str = "结束",
pnl_amount: float | None = None,
) -> None:
init_strategy_snapshot_table(conn)
g = dict(group or {})
gid = int(g.get("id") or 0)
if gid <= 0:
return
label = (result_label or "结束").strip()
if _snapshot_key_exists(conn, STRATEGY_ROLL, gid, label):
return
legs = []
for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? ORDER BY leg_index ASC, id ASC",
(gid,),
).fetchall():
ld = _row_dict(leg)
try:
from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label
ld["status_label"] = roll_leg_status_label(ld.get("status"))
except Exception:
ld["status_label"] = ld.get("status") or ""
legs.append(ld)
m = cfg.get("app_module")
closed_at = (
m.app_now_str()
if m is not None and hasattr(m, "app_now_str")
else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
)
payload = {
"group": g,
"legs": legs,
"result_label": result_label,
"pnl_amount": pnl_amount,
}
conn.execute(
"""INSERT INTO strategy_trade_snapshots (
strategy_type, source_id, symbol, exchange_symbol, direction,
result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
STRATEGY_ROLL,
gid,
g.get("symbol"),
g.get("exchange_symbol"),
g.get("direction"),
result_label,
g.get("status"),
g.get("created_at"),
closed_at,
pnl_amount,
_json_dumps(payload),
closed_at,
),
)
prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS)
def prune_strategy_snapshots(conn, *, keep: int = STRATEGY_SNAPSHOTS_MAX_ROWS) -> None:
"""仅保留最近 keep 条策略快照(按 closed_at / id 倒序)。"""
dedupe_strategy_snapshots(conn)
k = max(1, min(int(keep), 500))
conn.execute(
"""DELETE FROM strategy_trade_snapshots
WHERE id NOT IN (
SELECT id FROM strategy_trade_snapshots
ORDER BY COALESCE(closed_at, created_at, '') DESC, id DESC
LIMIT ?
)""",
(k,),
)
def _snapshot_pnl(row: dict, snap: dict) -> float | None:
for key in ("pnl_amount",):
v = row.get(key)
if v is not None and v != "":
try:
return float(v)
except (TypeError, ValueError):
pass
v = snap.get("pnl_amount")
if v is not None and v != "":
try:
return float(v)
except (TypeError, ValueError):
pass
return None
def _trend_dca_stats(snap: dict) -> dict:
levels = snap.get("dca_levels") or build_trend_dca_levels(snap)
dca_only = [
lv
for lv in levels
if (lv.get("leg_key") or "") != "first" and (lv.get("label") or "") != "首仓"
]
done = sum(1 for lv in dca_only if lv.get("status") == "done")
total = len(dca_only)
pending = total - done
if total <= 0:
tag = "na"
elif done <= 0:
tag = "no_dca"
elif done >= total:
tag = "dca_done"
else:
tag = "dca_partial"
return {
"dca_done": done,
"dca_total": total,
"dca_pending": pending,
"dca_tag": tag,
}
def _roll_leg_stats(snap: dict) -> dict:
legs = snap.get("legs") or []
if not isinstance(legs, list):
legs = []
filled = sum(1 for lg in legs if (lg.get("status") or "").lower() == "filled")
total = len(legs)
pending = total - filled
if total <= 0:
tag = "na"
elif filled <= 0:
tag = "no_dca"
elif filled >= total:
tag = "dca_done"
else:
tag = "dca_partial"
return {
"dca_done": filled,
"dca_total": total,
"dca_pending": pending,
"dca_tag": tag,
}
def enrich_strategy_snapshot_row(row: dict) -> dict:
d = dict(row or {})
snap = d.get("snapshot") or {}
st = (d.get("strategy_type") or "").strip()
pnl = _snapshot_pnl(d, snap)
if pnl is not None:
if pnl > 1e-9:
d["filter_pnl"] = "profit"
elif pnl < -1e-9:
d["filter_pnl"] = "loss"
else:
d["filter_pnl"] = "flat"
else:
d["filter_pnl"] = "unknown"
snap_sym = ""
if isinstance(snap, dict):
snap_sym = (snap.get("symbol") or snap.get("exchange_symbol") or "").strip()
sym = (d.get("symbol") or d.get("exchange_symbol") or snap_sym or "").strip()
if sym:
d["symbol"] = d.get("symbol") or sym
d["exchange_symbol"] = d.get("exchange_symbol") or sym
d["filter_symbol"] = sym.upper().split("/")[0].split(":")[0] if sym else ""
closed = (d.get("closed_at") or d.get("created_at") or "").strip()
d["sort_ts"] = closed
if st == STRATEGY_TREND:
stats = _trend_dca_stats(snap)
d.update(stats)
legs_txt = (
f"{stats['dca_done']}/{stats['dca_total']}"
if stats["dca_total"] > 0
else "0/0"
)
d["summary_dca"] = legs_txt
else:
stats = _roll_leg_stats(snap)
d.update(stats)
d["summary_dca"] = (
f"{stats['dca_done']}/{stats['dca_total']}"
if stats["dca_total"] > 0
else ""
)
return d
def list_strategy_snapshots(conn, *, limit: int = 200) -> list[dict]:
init_strategy_snapshot_table(conn)
rows = conn.execute(
"SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?",
(max(1, min(int(limit), 500)),),
).fetchall()
out = []
seen: dict[tuple[str, int, str], int] = {}
for r in rows:
d = _row_dict(r)
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except Exception:
d["snapshot"] = {}
st = (d.get("strategy_type") or "").strip()
d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓"
enriched = enrich_strategy_snapshot_row(d)
try:
source_id = int(enriched.get("source_id") or 0)
except (TypeError, ValueError):
source_id = 0
result_label = (enriched.get("result_label") or "").strip()
close_rank = _final_trend_close_rank(result_label)
if st == STRATEGY_TREND and source_id > 0 and close_rank > 0:
plan_key = (st, source_id)
snap_id = int(enriched.get("id") or 0)
prev = seen.get(plan_key)
if prev is not None:
prev_id, prev_rank = prev
if prev_rank > close_rank or (prev_rank == close_rank and prev_id >= snap_id):
continue
out = [x for x in out if int(x.get("id") or 0) != prev_id]
seen[plan_key] = (snap_id, close_rank)
out.append(enriched)
continue
key = (st, source_id, result_label)
snap_id = int(enriched.get("id") or 0)
prev = seen.get(key)
if prev is not None and prev[0] >= snap_id:
continue
if prev is not None:
out = [x for x in out if int(x.get("id") or 0) != prev[0]]
seen[key] = (snap_id, 0)
out.append(enriched)
return out
def list_strategy_snapshots_split(
conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS
) -> tuple[list[dict], list[dict], list[str]]:
"""趋势 / 顺势分组,及筛选用币种列表。"""
all_rows = list_strategy_snapshots(conn, limit=limit)
trend = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_TREND]
roll = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_ROLL]
symbols = sorted({r.get("filter_symbol") or "" for r in all_rows if r.get("filter_symbol")})
return trend, roll, symbols
+159
View File
@@ -0,0 +1,159 @@
"""策略交易写入 trade_records 时的类型与复盘开仓类型标注。"""
from __future__ import annotations
from typing import Optional
MONITOR_TYPE_TREND_PULLBACK = "趋势回调"
MONITOR_TYPE_ROLL = "顺势加仓"
ENTRY_REASON_TREND_PULLBACK = "趋势回调"
ENTRY_REASON_ROLL = "顺势加仓"
STRATEGY_ENTRY_REASON_OPTIONS = (
ENTRY_REASON_TREND_PULLBACK,
ENTRY_REASON_ROLL,
)
# 趋势回调保本移交下单监控:order_monitors.key_signal_type / 平仓备注
TREND_HANDOFF_KEY_SIGNAL = ENTRY_REASON_TREND_PULLBACK
TREND_HANDOFF_TRADE_NOTE = "趋势回调计划"
def handoff_trade_miss_reason(miss_reason, row) -> Optional[str]:
"""趋势保本移交的监控单平仓:交易记录备注带来源。"""
if trend_plan_id_from_monitor_row(row) is None:
return miss_reason
base = (miss_reason or "").strip()
if TREND_HANDOFF_TRADE_NOTE in base:
return base or TREND_HANDOFF_TRADE_NOTE
if base:
return f"{TREND_HANDOFF_TRADE_NOTE}{base}"
return TREND_HANDOFF_TRADE_NOTE
def trend_plan_id_from_monitor_row(row) -> Optional[int]:
if row is None:
return None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
if "trend_plan_id" not in keys or row["trend_plan_id"] in (None, ""):
return None
try:
tid = int(row["trend_plan_id"])
return tid if tid > 0 else None
except (TypeError, ValueError):
return None
def order_had_roll_fills(conn, order_monitor_id) -> bool:
try:
oid = int(order_monitor_id)
except (TypeError, ValueError):
return False
if oid <= 0:
return False
try:
row = conn.execute(
"""SELECT 1 FROM roll_legs l
INNER JOIN roll_groups g ON g.id = l.roll_group_id
WHERE g.order_monitor_id=? AND l.status='filled'
LIMIT 1""",
(oid,),
).fetchone()
return row is not None
except Exception:
return False
def _row_monitor_type(row, default_manual: str) -> str:
if row is None:
return default_manual
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
if "monitor_type" in keys:
mt = (row["monitor_type"] or "").strip()
if mt:
return mt
return default_manual
def _row_key_signal_type(row) -> str:
if row is None:
return ""
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
if "key_signal_type" not in keys:
return ""
return (row["key_signal_type"] or "").strip()
def order_monitor_source_type(row, *, default_manual: str = "下单监控") -> str:
"""展示/平仓记录:趋势保本移交单来源为「趋势回调」,非「下单监控」。"""
if trend_plan_id_from_monitor_row(row) is not None:
return MONITOR_TYPE_TREND_PULLBACK
mt = _row_monitor_type(row, default_manual)
if mt != default_manual:
return mt
kst = _row_key_signal_type(row)
if kst in (
MONITOR_TYPE_TREND_PULLBACK,
TREND_HANDOFF_KEY_SIGNAL,
TREND_HANDOFF_TRADE_NOTE,
ENTRY_REASON_TREND_PULLBACK,
):
return MONITOR_TYPE_TREND_PULLBACK
return mt
def apply_order_monitor_source_labels(item: dict, *, default_manual: str = "下单监控") -> dict:
"""实例页 / 中控 API:统一修正 order_monitors 展示用 monitor_type。"""
out = dict(item or {})
out["monitor_type"] = order_monitor_source_type(out, default_manual=default_manual)
return out
def trade_record_monitor_type(conn, order_row, *, default_manual: str = "下单监控") -> str:
"""平仓写入 trade_records 时:曾顺势加仓则标「顺势加仓」,否则沿用监控单来源类型。"""
oid = None
try:
keys = order_row.keys() if hasattr(order_row, "keys") else []
if "id" in keys and order_row["id"] is not None:
oid = int(order_row["id"])
except Exception:
oid = None
if oid and order_had_roll_fills(conn, oid):
return MONITOR_TYPE_ROLL
return order_monitor_source_type(order_row, default_manual=default_manual)
def entry_reason_for_monitor_type(monitor_type: str | None) -> str:
mt = (monitor_type or "").strip()
if mt == MONITOR_TYPE_TREND_PULLBACK:
return ENTRY_REASON_TREND_PULLBACK
if mt == MONITOR_TYPE_ROLL:
return ENTRY_REASON_ROLL
return ""
def order_monitor_excluded_from_position_limit(conn, row) -> bool:
"""趋势回调不计入 MAX_ACTIVE_POSITIONS;顺势加仓在已有持仓上操作,单独放行。"""
return order_monitor_source_type(row) == MONITOR_TYPE_TREND_PULLBACK
def count_position_limit_active_monitors(conn) -> int:
"""计入仓位上限冻结的活跃监控数(不含趋势回调、顺势加仓)。"""
try:
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
except Exception:
return 0
n = 0
for row in rows:
if not order_monitor_excluded_from_position_limit(conn, row):
n += 1
return n
+97
View File
@@ -0,0 +1,97 @@
"""趋势回调:各交易所止损刷新、市价加/平仓(通过 app 模块能力探测)。"""
from __future__ import annotations
import time
from typing import Any
def _m(cfg: dict) -> Any:
return cfg["app_module"]
def trend_refresh_stop_only(cfg: dict, exchange_symbol: str, direction: str, stop_loss: float) -> None:
m = _m(cfg)
if hasattr(m, "_gate_place_stop_loss_only_position"):
if hasattr(m, "cancel_gate_swap_trigger_orders"):
m.cancel_gate_swap_trigger_orders(exchange_symbol)
m._gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss)
return
if hasattr(m, "_binance_place_stop_loss_only"):
m._binance_place_stop_loss_only(exchange_symbol, direction, stop_loss)
return
if hasattr(m, "_okx_place_stop_loss_only"):
m._okx_place_stop_loss_only(exchange_symbol, direction, stop_loss)
return
raise RuntimeError("当前实例未配置趋势回调止损挂单能力")
def trend_market_add(cfg: dict, exchange_symbol: str, direction: str, contracts: float, leverage: int):
m = _m(cfg)
ex = m.exchange
m.ensure_markets_loaded()
ex.set_leverage(int(leverage), exchange_symbol)
side = "buy" if direction == "long" else "sell"
if hasattr(m, "build_gate_order_params"):
params = m.build_gate_order_params(direction, reduce_only=False)
elif hasattr(m, "build_binance_order_params"):
params = m.build_binance_order_params(direction, reduce_only=False)
elif hasattr(m, "build_okx_order_params"):
params = m.build_okx_order_params(direction, reduce_only=False)
else:
params = {}
order_params = params if params is not None else {}
return ex.create_order(exchange_symbol, "market", side, float(contracts), None, order_params)
def trend_market_close(cfg: dict, exchange_symbol: str, direction: str, pos_qty: float, leverage: int):
m = _m(cfg)
ex = m.exchange
m.ensure_markets_loaded()
ex.set_leverage(int(leverage), exchange_symbol)
side = "sell" if direction == "long" else "buy"
amt = float(ex.amount_to_precision(exchange_symbol, float(pos_qty)))
if hasattr(m, "close_exchange_order"):
row = {
"exchange_symbol": exchange_symbol,
"symbol": exchange_symbol,
"direction": direction,
"order_amount": amt,
}
return m.close_exchange_order(row)
if hasattr(m, "build_gate_order_params"):
params = m.build_gate_order_params(direction, reduce_only=True)
return ex.create_order(exchange_symbol, "market", side, amt, None, params)
if hasattr(m, "build_binance_order_params"):
for params in m._binance_market_close_param_candidates(direction):
try:
return ex.create_order(exchange_symbol, "market", side, amt, None, params)
except Exception as e:
if not m._is_binance_close_param_retryable(str(e)):
raise
raise RuntimeError("平仓失败")
if hasattr(m, "build_okx_order_params"):
params = m.build_okx_order_params(direction, reduce_only=True)
return ex.create_order(exchange_symbol, "market", side, amt, None, params)
return ex.create_order(exchange_symbol, "market", side, amt, None, {"reduceOnly": True})
def trend_replace_tpsl(cfg: dict, order_row: dict, stop_loss: float, take_profit: float) -> None:
"""趋势保本移交:先撤条件单再挂保本止损 + 计划止盈(与下单监控一致)。"""
m = _m(cfg)
fn = getattr(m, "replace_active_monitor_tpsl_on_exchange", None)
if not callable(fn):
raise RuntimeError("当前实例未配置止盈止损同步能力")
fn(order_row, float(stop_loss), float(take_profit))
def cancel_symbol_orders(cfg: dict, exchange_symbol: str) -> None:
m = _m(cfg)
if hasattr(m, "cancel_all_open_orders_for_symbol"):
m.cancel_all_open_orders_for_symbol(exchange_symbol)
return
if hasattr(m, "cancel_gate_swap_trigger_orders"):
m.cancel_gate_swap_trigger_orders(exchange_symbol)
if hasattr(m, "cancel_binance_futures_open_orders"):
m.cancel_binance_futures_open_orders(exchange_symbol)
if hasattr(m, "cancel_okx_swap_open_orders"):
m.cancel_okx_swap_open_orders(exchange_symbol)
+695
View File
@@ -0,0 +1,695 @@
"""趋势回调策略:纯计算与校验(无 ccxt / Flask)。各所 adapter 负责张数精度与下单。"""
from __future__ import annotations
import json
from typing import Any, Callable, Optional, Tuple
AmountPreciseFn = Callable[[str, float], Optional[float]]
def calc_risk_fraction(direction: str, entry_price: float, stop_loss: float) -> Optional[float]:
try:
entry = float(entry_price)
sl = float(stop_loss)
if entry <= 0 or sl <= 0:
return None
if (direction or "long").strip().lower() == "short":
risk = sl - entry
else:
risk = entry - sl
if risk <= 0:
return None
return risk / entry
except (TypeError, ValueError):
return None
def trend_effective_margin_capital(plan: dict) -> float:
"""按已开仓张数占计划总张数比例折算保证金(首仓/部分补仓时的盈亏估算)。"""
try:
plan_margin = float(plan.get("plan_margin_capital") or 0)
target = float(plan.get("target_order_amount") or 0)
open_amt = float(plan.get("order_amount_open") or 0)
except (TypeError, ValueError):
return float((plan or {}).get("plan_margin_capital") or 0)
if plan_margin <= 0:
return 0.0
if target > 0 and open_amt > 0:
return round(plan_margin * min(1.0, open_amt / target), 8)
try:
first = float(plan.get("first_order_amount") or 0)
except (TypeError, ValueError):
first = 0.0
if target > 0 and first > 0:
return round(plan_margin * min(1.0, first / target), 8)
return plan_margin
def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool:
"""做空:价升触达/越过档位即应补仓;做多:价跌触达/越过档位。"""
d = (direction or "long").strip().lower()
try:
pf = float(mark_price)
lv = float(level)
except (TypeError, ValueError):
return False
if d == "long":
return pf <= lv
return pf >= lv
def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]:
direction = (direction or "long").strip().lower()
if direction == "long":
if not (float(stop_loss) < float(add_upper)):
return "做多:止损价须低于补仓上沿"
else:
if not (float(stop_loss) > float(add_upper)):
return "做空:止损价须高于补仓下沿"
return None
def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]:
"""在 (止损, 补仓区间远侧边界) 内生成 n_legs 个触发价(不含端点)。"""
sl, upper = float(sl), float(upper)
out: list[float] = []
if n_legs <= 0:
return out
direction = (direction or "long").strip().lower()
if direction == "long":
if upper <= sl:
return out
span = upper - sl
for i in range(1, n_legs + 1):
t = i / float(n_legs + 1)
out.append(sl + t * span)
out.sort(reverse=True)
else:
if sl <= upper:
return out
span = sl - upper
for i in range(1, n_legs + 1):
t = i / float(n_legs + 1)
out.append(upper + t * span)
out.sort()
return [round(p, 10) for p in out]
def pick_dca_legs_and_per_leg(
exchange_symbol: str,
remainder_total: float,
want_legs: int,
amount_precise: AmountPreciseFn,
min_amount: float = 0.0,
) -> Tuple[int, float]:
"""按最小张数约束自动减少档位数。返回 (有效档数, 每档参考张数)。"""
legs = max(1, int(want_legs))
rem = float(remainder_total)
min_amt = float(min_amount or 0.0)
while legs >= 1:
per = rem / legs
per_p = amount_precise(exchange_symbol, per)
if per_p is None or per_p <= 0:
legs -= 1
continue
if min_amt and per_p + 1e-12 < min_amt:
legs -= 1
continue
return legs, per_p
one = amount_precise(exchange_symbol, rem)
if one is None or one <= 0:
return 0, 0.0
return 1, one
def build_leg_amounts_json(
exchange_symbol: str,
remainder_total: float,
want_legs: int,
amount_precise: AmountPreciseFn,
min_amount: float = 0.0,
) -> Tuple[int, str, float]:
"""拆分补仓张数 JSON。返回 (档位数, json列表, 每档参考)。"""
rem = amount_precise(exchange_symbol, float(remainder_total))
if rem is None or rem <= 0:
return 0, "[]", 0.0
n, _ = pick_dca_legs_and_per_leg(exchange_symbol, rem, want_legs, amount_precise, min_amount)
if n <= 0:
return 0, "[]", 0.0
if n <= 1:
one = amount_precise(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
unit = amount_precise(exchange_symbol, rem / n)
if unit is None or unit <= 0:
one = amount_precise(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
parts: list[float] = []
acc = 0.0
for _ in range(n - 1):
parts.append(unit)
acc += unit
last = amount_precise(exchange_symbol, max(0.0, rem - acc))
if last is None or last <= 0:
one = amount_precise(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
parts.append(last)
return n, json.dumps(parts), unit
def compute_trend_plan_core(
*,
direction: str,
stop_loss: float,
add_upper: float,
risk_percent: float,
snapshot_usdt: float,
leverage: int,
live_price: float,
target_order_amount: float,
exchange_symbol: str,
dca_legs: int,
amount_precise: AmountPreciseFn,
min_amount: float = 0.0,
full_margin_buffer_ratio: float = 0.95,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""在已有 target_order_amount 时组装预览 payload(张数由调用方 prepare_order_amount 计算)。"""
rf = calc_risk_fraction(direction, add_upper, stop_loss)
if rf is None or rf <= 0:
return None, "止损与补仓区间边界组合无法计算风险比例"
risk_budget = float(snapshot_usdt) * (float(risk_percent) / 100.0)
notional = risk_budget / rf
margin_plan = notional / float(leverage)
margin_plan = min(margin_plan, float(snapshot_usdt) * float(full_margin_buffer_ratio))
if margin_plan <= 0:
return None, "计划保证金过小"
first_amt = amount_precise(exchange_symbol, float(target_order_amount) * 0.5)
if first_amt is None or first_amt <= 0:
return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆"
remainder_total = amount_precise(exchange_symbol, max(0.0, float(target_order_amount) - float(first_amt)))
if remainder_total is None:
remainder_total = 0.0
n_legs, leg_json, per_ref = build_leg_amounts_json(
exchange_symbol, remainder_total, dca_legs, amount_precise, min_amount
)
if n_legs <= 0:
return None, "剩余计划张数不足以拆出补仓档,请提高风险比例或放宽止损与补仓区间间距"
grid = build_grid_prices(direction, stop_loss, add_upper, n_legs)
if len(grid) != n_legs:
return None, "补仓网格生成失败"
try:
leg_list = json.loads(leg_json)
except Exception:
leg_list = []
payload = {
"direction": direction,
"stop_loss": float(stop_loss),
"add_upper": float(add_upper),
"risk_percent": float(risk_percent),
"snapshot_available_usdt": float(snapshot_usdt),
"live_price_ref": float(live_price),
"plan_margin_capital": float(margin_plan),
"target_order_amount": float(target_order_amount),
"first_order_amount": float(first_amt),
"remainder_total": float(remainder_total),
"dca_legs": int(n_legs),
"per_leg_amount": float(per_ref),
"grid_prices_json": json.dumps(grid),
"leg_amounts_json": leg_json,
"grid": grid,
"leg_amounts": leg_list,
}
return payload, None
def calc_planned_reward_risk_ratio(
direction: str, entry_price: float, stop_loss: float, take_profit: float
) -> Optional[float]:
"""盈亏比(reward/risk),与四所 calc_rr_ratio 口径一致。"""
try:
entry = float(entry_price)
sl = float(stop_loss)
tp = float(take_profit)
if entry <= 0 or sl <= 0 or tp <= 0:
return None
direction = (direction or "long").strip().lower()
if direction == "short":
risk = sl - entry
reward = entry - tp
else:
risk = entry - sl
reward = tp - entry
if risk <= 0 or reward <= 0:
return None
return round(reward / risk, 4)
except (TypeError, ValueError):
return None
def calc_take_profit_for_rr(
direction: str, entry_price: float, stop_loss: float, reward_risk_ratio: float
) -> Optional[float]:
"""按统一止损与目标 RR 反推止盈价。"""
try:
entry = float(entry_price)
sl = float(stop_loss)
rr = float(reward_risk_ratio)
if entry <= 0 or sl <= 0 or rr <= 0:
return None
direction = (direction or "long").strip().lower()
if direction == "short":
risk = sl - entry
if risk <= 0:
return None
return round(entry - rr * risk, 10)
risk = entry - sl
if risk <= 0:
return None
return round(entry + rr * risk, 10)
except (TypeError, ValueError):
return None
def calc_risk_budget_usdt(snapshot_usdt: float, risk_percent: float) -> Optional[float]:
"""计划止损金额 U = 可用快照 × 风险比例。"""
try:
snap = float(snapshot_usdt)
rp = float(risk_percent)
if snap <= 0 or rp <= 0:
return None
return round(snap * rp / 100.0, 4)
except (TypeError, ValueError):
return None
def calc_money_reward_risk_ratio(profit_u: float, risk_u: float) -> Optional[float]:
"""金额盈亏比 = 止盈盈利 U / 止损金额 U。"""
try:
r = float(risk_u)
p = float(profit_u)
if r <= 0:
return None
return round(p / r, 4)
except (TypeError, ValueError):
return None
def calc_tp_profit_usdt(
direction: str,
avg_entry: float,
take_profit_price: float,
contracts: float,
contract_size: float = 1.0,
) -> Optional[float]:
"""到达止盈价时,按累计张数与加仓后均价的盈利 U。"""
try:
from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt
return estimate_linear_swap_upnl_usdt(
direction, float(avg_entry), float(take_profit_price), float(contracts), float(contract_size)
)
except (TypeError, ValueError):
return None
def weighted_avg_entry(legs: list[tuple[float, float]]) -> Optional[float]:
"""按 (成交价, 张数) 加权均价。"""
total = 0.0
cost = 0.0
for price, amount in legs or []:
try:
p = float(price)
a = float(amount)
except (TypeError, ValueError):
continue
if a <= 0:
continue
total += a
cost += p * a
if total <= 0:
return None
return cost / total
def parse_leg_fill_prices(plan: dict) -> list[float]:
"""首仓 + 各档补仓实际成交价列表。"""
try:
raw = json.loads((plan or {}).get("leg_fill_prices_json") or "[]")
if not isinstance(raw, list):
return []
out: list[float] = []
for item in raw:
try:
out.append(float(item))
except (TypeError, ValueError):
continue
return out
except Exception:
return []
def append_leg_fill_price_json(existing_json: str | None, fill_px: float) -> str:
fills = parse_leg_fill_prices({"leg_fill_prices_json": existing_json})
fills.append(float(fill_px))
return json.dumps(fills, ensure_ascii=False, separators=(",", ":"))
def trend_leg_grid_price(plan: dict, leg_idx: int) -> Optional[float]:
"""补仓 leg_idx(1..N) 的计划网格触发价;首仓返回 None。"""
if leg_idx <= 0:
return None
try:
grid = [float(x) for x in json.loads((plan or {}).get("grid_prices_json") or "[]")]
except Exception:
grid = []
gi = leg_idx - 1
if 0 <= gi < len(grid):
return float(grid[gi])
return None
def trend_leg_display_price(plan: dict, leg_idx: int) -> Optional[float]:
"""
四所统一单档展示价 = leg_fill_prices_json 实际记录否则计划网格首仓用均价/参考价
禁止为凑均价反推虚构成交价
"""
p = plan or {}
fills = parse_leg_fill_prices(p)
if len(fills) > leg_idx:
return float(fills[leg_idx])
if leg_idx == 0:
try:
return float(p.get("avg_entry_price"))
except (TypeError, ValueError):
pass
try:
ref = p.get("live_price_ref")
if ref not in (None, ""):
return float(ref)
except (TypeError, ValueError):
pass
return None
return trend_leg_grid_price(p, leg_idx)
def reconcile_trend_leg_fill_prices(plan: dict) -> list[float]:
"""首仓(0)+已补仓(1..legs_done) 展示价列表(四所共用 trend_leg_display_price)。"""
p = plan or {}
if int(p.get("first_order_done") or 0) == 0:
return []
try:
legs_done = int(p.get("legs_done") or 0)
except (TypeError, ValueError):
legs_done = 0
result: list[float] = []
for leg_idx in range(legs_done + 1):
px = trend_leg_display_price(p, leg_idx)
result.append(float(px) if px is not None else 0.0)
return result
def calc_trend_plan_money_metrics(plan: dict) -> dict:
"""运行中计划头部:按快照风险金额计算盈亏比(止盈盈利 U / 风险 U)。"""
out = {"money_rr": None, "risk_amount_u": None}
p = plan or {}
try:
direction = (p.get("direction") or "long").strip().lower()
user_tp = float(p.get("take_profit"))
avg = float(p.get("avg_entry_price"))
open_amt = float(p.get("order_amount_open") or p.get("first_order_amount") or 0)
snapshot = float(p.get("snapshot_available_usdt"))
risk_percent = float(p.get("risk_percent"))
except (TypeError, ValueError):
return out
if avg <= 0 or open_amt <= 0:
return out
risk_u = calc_risk_budget_usdt(snapshot, risk_percent)
if risk_u is None or risk_u <= 0:
return out
out["risk_amount_u"] = risk_u
try:
contract_size = float(p.get("contract_size") or 1.0)
if contract_size <= 0:
contract_size = 1.0
except (TypeError, ValueError):
contract_size = 1.0
profit_u = calc_tp_profit_usdt(direction, avg, user_tp, open_amt, contract_size)
out["money_rr"] = calc_money_reward_risk_ratio(profit_u, risk_u)
return out
def build_trend_preview_level_rows(preview: dict) -> tuple[dict, list[dict]]:
"""
预览表单止盈价下每档累计持仓的盈利 U止损金额 = 快照×风险盈亏比按金额对比
返回 (增强后的 preview 字段, 表格行列表含首仓行)
"""
p = dict(preview or {})
direction = (p.get("direction") or "long").strip().lower()
try:
ref = float(p.get("live_price_ref"))
sl = float(p.get("stop_loss"))
user_tp = float(p.get("take_profit"))
first_amt = float(p.get("first_order_amount"))
snapshot = float(p.get("snapshot_available_usdt"))
risk_percent = float(p.get("risk_percent"))
except (TypeError, ValueError):
return p, []
risk_u = calc_risk_budget_usdt(snapshot, risk_percent)
if risk_u is None or risk_u <= 0:
return p, []
try:
contract_size = float(p.get("contract_size") or 1.0)
if contract_size <= 0:
contract_size = 1.0
except (TypeError, ValueError):
contract_size = 1.0
p["preview_risk_amount_u"] = risk_u
p["preview_take_profit_price"] = user_tp
p["preview_unified_stop_loss"] = sl
try:
grid = json.loads(p.get("grid_prices_json") or "[]")
if not isinstance(grid, list):
grid = []
except Exception:
grid = []
try:
leg_amounts = json.loads(p.get("leg_amounts_json") or "[]")
if not isinstance(leg_amounts, list):
leg_amounts = []
except Exception:
leg_amounts = []
def _row_dict(
*,
i: int,
label: str,
price: float,
leg_contracts: float,
cum_contracts: float,
avg: float,
is_first: bool,
) -> dict:
profit_u = calc_tp_profit_usdt(direction, avg, user_tp, cum_contracts, contract_size)
rr_money = calc_money_reward_risk_ratio(profit_u, risk_u) if profit_u is not None else None
return {
"i": i,
"label": label,
"price": price,
"contracts": leg_contracts,
"cum_contracts": cum_contracts,
"avg_entry": avg,
"take_profit_price": user_tp,
"profit_u": profit_u,
"risk_u": risk_u,
"rr": rr_money,
"stop_loss_price": sl,
"take_profit": profit_u,
"stop_loss": risk_u,
"is_first": is_first,
}
cum_contracts = first_amt
first_profit = calc_tp_profit_usdt(direction, ref, user_tp, cum_contracts, contract_size)
first_rr = calc_money_reward_risk_ratio(first_profit, risk_u) if first_profit is not None else None
p["preview_first_profit_u"] = first_profit
p["preview_target_rr"] = first_rr
p["preview_first_take_profit"] = user_tp
rows: list[dict] = [
_row_dict(
i=0,
label="首仓",
price=ref,
leg_contracts=first_amt,
cum_contracts=cum_contracts,
avg=ref,
is_first=True,
)
]
accumulated: list[tuple[float, float]] = [(ref, first_amt)]
for i, pair in enumerate(zip(grid, leg_amounts), 1):
try:
price = float(pair[0])
leg_contracts = float(pair[1])
except (TypeError, ValueError):
continue
accumulated.append((price, leg_contracts))
avg = weighted_avg_entry(accumulated)
if avg is None:
continue
cum_contracts += leg_contracts
rows.append(
_row_dict(
i=i,
label=f"补仓{i}",
price=price,
leg_contracts=leg_contracts,
cum_contracts=cum_contracts,
avg=avg,
is_first=False,
)
)
return p, rows
def enrich_trend_dca_levels_with_tp(plan: dict, levels: list[dict]) -> list[dict]:
"""
四所统一补仓表 enrich实例策略页 + 中控 monitor 共用
触发价实际成交价或计划网格末档加仓后均价用持仓均价禁止反推虚构成交价
"""
if not levels:
return levels
p = plan or {}
direction = (p.get("direction") or "long").strip().lower()
try:
sl = float(p.get("stop_loss"))
user_tp = float(p.get("take_profit"))
first_amt = float(p.get("first_order_amount"))
snapshot = float(p.get("snapshot_available_usdt"))
risk_percent = float(p.get("risk_percent"))
except (TypeError, ValueError):
return levels
risk_u = calc_risk_budget_usdt(snapshot, risk_percent)
if risk_u is None or risk_u <= 0:
return levels
try:
legs_done = int(p.get("legs_done") or 0)
except (TypeError, ValueError):
legs_done = 0
first_done = int(p.get("first_order_done") or 0) != 0
try:
target_avg = float(p.get("avg_entry_price"))
except (TypeError, ValueError):
target_avg = None
ref_raw = p.get("live_price_ref")
if ref_raw in (None, ""):
ref_raw = p.get("avg_entry_price")
try:
ref = float(ref_raw)
except (TypeError, ValueError):
return levels
try:
contract_size = float(p.get("contract_size") or 1.0)
if contract_size <= 0:
contract_size = 1.0
except (TypeError, ValueError):
contract_size = 1.0
out: list[dict] = []
accumulated: list[tuple[float, float]] = []
cum_contracts = 0.0
for lv in levels:
row = dict(lv)
is_first = row.get("leg_key") == "first" or row.get("label") == "首仓" or row.get("i") == 0
row_cum = cum_contracts
if is_first:
try:
amt_f = float(row.get("contracts") if row.get("contracts") is not None else first_amt)
except (TypeError, ValueError):
amt_f = first_amt
if first_done:
fill_px = trend_leg_display_price(p, 0)
if fill_px is None:
try:
fill_px = float(p.get("avg_entry_price") or ref)
except (TypeError, ValueError):
fill_px = ref
accumulated = [(float(fill_px), amt_f)]
cum_contracts = amt_f
row_cum = cum_contracts
row["price"] = fill_px
if target_avg is not None and legs_done == 0:
row["avg_entry"] = target_avg
else:
row["avg_entry"] = float(fill_px)
else:
accumulated = [(ref, amt_f)]
cum_contracts = amt_f
row_cum = cum_contracts
row["avg_entry"] = ref
else:
try:
leg_num = int(row.get("i") or 0)
except (TypeError, ValueError):
leg_num = 0
grid_trigger = row.get("price")
try:
grid_trigger_f = float(grid_trigger) if grid_trigger is not None else None
except (TypeError, ValueError):
grid_trigger_f = None
try:
leg_contracts = float(row.get("contracts") or 0)
except (TypeError, ValueError):
leg_contracts = 0.0
done = row.get("status") == "done" or (leg_num > 0 and leg_num <= legs_done)
if done and leg_contracts > 0:
fill_px = trend_leg_display_price(p, leg_num)
if fill_px is None:
fill_px = grid_trigger_f if grid_trigger_f is not None else ref
row["price"] = fill_px
accumulated.append((fill_px, leg_contracts))
cum_contracts += leg_contracts
row_cum = cum_contracts
if leg_num == legs_done and target_avg is not None:
row["avg_entry"] = target_avg
else:
avg = weighted_avg_entry(accumulated)
if avg is not None:
row["avg_entry"] = avg
elif grid_trigger_f is not None and leg_contracts > 0:
row["price"] = grid_trigger_f
projected = accumulated + [(grid_trigger_f, leg_contracts)]
avg = weighted_avg_entry(projected)
if avg is not None:
row["avg_entry"] = avg
row_cum = cum_contracts + leg_contracts
elif grid_trigger_f is not None:
row["price"] = grid_trigger_f
avg_entry = row.get("avg_entry")
if avg_entry is not None and row_cum > 0:
profit_u = calc_tp_profit_usdt(
direction, float(avg_entry), user_tp, row_cum, contract_size
)
row["take_profit_price"] = user_tp
row["profit_u"] = profit_u
row["risk_u"] = risk_u
row["rr"] = calc_money_reward_risk_ratio(profit_u, risk_u) if profit_u is not None else None
row["take_profit"] = profit_u
row["stop_loss"] = risk_u
row["stop_loss_price"] = sl
out.append(row)
return out
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
"""策略交易页:主站 index.html 所需数据(顺势加仓等)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.strategy.strategy_db import init_strategy_tables
from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label
def _row_to_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def count_active_trend_plans(conn, count_fn: Optional[Callable] = None) -> int:
if callable(count_fn):
return int(count_fn(conn) or 0)
try:
return int(
conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
)
except Exception:
return 0
def fetch_roll_page_data(
conn,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
roll_cfg: dict | None = None,
) -> dict[str, Any]:
init_strategy_tables(conn)
monitors = []
for row in conn.execute(
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall():
monitors.append(_row_to_dict(row))
roll_groups = []
for row in 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():
roll_groups.append(_row_to_dict(row))
active_gids = {int(g["id"]) for g in roll_groups if g.get("id") is not None}
roll_legs = []
for row in conn.execute(
"SELECT * FROM roll_legs ORDER BY id DESC LIMIT 80"
).fetchall():
leg = _row_to_dict(row)
gid = leg.get("roll_group_id")
if gid is not None and int(gid) not in active_gids:
continue
leg["status_label"] = roll_leg_status_label(leg.get("status"))
roll_legs.append(leg)
roll_legs = roll_legs[:50]
out = {
"roll_monitors": monitors,
"roll_groups": roll_groups,
"roll_legs": roll_legs,
"roll_trend_active": count_active_trend_plans(conn, count_active_trends),
"default_risk_percent": default_risk_percent,
}
if roll_cfg:
from lib.strategy.strategy_roll_ui_lib import enrich_roll_page_data
enrich_roll_page_data(conn, out, roll_cfg)
return out
DEFAULT_TREND_DISABLED_NOTE = (
"趋势回调(预览、自动补仓、程序止盈)仅在 Gate 趋势机器人实例 "
"crypto_monitor_gate_bot,常见端口 5002)中启用。"
"币安 / Gate 主站 / OKX 可使用本页「顺势加仓」;完整趋势回调请打开该实例。"
)
def strategy_render_extras(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 策略相关页变量(含策略交易记录)。"""
if page == "strategy_records":
from lib.strategy.strategy_records_register import load_strategy_records_page
return load_strategy_records_page(conn)
return strategy_page_template_vars(
conn,
page,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
trend_disabled_note=trend_disabled_note,
request_obj=request_obj,
trend_cfg=trend_cfg,
)
def strategy_page_template_vars(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 在 conn.close() 前合并进 render_template 的变量。"""
if page not in ("strategy", "strategy_trend", "strategy_roll"):
return {}
roll_cfg = None
try:
from flask import current_app
roll_cfg = (current_app.extensions or {}).get("strategy_roll_cfg")
except Exception:
roll_cfg = None
out = fetch_roll_page_data(
conn,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
roll_cfg=roll_cfg if isinstance(roll_cfg, dict) else None,
)
if trend_cfg and request_obj is not None:
from lib.strategy.strategy_trend_register import load_trend_page_context
out.update(load_trend_page_context(conn, request_obj, trend_cfg))
elif page == "strategy_trend":
out["trend_disabled_note"] = trend_disabled_note or DEFAULT_TREND_DISABLED_NOTE
return out
+192
View File
@@ -0,0 +1,192 @@
"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(四所共用)。"""
from __future__ import annotations
from typing import Any, Optional
from lib.common.wechat_notify_lib import wechat_direction_label
def _send(cfg: dict[str, Any], content: str) -> None:
fn = cfg.get("send_wechat")
if callable(fn):
try:
fn(content)
return
except Exception:
pass
m = cfg.get("app_module")
if m is not None:
sw = getattr(m, "send_wechat_msg", None)
if callable(sw):
try:
sw(content)
except Exception:
pass
def _account(cfg: dict[str, Any]) -> str:
fn = cfg.get("wechat_account_label")
if callable(fn):
try:
return str(fn()).strip() or _exchange(cfg)
except Exception:
pass
return _exchange(cfg)
def _exchange(cfg: dict[str, Any]) -> str:
return str(cfg.get("exchange_display") or "").strip() or "交易账户"
def _dir_text(cfg: dict[str, Any], direction: str) -> str:
fn = cfg.get("wechat_direction_text")
if callable(fn):
try:
return str(fn(direction))
except Exception:
pass
return wechat_direction_label(direction)
def _fmt_price(cfg: dict[str, Any], symbol: str, price: Any) -> str:
if price is None or price == "":
return ""
fn = cfg.get("format_price") or cfg.get("price_fmt")
if callable(fn):
try:
return str(fn(symbol, price))
except Exception:
pass
m = cfg.get("app_module")
pf = getattr(m, "format_price_for_symbol", None) if m else None
if callable(pf):
try:
return str(pf(symbol, price))
except Exception:
pass
try:
return str(round(float(price), 8))
except (TypeError, ValueError):
return str(price)
def _fmt_pnl(pnl: Any) -> str:
if pnl is None:
return ""
try:
v = float(pnl)
return f"{'+' if v > 0 else ''}{round(v, 2)} U"
except (TypeError, ValueError):
return str(pnl)
def notify_trend_plan_started(
cfg: dict[str, Any],
*,
plan_id: int,
symbol: str,
direction: str,
leverage: int,
stop_loss: float,
take_profit: float,
add_upper: float,
risk_percent: float,
dca_legs: int,
first_order_amount: float,
avg_entry: Optional[float] = None,
snapshot_usdt: Optional[float] = None,
) -> None:
sym = symbol or ""
lines = [
f"# 🚀 {sym} 趋势回调计划已开始",
f"**账户:{_account(cfg)}**",
f"- 计划 ID**{plan_id}**",
f"- 方向:{_dir_text(cfg, direction)}|杠杆 **{int(leverage or 1)}x**",
f"- 止损:{_fmt_price(cfg, sym, stop_loss)}|止盈:{_fmt_price(cfg, sym, take_profit)}",
f"- 补仓区:{_fmt_price(cfg, sym, add_upper)}|补仓档 **{int(dca_legs or 0)}** 档",
f"- 风险:**{risk_percent}%**|首仓张数:**{first_order_amount}**",
]
if avg_entry is not None:
lines.append(f"- 首仓成交价:{_fmt_price(cfg, sym, avg_entry)}")
if snapshot_usdt is not None:
try:
lines.append(f"- 启动时合约可用:**{round(float(snapshot_usdt), 2)} U**")
except (TypeError, ValueError):
pass
lines.append("- 说明:交易所已挂止损;止盈由程序监控;结束/保本将另行推送")
_send(cfg, "\n".join(lines))
def notify_trend_plan_ended(
cfg: dict[str, Any],
*,
plan_id: int,
symbol: str,
direction: str,
end_type: str,
result_label: Optional[str] = None,
exit_price: Optional[float] = None,
pnl_amount: Optional[float] = None,
extra: Optional[str] = None,
) -> None:
sym = symbol or ""
res = (result_label or end_type or "").strip()
lines = [
f"# 🏁 {sym} 趋势回调计划已结束",
f"**账户:{_account(cfg)}**",
f"- 计划 ID**{plan_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 结束方式:**{end_type}**",
f"- 结果:**{res}**",
]
if exit_price is not None:
lines.append(f"- 离场参考价:{_fmt_price(cfg, sym, exit_price)}")
if pnl_amount is not None:
lines.append(f"- 本单盈亏:**{_fmt_pnl(pnl_amount)}**")
if extra:
lines.append(f"- {extra}")
_send(cfg, "\n".join(lines))
def notify_roll_group_started(
cfg: dict[str, Any],
*,
group_id: int,
symbol: str,
direction: str,
order_monitor_id: int,
initial_take_profit: Optional[float] = None,
initial_stop_loss: Optional[float] = None,
) -> None:
sym = symbol or ""
lines = [
f"# 🚀 {sym} 滚仓计划已开始",
f"**账户:{_account(cfg)}**",
f"- 滚仓组 ID**{group_id}**|绑定下单监控 **#{order_monitor_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 首仓止盈(锁定):{_fmt_price(cfg, sym, initial_take_profit)}",
f"- 当前止损:{_fmt_price(cfg, sym, initial_stop_loss)}",
"- 说明:顺势加仓为人工触发;组结束(无持仓/监控结案)将另行推送",
]
_send(cfg, "\n".join(lines))
def notify_roll_group_ended(
cfg: dict[str, Any],
*,
group_id: int,
symbol: str,
direction: str,
reason: str,
leg_count: int = 0,
) -> None:
sym = symbol or ""
lines = [
f"# 🏁 {sym} 滚仓计划已结束",
f"**账户:{_account(cfg)}**",
f"- 滚仓组 ID**{group_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 结束原因:**{reason}**",
f"- 已完成滚仓腿数:**{int(leg_count or 0)}**",
]
_send(cfg, "\n".join(lines))
@@ -0,0 +1,23 @@
<details class="tip-collapse gate-top-tips-collapse">
<summary class="tip-collapse-summary">
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8
<span class="tip-collapse-hint">· 划转规则</span>
</summary>
<div class="tip-collapse-body rule-tip gate-transfer-tip">
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt }}U:不足从 {{ auto_transfer_from }} 划入、超出划回 {{ auto_transfer_from }}<strong>持仓中不划转</strong>并微信通知)
</div>
</details>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
<select name="from_account">
<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">
<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>
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 关键位放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page">
<div class="container">
<div class="card">
<div class="row" style="justify-content:space-between">
<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 class="row">
<a class="btn" href="/">返回首页</a>
<strong class="focus-title">关键位放大(可输入币种)</strong><span class="exchange-tag">{{ exchange_display }}</span>
</div>
<div class="status">最近刷新:<span id="updated-at">--</span></div>
</div>
<div class="row" style="margin-top:10px">
<label>币种</label>
<input id="symbol-input" value="{{ default_symbol }}" placeholder="BTC/USDT">
<label>关键位</label>
<select id="key-id">
<option value="">无(仅看K线)</option>
{% for k in key_list %}
<option value="{{ k.id }}" {% if selected_key and k.id == selected_key.id %}selected{% endif %}>#{{ k.id }} {{ k.symbol }} {{ k.monitor_type }} {{ '做多' if k.direction == 'long' else '做空' }}</option>
{% endfor %}
</select>
<label>周期</label>
<select id="timeframe">
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label>K线数</label>
<select id="kline-limit">
<option value="100" {% if default_kline_limit == 100 %}selected{% endif %}>100</option>
<option value="200" {% if default_kline_limit == 200 %}selected{% endif %}>200</option>
</select>
<button id="manual-refresh" type="button">刷新</button>
<span id="load-status" class="status"></span>
</div>
</div>
<div class="card">
<div class="meta">
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
<div class="meta-item"><div class="k">监控类型</div><div class="v" id="m-type">-</div></div>
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
<div class="meta-item"><div class="k">上沿/阻力</div><div class="v" id="m-upper">-</div></div>
<div class="meta-item"><div class="k">下沿/支撑</div><div class="v" id="m-lower">-</div></div>
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
<div class="meta-item"><div class="k">距上沿</div><div class="v" id="m-updiff">-</div></div>
<div class="meta-item"><div class="k">距下沿</div><div class="v" id="m-lowdiff">-</div></div>
</div>
</div>
<div class="card"><div id="chart-wrap"><div id="chart"></div></div></div>
</div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/static/focus_chart_page.js?v=2"></script>
<script>
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
const keySelect = document.getElementById("key-id");
const symbolInput = document.getElementById("symbol-input");
const tfSelect = document.getElementById("timeframe");
const limitSelect = document.getElementById("kline-limit");
const statusEl = document.getElementById("load-status");
const updatedAtEl = document.getElementById("updated-at");
const chartHost = document.getElementById("chart");
const FCP = window.FocusChartPage;
const keyMap = {};
{% for k in key_list %}
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
{% endfor %}
let fc = null;
function ensureChart(){
if(fc && fc.ensureSeries()) return true;
if(!window.LightweightCharts){
statusEl.className = "status err";
statusEl.innerText = "图表库加载失败";
return false;
}
fc = FCP.createFocusChart(chartHost);
if(!fc || !fc.ensureSeries()){
statusEl.className = "status err";
statusEl.innerText = "K线序列初始化失败";
return false;
}
return true;
}
function syncSymbolByKey(){
const keyId = keySelect.value;
if(keyId && keyMap[keyId]) symbolInput.value = keyMap[keyId];
}
async function loadKeyKline(){
if(!ensureChart()) return;
const keyId = keySelect.value;
const symbol = (symbolInput.value || "").trim().toUpperCase();
const timeframe = tfSelect.value;
const limit = limitSelect.value;
if(!symbol && !keyId){
statusEl.className = "status err";
statusEl.innerText = "请先输入币种或选择关键位";
return;
}
statusEl.className = "status";
statusEl.innerText = "加载中...";
try{
const qs = new URLSearchParams();
if(keyId) qs.set("key_id", keyId);
if(symbol) qs.set("symbol", symbol);
qs.set("timeframe", timeframe);
qs.set("limit", limit);
const resp = await fetch(`/api/key_kline?${qs.toString()}`);
const data = await resp.json();
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
else FCP.setActivePriceTick(data.price_tick);
const candles = Array.isArray(data.candles) ? data.candles : [];
if(!candles.length){
statusEl.className = "status err";
statusEl.innerText = "暂无K线数据";
return;
}
fc.candleSeries.setData(candles);
fc.resetPriceLines();
fc.addLine(data.current_price, FCP.lineTitle("现价", data.current_price_display), "#42a5f5");
if(data.key_monitor){
const km = data.key_monitor;
fc.addLine(km.upper, FCP.lineTitle("上沿", km.upper_display), "#ffb84d");
fc.addLine(km.lower, FCP.lineTitle("下沿", km.lower_display), "#4cd97f");
}
fc.chart.timeScale().fitContent();
FCP.paintKeyMeta(data);
updatedAtEl.innerText = data.updated_at || "--";
statusEl.className = "status";
statusEl.innerText = `已加载 ${candles.length} 根K线`;
}catch(err){
statusEl.className = "status err";
statusEl.innerText = err && err.message ? err.message : "加载失败";
}
}
document.getElementById("manual-refresh").addEventListener("click", loadKeyKline);
keySelect.addEventListener("change", ()=>{ syncSymbolByKey(); loadKeyKline(); });
symbolInput.addEventListener("change", ()=>{
if(symbolInput.value.trim()) keySelect.value = "";
loadKeyKline();
});
tfSelect.addEventListener("change", loadKeyKline);
limitSelect.addEventListener("change", loadKeyKline);
syncSymbolByKey();
loadKeyKline();
setInterval(loadKeyKline, refreshMs);
</script>
</body>
</html>
@@ -0,0 +1,327 @@
<style>
.key-monitor-dual-grid{align-items:stretch}
.key-monitor-dual-grid>.card{
height:100%;
min-height:0;
display:flex;
flex-direction:column;
}
.key-panel-scroll.panel-scroll.pos-list{
display:block;
flex:1 1 auto;
min-height:0;
overflow-x:hidden;
overflow-y:auto;
padding-bottom:6px;
-webkit-overflow-scrolling:touch;
scrollbar-gutter:stable;
}
.key-monitor-panel-scroll{min-height:200px}
.key-history-panel-scroll{
flex:0 0 auto;
max-height:calc(8 * 42px + 7 * 8px);
min-height:calc(3 * 42px + 2 * 8px);
}
.key-panel-scroll.panel-scroll.pos-list .key-row-collapse{flex-shrink:0}
.key-panel-scroll.panel-scroll.pos-list::-webkit-scrollbar{width:8px}
.key-panel-scroll.panel-scroll.pos-list::-webkit-scrollbar-thumb{background:#3a4660;border-radius:4px}
.key-panel-scroll.panel-scroll.pos-list::-webkit-scrollbar-track{background:transparent}
.key-row-collapse{border:1px solid #2a3348;border-radius:10px;background:#141923}
.key-row-collapse:not([open]){overflow:hidden}
.key-row-collapse[open]{overflow:visible}
.key-row-collapse+.key-row-collapse{margin-top:8px}
.key-row-collapse-summary{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;cursor:pointer;list-style:none;font-size:.8rem;color:#c5cde0;line-height:1.45}
.key-row-collapse-summary::-webkit-details-marker{display:none}
.key-row-collapse-summary::before{content:"▸";flex:0 0 auto;color:#6d7a99;transition:transform .15s ease}
.key-row-collapse[open]>.key-row-collapse-summary::before{transform:rotate(90deg)}
.key-row-summary-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px}
.key-row-summary-title{display:flex;align-items:center;gap:6px;flex:0 1 auto;flex-wrap:wrap;min-width:0}
.key-row-summary-title strong{font-size:.88rem;color:#fff}
.key-row-summary-line{color:#9aa8c4;font-size:.76rem;word-break:break-word}
.key-row-summary-live{
flex:1 1 auto;
min-width:0;
color:#8fc8ff;
font-size:.72rem;
text-align:right;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.key-row-summary-live.key-row-summary-pending{color:#4cd97f;font-weight:600}
.key-history-panel-scroll .key-row-summary-main{justify-content:flex-start}
.key-row-summary-actions{flex:0 0 auto;display:flex;gap:6px;align-items:center}
.key-row-collapse-body{padding:0 12px 16px;border-top:1px solid #232b3d}
.key-row-collapse-body .pos-meta{margin-top:10px;margin-bottom:10px}
.key-row-collapse-body .pos-grid{margin-bottom:8px}
.key-history-alert{font-size:.75rem;color:#aab;margin-top:8px;margin-bottom:2px;padding-bottom:4px;white-space:pre-wrap;word-break:break-word;line-height:1.5}
.key-history-outcome-badge{font-size:.7rem;font-weight:600;padding:1px 7px;border-radius:4px;line-height:1.35}
.key-row-collapse.key-history-success{border-color:rgba(76,217,127,.42);background:rgba(18,32,26,.92)}
.key-row-collapse.key-history-success .key-row-collapse-summary{color:#c8f0d6}
.key-row-collapse.key-history-success .key-row-summary-title strong{color:#e8fff0}
.key-row-collapse.key-history-success .key-history-brief,.key-row-collapse.key-history-success .key-history-outcome-badge{color:#4cd97f;background:rgba(76,217,127,.12);border:1px solid rgba(76,217,127,.28)}
.key-row-collapse.key-history-manual{border-color:rgba(136,146,176,.45);background:rgba(22,24,32,.95)}
.key-row-collapse.key-history-manual .key-history-brief,.key-row-collapse.key-history-manual .key-history-outcome-badge{color:#9aa8c4;background:rgba(136,146,176,.12);border:1px solid rgba(136,146,176,.28)}
.key-row-collapse.key-history-failed{border-color:rgba(232,160,144,.4);background:rgba(36,22,24,.95)}
.key-row-collapse.key-history-failed .key-row-collapse-summary{color:#e8cfc8}
.key-row-collapse.key-history-failed .key-history-brief,.key-row-collapse.key-history-failed .key-history-outcome-badge{color:#e8a090;background:rgba(232,160,144,.1);border:1px solid rgba(232,160,144,.28)}
.key-rule-table-wrap{overflow-x:auto;margin:0 -2px}
.key-rule-table{width:100%;min-width:620px;border-collapse:collapse;font-size:.6rem;line-height:1.3}
.key-rule-table th,.key-rule-table td{border:1px solid #2a3348;padding:4px 6px;vertical-align:top;text-align:left}
.key-rule-table th{background:rgba(0,0,0,.28);color:#9ab;font-weight:600;white-space:nowrap;font-size:.58rem}
.key-rule-table td{color:#c5cde0}
.key-rule-table .key-rule-type{color:#fff;font-weight:600;line-height:1.25;white-space:nowrap}
.key-rule-table .key-rule-sub{color:#8fc8ff;font-size:.54rem;font-weight:500}
.key-rule-cell{word-break:break-word}
.key-rule-foot{margin:6px 0 0;font-size:.56rem;color:#8892b0;line-height:1.35}
.key-rule-foot code{font-size:.54rem;color:#8fc8ff}
</style>
{% macro key_monitor_type_label(k) -%}
{%- if k.monitor_type in ['关键阻力位','关键支撑位','关键支撑阻力'] -%}关键支撑阻力{%- else -%}{{ k.monitor_type }}{%- endif -%}
{%- endmacro %}
{% macro key_direction_label(k) -%}
{% if k.direction == 'watch' %}双向{% elif k.direction == 'long' %}做多{% else %}做空{% endif %}
{%- endmacro %}
{% macro key_sl_tp_mode_label(k) -%}
{% if (k.sl_tp_mode or 'standard') == 'standard' %}标准突破{% elif k.sl_tp_mode == 'box_1p5' %}箱体1R·止盈1.5H{% else %}趋势单{% endif %}
{%- endmacro %}
{% macro key_monitor_brief(k) -%}
上{{ k.upper }} / 下{{ k.lower }} · 提醒 {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}
{%- if k.monitor_type in ['箱体突破','收敛突破'] %} · {{ key_sl_tp_mode_label(k) }}{% endif %}
{%- if k.breakeven_enabled %} · 保本开{% else %} · 保本关{% endif %}
{%- endmacro %}
{% macro key_history_outcome_kind(h) -%}
{%- set r = (h.close_reason or '')|trim -%}
{%- if r in ['fib_filled', 'false_breakout_filled', 'trigger_entry_filled', 'key_level_alert_done', 'alerts_complete', 'auto_opened'] -%}success
{%- elif r == 'manual' -%}manual
{%- elif r -%}failed
{%- else -%}neutral
{%- endif -%}
{%- endmacro %}
{% macro key_history_outcome_label(h) -%}
{%- set r = (h.close_reason or '')|trim -%}
{%- if r == 'fib_filled' -%}斐波成交
{%- elif r == 'false_breakout_filled' -%}假突破成交
{%- elif r == 'trigger_entry_filled' -%}触价成交
{%- elif r == 'key_level_alert_done' -%}提醒完成
{%- elif r == 'alerts_complete' -%}提醒已满
{%- elif r == 'auto_opened' -%}自动开仓
{%- elif r == 'manual' -%}手动删除
{%- elif r == 'fib_invalidate' -%}斐波失效
{%- elif r == 'box_opposite_break' -%}反向突破失效
{%- elif r == 'trigger_tp_invalidate' -%}触价止盈失效
{%- elif r == 'trigger_sl_invalidate' -%}触价止损失效
{%- elif r == 'trigger_entry_expired' -%}触价过期
{%- elif r == 'trigger_exchange_failed' -%}触价下单失败
{%- elif r == 'false_breakout_expired' -%}假突破过期
{%- elif r == 'fib_plan_invalid' -%}计划无效
{%- elif r == 'rr_insufficient' -%}盈亏比不足
{%- elif r == 'exchange_failed' -%}下单失败
{%- else -%}{{ r or '—' }}
{%- endif -%}
{%- endmacro %}
{% macro key_history_brief(h) -%}
{{ key_history_outcome_label(h) }} · {{ (h.closed_at or '-')[:16] }} · 上{{ h.upper }} / 下{{ h.lower }} · 提醒 {{ h.notification_count or 0 }}
{%- endmacro %}
<div class="dual-panel-grid key-monitor-dual-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_key_id %}
<a href="/key_focus?key_id={{ focus_key_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
{% else %}
<a href="/key_focus" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">输入币种查看K线</a>
{% endif %}
</div>
<form id="key-form" action="/add_key" method="post" class="form-row">
<input name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select name="type" id="key-type-select" required>
{% if position_sizing_mode != 'full_margin' %}
<option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option>
<option value="斐波回调0.618">斐波回调0.618</option>
<option value="斐波回调0.786">斐波回调0.786</option>
<option value="假突破">假突破(BTC/ETH</option>
{% endif %}
<option value="回调触价开仓">回调触价开仓</option>
<option value="突破触价开仓">突破触价开仓</option>
<option value="关键支撑阻力">关键支撑阻力</option>
</select>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="key_price" id="key-fb-price" step="0.0001" placeholder="做空填高点/做多填低点" style="display:none">
<input name="trigger_entry" id="key-trigger-entry" step="0.0001" placeholder="计划入场价" style="display:none">
<input name="trigger_sl" id="key-trigger-sl" step="0.0001" placeholder="止损价" style="display:none">
<input name="trigger_tp" id="key-trigger-tp" step="0.0001" placeholder="止盈价" style="display:none">
<input name="upper" id="key-upper" step="0.0001" placeholder="上沿/阻力" required>
<input name="lower" id="key-lower" step="0.0001" placeholder="下沿/支撑" required>
<select name="sl_tp_mode" id="key-sl-tp-mode" title="止盈止损方案">
<option value="standard">标准突破</option>
<option value="box_1p5">箱体1R·止盈1.5H</option>
<option value="trend_manual">趋势单·自填止盈</option>
</select>
<input name="manual_take_profit" id="key-manual-tp" step="0.0001" placeholder="趋势单止盈价" style="display:none">
<label id="key-breakeven-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.85rem;color:#9aa">
<input type="checkbox" name="breakeven_enabled" value="1" id="key-breakeven-cb"> 移动保本
</label>
<span id="key-time-close-wrap" class="key-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.85rem;color:#9aa">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="key-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="key-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
<button type="submit">添加</button>
</form>
<details class="tip-collapse key-rule-collapse">
<summary class="tip-collapse-summary">关键位监控规则说明</summary>
<div class="tip-collapse-body rule-tip">
{% include 'key_monitor_rule_tips.html' %}
</div>
</details>
<div class="panel-scroll pos-list key-panel-scroll key-monitor-panel-scroll">
{% for k in key %}
<details class="key-row-collapse" id="key-row-{{ k.id }}">
<summary class="key-row-collapse-summary">
<span class="key-row-summary-main">
<span class="key-row-summary-title">
<strong>{{ k.symbol }}</strong>
{% if k.time_close_enabled and k.time_close_hours %}
<span class="pos-symbol-time-close pos-meta-on">时间平仓 {{ k.time_close_hours }}h</span>
{% endif %}
{% if k.direction == 'watch' %}
<span class="pos-side-badge" style="background:#2a3152;color:#9ab">双向</span>
{% else %}
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ key_direction_label(k) }}</span>
{% endif %}
<span class="badge direction">{{ key_monitor_type_label(k) }}</span>
</span>
<span class="key-row-summary-live" id="key-summary-live-{{ k.id }}">现价 — · 门控 —</span>
</span>
<span class="key-row-summary-actions">
<button type="button" class="table-del" onclick="event.preventDefault(); event.stopPropagation(); deleteKeyMonitor({{ k.id }})"></button>
</span>
</summary>
<div class="key-row-collapse-body">
<div class="key-row-summary-line">{{ key_monitor_brief(k) }}</div>
<div class="pos-meta">
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
{% if k.fib_entry_price and k.monitor_type in ['回调触价开仓','突破触价开仓','触价开仓'] %}<span class="pos-meta-item">E: {{ k.fib_entry_price }} / SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}</span>{% elif k.fib_entry_price %}<span class="pos-meta-item">挂E: {{ k.fib_entry_price }}</span>{% endif %}
{% if k.monitor_type == '假突破' and k.fib_stop_loss %}<span class="pos-meta-item">SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}</span>{% endif %}
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
{% if k.monitor_type in ['箱体突破','收敛突破'] %}
<span class="pos-meta-item">方案: {{ key_sl_tp_mode_label(k) }}</span>
{% endif %}
<span class="pos-meta-item">保本: {{ '开' if k.breakeven_enabled else '关' }}</span>
</div>
<div class="pos-grid">
<div class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{ k.id }}" style="color:#9aa">-</span></div>
</div>
<div class="pos-meta"><span class="pos-meta-item" id="key-gate-metrics-{{ k.id }}" style="color:#8fc8ff"></span></div>
</div>
</details>
{% else %}
<div class="pos-empty">暂无监控中的关键位</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 style="margin-bottom:8px">关键位历史</h2>
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位 · 点击展开详情</div>
<div class="panel-scroll pos-list key-panel-scroll key-history-panel-scroll">
{% for h in key_history %}
<details class="key-row-collapse key-history-{{ key_history_outcome_kind(h) }}">
<summary class="key-row-collapse-summary">
<span class="key-row-summary-main">
<span class="key-row-summary-title">
<strong>{{ h.symbol }}</strong>
<span class="pos-side-badge {{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}">{{ key_direction_label(h) }}</span>
<span class="badge direction">{{ key_monitor_type_label(h) }}</span>
<span class="key-history-outcome-badge">{{ key_history_outcome_label(h) }}</span>
</span>
</span>
<span class="key-row-summary-actions">
<button type="button" class="table-del" onclick="event.preventDefault(); event.stopPropagation(); deleteKeyHistory({{ h.id }})">删除</button>
</span>
</summary>
<div class="key-row-collapse-body">
<div class="key-row-summary-line key-history-brief">{{ key_history_brief(h) }}</div>
<div class="pos-meta">
<span class="pos-meta-item">类型: {{ key_monitor_type_label(h) }}</span>
<span class="pos-meta-item">结案: {{ key_history_outcome_label(h) }}{% if h.close_reason %} ({{ h.close_reason }}){% endif %}</span>
<span class="pos-meta-item">时间: {{ h.closed_at or '—' }}</span>
</div>
<div class="pos-meta">
<span class="pos-meta-item">上沿: {{ h.upper }}</span>
<span class="pos-meta-item">下沿: {{ h.lower }}</span>
<span class="pos-meta-item">提醒次数: {{ h.notification_count or 0 }}</span>
</div>
{% if h.last_alert_message %}
<div class="key-history-alert">{{ h.last_alert_message }}</div>
{% endif %}
</div>
</details>
{% else %}
<div class="pos-empty">暂无历史</div>
{% endfor %}
</div>
</div>
</div>
<script>
function keySummaryIsPending(snap){
if(!snap) return false;
const gs = String(snap.gate_summary || "");
if(gs.includes("标记价将失效")) return false;
const gm = String(snap.gate_metrics || "");
if(gm.includes("限价单") || gm.includes("挂单")) return true;
if(/等待成交/.test(gs)) return true;
if(/触价待触发/.test(gs)) return true;
if(/挂E=/.test(gs) && !gs.includes("将失效")) return true;
return false;
}
function paintKeyMonitorSummary(id, snap){
const el = document.getElementById(`key-summary-live-${id}`);
if(!el || !snap) return;
const px = snap.price_display || (Number.isFinite(Number(snap.price)) ? Number(snap.price).toFixed(6) : "—");
const gate = snap.gate_summary || "—";
el.innerText = `现价 ${px} · 门控 ${gate}`;
el.classList.toggle("key-row-summary-pending", keySummaryIsPending(snap));
}
document.querySelectorAll(".key-row-collapse").forEach((row)=>{
row.addEventListener("toggle", ()=>{
if(!row.open) return;
requestAnimationFrame(()=>{
const body = row.querySelector(".key-row-collapse-body");
const panel = row.closest(".key-panel-scroll");
if(body && panel){
const bodyRect = body.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
if(bodyRect.bottom > panelRect.bottom - 8){
panel.scrollTop += bodyRect.bottom - panelRect.bottom + 16;
} else if(bodyRect.top < panelRect.top + 8){
panel.scrollTop -= panelRect.top - bodyRect.top + 16;
}
} else {
row.scrollIntoView({block:"nearest", behavior:"smooth"});
}
});
});
});
</script>
<script src="/static/key_monitor_form.js?v=2"></script>
@@ -0,0 +1,59 @@
{% set r = key_rule_ctx %}
<div class="key-rule-table-wrap">
<table class="key-rule-table">
<thead>
<tr>
<th>类型</th>
<th>填写</th>
<th>门控</th>
<th>止盈止损</th>
<th>执行</th>
</tr>
</thead>
<tbody>
<tr>
<td class="key-rule-type">箱体突破<br><span class="key-rule-sub">收敛突破</span></td>
<td class="key-rule-cell">方向必选;填 H/L<br>方案:标准 / 1R·1.5H / 趋势<br>可勾移动保本</td>
<td class="key-rule-cell">{{ r.tf }} 两根闭合 K{{ r.breakout_bar }}/{{ r.confirm_bar }}<br>突破 &gt;{{ r.amp_min_pct }}%;确认在箱外<br>&gt;前{{ r.vol_ma_bars }}均×{{ r.vol_ratio_min }}<br>成交 Top{{ r.vol_rank_max }}RR &gt;{{ r.min_rr }}<br>标记价先破反向边界→失效</td>
<td class="key-rule-cell">标准:SL 极值外{{ r.stop_outside_pct }}%TP=E±H<br>1RSL=E∓HTP=E∓1.5H<br>趋势:SL 极值外{{ r.trend_stop_outside_pct }}%TP 自填</td>
<td class="key-rule-cell">门控过→市价开仓→下单监控<br>满仓不可再加</td>
</tr>
<tr>
<td class="key-rule-type">斐波回调<br><span class="key-rule-sub">0.618 / 0.786</span></td>
<td class="key-rule-cell">方向 + H/L 波段<br>系统算 E/SL/TP</td>
<td class="key-rule-cell">多:E=HrΔ,SL=LTP=H<br>空:E=L+rΔ,SL=HTP=L<br>RR &gt;{{ r.min_rr }};先触 TP 侧失效</td>
<td class="key-rule-cell">公式固定 SL/TP<br>成交后挂所</td>
<td class="key-rule-cell">挂限价等成交<br>成交→下单监控</td>
</tr>
<tr>
<td class="key-rule-type">假突破<br><span class="key-rule-sub">BTC / ETH</span></td>
<td class="key-rule-cell">空填高点 / 多填低点<br>同币仅 1 条</td>
<td class="key-rule-cell">外侧 {{ r.fb_offset_pct }}% 限价<br>SL {{ r.fb_sl_pct }}%RR {{ r.fb_rr }}<br>有效 {{ r.fb_valid_hours }}h</td>
<td class="key-rule-cell">自动 E/SL/TP<br>可保本</td>
<td class="key-rule-cell">即挂限价<br>成交/过期→历史</td>
</tr>
<tr>
<td class="key-rule-type">回调触价开仓</td>
<td class="key-rule-cell">方向 + 入场 E / 止损 SL / 止盈 TP<br>可勾移动保本、时间平仓</td>
<td class="key-rule-cell">RR &gt;{{ r.min_rr }};做多 SL&lt;E&lt;TP<br>标记价回调触 E(多≤E / 空≥E)后下一轮询市价开<br>先触 TP 侧失效;有效 {{ r.trigger_entry_validity_hours }}h</td>
<td class="key-rule-cell">程序盯价,无交易所挂单<br>成交后挂所 TP/SL → 下单监控</td>
<td class="key-rule-cell">占当日开仓意图<br>全仓模式可用</td>
</tr>
<tr>
<td class="key-rule-type">突破触价开仓</td>
<td class="key-rule-cell">方向 + 突破价 E / 止损 SL / 止盈 TP<br>可勾移动保本、时间平仓</td>
<td class="key-rule-cell">RR &gt;{{ r.min_rr }};做多 SL&lt;E&lt;TP<br>标记价<strong>穿越</strong> E 立即市价开(多向上 / 空向下)<br>先触 TP 或 SL 侧失效;有效 {{ r.trigger_entry_validity_hours }}h</td>
<td class="key-rule-cell">程序盯价,无交易所挂单<br>成交后挂所 TP/SL → 下单监控</td>
<td class="key-rule-cell">占当日开仓意图<br>全仓模式可用</td>
</tr>
<tr>
<td class="key-rule-type">关键支撑阻力</td>
<td class="key-rule-cell">双向;填上/下沿</td>
<td class="key-rule-cell">{{ r.tf }} 收盘破上沿或下沿<br>上沿优先</td>
<td class="key-rule-cell">无(仅提醒)</td>
<td class="key-rule-cell">微信 ≤{{ r.alert_max }} 次<br>间隔 ≥{{ r.alert_interval_min }} 分</td>
</tr>
</tbody>
</table>
</div>
<p class="key-rule-foot">阈值来自 <code>.env</code>,修改后重启实例。</p>
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 实盘下单放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page">
<div class="container">
<div class="card">
<div class="row" style="justify-content:space-between">
<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 class="row">
<a class="btn" href="/">返回首页</a>
<strong class="focus-title">实盘下单放大(100根K线)</strong><span class="exchange-tag">{{ exchange_display }}</span>
</div>
<div class="status">最近刷新:<span id="updated-at">--</span></div>
</div>
{% if orders %}
<div class="row" style="margin-top:10px">
<label>订单</label>
<select id="order-id">
{% for o in orders %}
<option value="{{ o.id }}" {% if selected_order and o.id == selected_order.id %}selected{% endif %}>
#{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
</option>
{% endfor %}
</select>
<label>周期</label>
<select id="timeframe">
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<button id="manual-refresh" type="button">刷新</button>
<span id="load-status" class="status"></span>
</div>
{% else %}
<div class="empty">当前没有激活订单,无法展示放大K线。</div>
{% endif %}
</div>
{% if orders %}
<div class="card">
<div class="meta">
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
<div class="meta-item"><div class="k">成交价</div><div class="v" id="m-entry">-</div></div>
<div class="meta-item"><div class="k">止损</div><div class="v" id="m-sl">-</div></div>
<div class="meta-item"><div class="k">止盈</div><div class="v" id="m-tp">-</div></div>
<div class="meta-item"><div class="k">盈亏比</div><div class="v" id="m-rr">-</div></div>
<div class="meta-item"><div class="k">移动保本</div><div class="v" id="m-breakeven">-</div></div>
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
<div class="meta-item meta-item--emph meta-item--pnl"><div class="k">浮盈亏</div><div class="v" id="m-pnl">-</div></div>
</div>
</div>
<div class="card">
<div id="chart-wrap"><div id="chart"></div></div>
</div>
{% endif %}
</div>
{% if orders %}
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/static/focus_chart_page.js?v=2"></script>
<script>
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
const orderSelect = document.getElementById("order-id");
const tfSelect = document.getElementById("timeframe");
const statusEl = document.getElementById("load-status");
const updatedAtEl = document.getElementById("updated-at");
const chartHost = document.getElementById("chart");
const FCP = window.FocusChartPage;
let fc = null;
function ensureChart(){
if(fc && fc.ensureSeries()) return true;
if(!window.LightweightCharts){
statusEl.className = "status err";
statusEl.innerText = "图表库加载失败";
return false;
}
fc = FCP.createFocusChart(chartHost);
if(!fc || !fc.ensureSeries()){
statusEl.className = "status err";
statusEl.innerText = "K线序列初始化失败";
return false;
}
return true;
}
async function loadOrderKline(){
if(!ensureChart()) return;
const orderId = orderSelect.value;
const timeframe = tfSelect.value;
if(!orderId) return;
statusEl.className = "status";
statusEl.innerText = "加载中...";
try{
const resp = await fetch(`/api/order_kline?order_id=${encodeURIComponent(orderId)}&timeframe=${encodeURIComponent(timeframe)}`);
const data = await resp.json();
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
else FCP.setActivePriceTick(data.price_tick);
const candles = Array.isArray(data.candles) ? data.candles : [];
if(!candles.length){
statusEl.className = "status err";
statusEl.innerText = "暂无K线数据";
return;
}
fc.candleSeries.setData(candles);
fc.resetPriceLines();
const o = data.order || {};
fc.addLine(o.trigger_price, FCP.lineTitle("成交价", o.trigger_price_display), "#42a5f5");
fc.addLine(o.stop_loss, FCP.lineTitle("止损", o.stop_loss_display), "#ff6666");
fc.addLine(o.take_profit, FCP.lineTitle("止盈", o.take_profit_display), "#4cd97f");
const markPx = o.current_price;
if(markPx) fc.addLine(markPx, FCP.lineTitle("现价", o.current_price_display), "#ffb74d");
fc.chart.timeScale().fitContent();
FCP.paintOrderMeta(o);
updatedAtEl.innerText = data.updated_at || "--";
statusEl.className = "status";
statusEl.innerText = `已加载 ${candles.length} 根K线`;
}catch(err){
statusEl.className = "status err";
statusEl.innerText = err && err.message ? err.message : "加载失败";
}
}
document.getElementById("manual-refresh").addEventListener("click", loadOrderKline);
orderSelect.addEventListener("change", loadOrderKline);
tfSelect.addEventListener("change", loadOrderKline);
loadOrderKline();
setInterval(loadOrderKline, refreshMs);
</script>
{% endif %}
</body>
</html>
@@ -0,0 +1,21 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满、单日开仓达上限,或未到北京时间 {{ reset_hour }}:00{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }}BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -0,0 +1,21 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满、单日开仓达上限,或未到北京时间 {{ reset_hour }}:00{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }}BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -0,0 +1,21 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓达上限、单日开仓达上限、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }}BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -0,0 +1,21 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}{% endif %}{% if daily_open_hard_limit > 0 and opens_today >= daily_open_hard_limit %}(单日开仓达上限){% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00{% endif %}{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }}BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -0,0 +1,5 @@
<div id="order-plan-preview" class="order-plan-preview">
<span id="order-risk-preview" class="order-preview-risk">预估风险:<strong></strong></span>
<span id="order-profit-preview" class="order-preview-profit">预估盈利:<strong></strong></span>
<span id="order-rr-preview" class="order-preview-rr">预估盈亏比:<strong></strong></span>
</div>
@@ -0,0 +1,284 @@
{% set mf = money_fmt|default(funds_fmt) %}
<style>
.strategy-records-page{
padding:10px clamp(14px,2.2vw,22px) 22px;
box-sizing:border-box;
}
.strategy-records-page h2{margin:0 0 8px;color:#dbe4ff}
.strategy-records-tip{font-size:.76rem;color:#8892b0;line-height:1.55;margin-bottom:12px}
.sr-filters{display:flex;flex-wrap:wrap;gap:10px 14px;align-items:center;padding:12px 14px;background:#141a2a;border:1px solid #2a3150;border-radius:10px;margin-bottom:16px}
.sr-filters label{font-size:.76rem;color:#8b95b8;display:flex;align-items:center;gap:6px}
.sr-filters select,.sr-filters input[type=datetime-local]{padding:5px 8px;background:#0f1424;border:1px solid #304164;border-radius:6px;color:#dbe4ff;font-size:.78rem}
.sr-chip-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.sr-chip{padding:5px 12px;border:1px solid #304164;border-radius:16px;background:#151a2a;color:#9aa3c4;font-size:.74rem;cursor:pointer;user-select:none}
.sr-chip.active{background:#2a3f6c;color:#dbe4ff;border-color:#4a6a9a}
.sr-panels{display:grid;grid-template-columns:repeat(2,minmax(280px,1fr));gap:14px}
@media (max-width:960px){.sr-panels{grid-template-columns:1fr}}
.sr-panel{background:#141a2a;border:1px solid #2a3150;border-radius:12px;padding:12px 14px;min-height:120px;min-width:0}
.sr-panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:8px}
.sr-panel-title{font-size:.92rem;font-weight:700;color:#f0f2ff}
.sr-panel-title.trend{color:#6ab8ff}
.sr-panel-title.roll{color:#ffb020}
.sr-panel-count{font-size:.72rem;color:#8892b0}
.sr-list{display:flex;flex-direction:column;gap:8px;max-height:62vh;overflow:auto;min-width:0}
.sr-item{border:1px solid #243050;border-radius:10px;background:#0f1424;overflow:visible}
.sr-item.sr-hidden{display:none}
.sr-summary{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 12px;cursor:pointer;font-size:.78rem;color:#cfd3ef !important;line-height:1.45;min-height:2.4rem;min-width:0}
.sr-summary > span{flex:0 1 auto;min-width:0;color:inherit}
.sr-summary .sr-sym{color:#f0f2ff !important;flex-shrink:0}
.sr-summary .sr-dca-tag{color:#8892b0 !important}
.sr-summary .sr-pnl.pos{color:#4cd97f !important}
.sr-summary .sr-pnl.neg{color:#ff6666 !important}
.sr-summary:hover{background:rgba(42,63,108,.2)}
.sr-summary::before{content:"▸";color:#6ab8ff;margin-right:2px;transition:transform .15s}
.sr-item.sr-open .sr-summary::before{transform:rotate(90deg)}
.sr-summary .sr-sym{font-weight:600;color:#f0f2ff}
.sr-summary .sr-pnl.pos{color:#4cd97f}
.sr-summary .sr-pnl.neg{color:#ff6666}
.sr-summary .sr-dca-tag{font-size:.7rem;color:#8892b0}
.sr-detail{display:none;padding:0 12px 12px;border-top:1px dashed #2a3558;font-size:.76rem;color:#cfd3ef}
.sr-item.sr-open .sr-detail{display:block}
.sr-detail-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 12px;margin:10px 0}
.sr-detail-grid .lbl{color:#8b95b8;font-size:.7rem}
.sr-detail-grid .val{color:#f0f2ff}
.sr-dca-table{width:100%;border-collapse:collapse;font-size:.72rem;margin-top:8px}
.sr-dca-table th,.sr-dca-table td{padding:5px 8px;border-bottom:1px solid #243050;text-align:left}
.sr-dca-table th{color:#6a7598}
.sr-dca-table .st-done{color:#4cd97f}
.sr-dca-table .st-pending{color:#9aa3c4}
.sr-empty{padding:20px;text-align:center;color:#8892b0;font-size:.8rem}
</style>
<div class="strategy-records-page card full">
<h2>策略交易记录</h2>
<p class="strategy-records-tip">
数据库保留最近 <strong>{{ strategy_records_limit|default(100) }}</strong> 条结束快照(按结束时间排序)。
趋势回调与顺势加仓分栏展示;点击行展开详情。结束计划、保本移交、止盈止损会自动写入。
</p>
<div class="sr-filters" id="sr-filters">
<label>币种
<select id="sr-filter-symbol">
<option value="">全部</option>
{% for sym in strategy_record_symbols %}
<option value="{{ sym }}">{{ sym }}</option>
{% endfor %}
</select>
</label>
<label>时间
<select id="sr-filter-time">
<option value="desc">最新优先</option>
<option value="asc">最早优先</option>
</select>
</label>
<div class="sr-chip-row">
<span style="font-size:.72rem;color:#8b95b8">筛选</span>
<button type="button" class="sr-chip" data-filter="profit">盈利</button>
<button type="button" class="sr-chip" data-filter="loss">亏损</button>
<button type="button" class="sr-chip" data-filter="no_dca">未补仓</button>
<button type="button" class="sr-chip" data-filter="dca">补仓</button>
<button type="button" class="sr-chip" id="sr-filter-reset" style="border-style:dashed">重置</button>
</div>
</div>
<div class="sr-panels">
<div class="sr-panel" data-panel="trend">
<div class="sr-panel-head">
<span class="sr-panel-title trend">趋势回调记录</span>
<span class="sr-panel-count" id="sr-trend-count">{{ strategy_trend_records|length }} 条</span>
</div>
<div class="sr-list" id="sr-trend-list">
{% for s in strategy_trend_records %}
{% set snap = s.snapshot or {} %}
{% set dca = snap.dca_levels if snap.dca_levels is defined else [] %}
{% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %}
{% set sym = s.symbol or s.exchange_symbol or snap.symbol or snap.exchange_symbol or '—' %}
<div class="sr-item" data-symbol="{{ s.filter_symbol or '' }}" data-pnl="{{ s.filter_pnl or '' }}" data-dca-tag="{{ s.dca_tag or '' }}" data-dca-done="{{ s.dca_done|default(0) }}" data-sort-ts="{{ s.sort_ts or '' }}">
<div class="sr-summary" role="button" tabindex="0">
<span class="sr-sym">#{{ s.id }} {{ sym }}</span>
<span class="badge {{ 'direction-long' if s.direction == 'long' else 'direction-short' }}">{{ '做多' if s.direction == 'long' else '做空' }}</span>
<span>{{ s.result_label or '—' }}</span>
<span class="sr-pnl {% if pnl is not none %}{% if pnl|float > 0 %}pos{% elif pnl|float < 0 %}neg{% endif %}{% endif %}">{% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %}</span>
<span class="sr-dca-tag">补仓 {{ s.summary_dca or '—' }}</span>
<span style="color:#8892b0">{{ (s.closed_at or '')[:16] }}</span>
</div>
<div class="sr-detail">
<div class="sr-detail-grid">
<div><div class="lbl">计划 ID</div><div class="val">{{ s.source_id or '—' }}</div></div>
<div><div class="lbl">开仓</div><div class="val">{{ (s.opened_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">结束</div><div class="val">{{ (s.closed_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">均价</div><div class="val">{% if snap.avg_entry_price is not none %}{{ price_fmt(sym, snap.avg_entry_price) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">止损</div><div class="val">{% if snap.stop_loss is not none %}{{ price_fmt(sym, snap.stop_loss) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">止盈</div><div class="val">{% if snap.take_profit is not none %}{{ price_fmt(sym, snap.take_profit) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">风险%</div><div class="val">{{ snap.risk_percent if snap.risk_percent is defined else '—' }}</div></div>
<div><div class="lbl">杠杆</div><div class="val">{{ snap.leverage if snap.leverage is defined else '—' }}x</div></div>
<div><div class="lbl">计划保证金</div><div class="val">{% if snap.plan_margin_capital is not none %}{{ mf(snap.plan_margin_capital) }}U{% else %}—{% endif %}</div></div>
</div>
{% if dca and dca|length %}
<table class="sr-dca-table">
<tr><th>档位</th><th>触发价</th><th>张数</th><th>状态</th></tr>
{% for lv in dca %}
<tr>
<td>{{ lv.label or lv.leg_key }}</td>
<td>{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}</td>
<td>{% if lv.contracts is not none %}{{ lv.contracts }}{% else %}—{% endif %}</td>
<td class="{% if lv.status == 'done' %}st-done{% else %}st-pending{% endif %}">{{ lv.status_label or '—' }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% else %}
<div class="sr-empty sr-empty-default">暂无趋势回调结束记录</div>
{% endfor %}
</div>
</div>
<div class="sr-panel" data-panel="roll">
<div class="sr-panel-head">
<span class="sr-panel-title roll">顺势加仓记录</span>
<span class="sr-panel-count" id="sr-roll-count">{{ strategy_roll_records|length }} 条</span>
</div>
<div class="sr-list" id="sr-roll-list">
{% for s in strategy_roll_records %}
{% set snap = s.snapshot or {} %}
{% set group = snap.group if snap.group is defined else {} %}
{% set legs = snap.legs if snap.legs is defined else [] %}
{% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %}
{% set sym = s.symbol or s.exchange_symbol or snap.symbol or snap.exchange_symbol or '—' %}
<div class="sr-item" data-symbol="{{ s.filter_symbol or '' }}" data-pnl="{{ s.filter_pnl or '' }}" data-dca-tag="{{ s.dca_tag or '' }}" data-dca-done="{{ s.dca_done|default(0) }}" data-sort-ts="{{ s.sort_ts or '' }}">
<div class="sr-summary" role="button" tabindex="0">
<span class="sr-sym">#{{ s.id }} {{ sym }}</span>
<span class="badge {{ 'direction-long' if s.direction == 'long' else 'direction-short' }}">{{ '做多' if s.direction == 'long' else '做空' }}</span>
<span>{{ s.result_label or '—' }}</span>
<span class="sr-pnl {% if pnl is not none %}{% if pnl|float > 0 %}pos{% elif pnl|float < 0 %}neg{% endif %}{% endif %}">{% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %}</span>
<span class="sr-dca-tag">成交 {{ s.summary_dca or '—' }}</span>
<span style="color:#8892b0">{{ (s.closed_at or '')[:16] }}</span>
</div>
<div class="sr-detail">
<div class="sr-detail-grid">
<div><div class="lbl">组 ID</div><div class="val">{{ s.source_id or '—' }}</div></div>
<div><div class="lbl">创建</div><div class="val">{{ (s.opened_at or group.created_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">结束</div><div class="val">{{ (s.closed_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">状态</div><div class="val">{{ s.status_at_close or group.status or '—' }}</div></div>
<div><div class="lbl">杠杆</div><div class="val">{{ group.leverage if group.leverage is defined else '—' }}x</div></div>
<div><div class="lbl">备注</div><div class="val">{{ group.message if group.message is defined else '—' }}</div></div>
</div>
{% if legs and legs|length %}
<table class="sr-dca-table">
<tr><th>腿次</th><th>挂单价</th><th>张数</th><th>状态</th></tr>
{% for leg in legs %}
<tr>
<td>{{ leg.leg_index or loop.index }}</td>
<td>{% if leg.limit_price is not none %}{{ price_fmt(sym, leg.limit_price) }}{% else %}—{% endif %}</td>
<td>{% if leg.order_amount is not none %}{{ leg.order_amount }}{% else %}—{% endif %}</td>
<td>{{ leg.status_label or leg.status or '—' }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% else %}
<div class="sr-empty sr-empty-default">暂无顺势加仓结束记录</div>
{% endfor %}
</div>
</div>
</div>
</div>
<script>
(function(){
const symbolSel = document.getElementById("sr-filter-symbol");
const timeSel = document.getElementById("sr-filter-time");
const chips = document.querySelectorAll(".sr-chip[data-filter]");
const resetBtn = document.getElementById("sr-filter-reset");
const active = new Set();
function itemMatches(el){
const sym = (symbolSel && symbolSel.value) || "";
if(sym && (el.getAttribute("data-symbol")||"").toUpperCase() !== sym.toUpperCase()) return false;
if(active.has("profit") && el.getAttribute("data-pnl") !== "profit") return false;
if(active.has("loss") && el.getAttribute("data-pnl") !== "loss") return false;
if(active.has("no_dca")){
const tag = el.getAttribute("data-dca-tag") || "";
const done = parseInt(el.getAttribute("data-dca-done")||"0",10);
if(tag !== "no_dca" && done > 0) return false;
}
if(active.has("dca")){
const done = parseInt(el.getAttribute("data-dca-done")||"0",10);
if(!(done > 0)) return false;
}
return true;
}
function sortList(listEl){
if(!listEl) return;
const asc = timeSel && timeSel.value === "asc";
const items = Array.from(listEl.querySelectorAll(".sr-item"));
items.sort((a,b)=>{
const ta = a.getAttribute("data-sort-ts") || "";
const tb = b.getAttribute("data-sort-ts") || "";
if(ta === tb) return 0;
return asc ? (ta < tb ? -1 : 1) : (ta > tb ? -1 : 1);
});
items.forEach(it => listEl.appendChild(it));
}
function applyFilters(){
["sr-trend-list","sr-roll-list"].forEach(id=>{
const list = document.getElementById(id);
if(!list) return;
sortList(list);
let visible = 0;
list.querySelectorAll(".sr-item").forEach(el=>{
const ok = itemMatches(el);
el.classList.toggle("sr-hidden", !ok);
if(ok) visible++;
});
const panel = list.closest(".sr-panel");
const cnt = panel && panel.querySelector(".sr-panel-count");
if(cnt) cnt.textContent = visible + " 条";
let empty = list.querySelector(".sr-empty-filter");
if(!visible && !list.querySelector(".sr-empty-default")){
if(!empty){
empty = document.createElement("div");
empty.className = "sr-empty sr-empty-filter";
empty.textContent = "无符合筛选的记录";
list.appendChild(empty);
}
} else if(empty) empty.remove();
});
}
chips.forEach(ch=>{
ch.addEventListener("click", ()=>{
const k = ch.getAttribute("data-filter");
if(active.has(k)) active.delete(k); else active.add(k);
ch.classList.toggle("active", active.has(k));
applyFilters();
});
});
if(symbolSel) symbolSel.addEventListener("change", applyFilters);
if(timeSel) timeSel.addEventListener("change", applyFilters);
if(resetBtn) resetBtn.addEventListener("click", ()=>{
active.clear();
chips.forEach(c=>c.classList.remove("active"));
if(symbolSel) symbolSel.value = "";
if(timeSel) timeSel.value = "desc";
applyFilters();
});
document.querySelectorAll(".sr-summary").forEach(sum=>{
const toggle = ()=>{
const item = sum.closest(".sr-item");
if(item) item.classList.toggle("sr-open");
};
sum.addEventListener("click", toggle);
sum.addEventListener("keydown", e=>{
if(e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
});
});
applyFilters();
})();
</script>
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=4"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>顺势加仓 · {{ exchange_display }}</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
<meta name="theme-color" content="#0b0d14">
</head>
<body>
<div class="container" style="max-width:1100px;margin:0 auto;padding:16px">
{% with messages = get_flashed_messages() %}{% if messages %}<div class="flash">{{ messages[0] }}</div>{% endif %}{% endwith %}
{% include 'strategy_roll_panel.html' %}
<p class="rule-tip" style="margin-top:12px"><a href="/strategy/roll/docs" style="color:#8fc8ff">顺势加仓完整逻辑说明</a></p>
</div>
<script src="/static/strategy_roll.js?v=5"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=4"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>顺势加仓 · 详细说明 · {{ exchange_display }}</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
<meta name="theme-color" content="#0b0d14">
</head>
<body class="roll-doc-page">
<div class="container roll-doc-container">
<div class="doc-nav roll-doc-nav">
<a href="/strategy">← 返回策略交易</a>
&nbsp;·&nbsp;
<a href="/strategy/roll">顺势加仓</a>
</div>
<article class="doc-body roll-doc-body">
{{ doc_html|safe }}
</article>
</div>
</body>
</html>
@@ -0,0 +1,103 @@
<div class="strategy-panel-inner" id="strategy-roll-panel">
<h2 style="margin:0 0 8px">顺势加仓</h2>
<details class="tip-collapse strategy-roll-rule-collapse" open>
<summary class="tip-collapse-summary">顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %}</summary>
<div class="tip-collapse-body rule-tip">
<strong>仅人工提交</strong>;须先在「实盘下单」有同向持仓。仅<strong>以损定仓</strong>模式可用。<br>
做多/做空各最多滚仓 <strong>3</strong> 次(仅计已成交腿);止盈<strong>锁定首仓</strong>不变。<br>
风险比例读取所选监控单,<strong>不可手改</strong>;打到新止损时合并持仓亏损 ≈ 1 个风险单位(当前基数 × 监控 risk%)。<br>
斐波/突破为<strong>程序监控</strong>(mark 价穿越触发),触价后市价加仓;填写后直接点「执行滚仓」(无需预览)。同时仅允许 <strong>1</strong> 条监控中腿,提交后<strong>不可修改</strong>,可删除。<br>
手动平仓后滚仓监控自动结束;<strong>已成交腿历史保留</strong>供复盘。<br>
<a href="/strategy/roll/docs" target="_blank" rel="noopener" class="roll-doc-link">→ 顺势加仓完整逻辑说明</a><br>
{% if roll_trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
</div>
</details>
<div id="roll-risk-banner" class="rule-tip roll-risk-banner">
当前风险:请选择持仓币种
</div>
<form id="roll-form" action="{{ url_for('strategy_roll_execute') }}" method="post" class="form-row" data-add-mode="market">
<select name="symbol" id="roll-symbol" required>
<option value="">选择持仓币种</option>
{% for o in roll_monitors %}
<option value="{{ o.symbol }}"
data-direction="{{ o.direction }}"
data-monitor-id="{{ o.id }}"
data-risk-percent="{{ o.risk_percent or default_risk_percent }}">
{{ o.symbol }} {{ '多' if o.direction=='long' else '空' }} #{{ o.id }} · 风险{{ o.risk_percent or default_risk_percent }}%
</option>
{% endfor %}
</select>
<input type="hidden" name="direction" id="roll-direction" value="long">
<select name="add_mode" id="roll-add-mode" onchange="var f=document.getElementById('roll-form');if(f){f.setAttribute('data-add-mode',this.value);if(window.syncRollFormMode)syncRollFormMode(f,this.value);}">
<option value="market">市价加仓</option>
<option value="fib_618">斐波 0.618</option>
<option value="fib_786">斐波 0.786</option>
<option value="breakout">突破加仓</option>
</select>
<span class="roll-field roll-field-fib">
<input name="fib_upper" id="roll-fib-upper" step="any" placeholder="上沿 H">
<input name="fib_lower" id="roll-fib-lower" step="any" placeholder="下沿 L">
</span>
<span class="roll-field roll-field-breakout">
<input name="breakthrough_price" id="roll-breakout" step="any" placeholder="突破价">
</span>
<input name="new_stop_loss" id="roll-stop-loss" type="number" min="0" step="any" placeholder="新止损价" required>
<button type="button" id="roll-preview-btn" class="roll-preview-btn" {% if roll_trend_active %}disabled{% endif %}>预览</button>
<button type="submit" id="roll-submit-btn" {% if roll_trend_active %}disabled style="opacity:.5" data-trend-locked="1"{% endif %} disabled>执行滚仓</button>
</form>
<div id="roll-preview-box" class="rule-tip roll-preview-box" style="display:none" role="status" aria-live="polite">
<div id="roll-preview-text"></div>
<div id="roll-countdown" class="roll-countdown" style="display:none"></div>
</div>
<h3 class="roll-section-title">活跃滚仓组</h3>
<div class="table-wrap">
<table>
<tr><th>ID</th><th>币种</th><th>方向</th><th>腿数</th><th>首仓TP</th><th>当前SL</th><th>当前均价</th><th>止盈盈利U</th></tr>
{% for g in roll_groups %}
<tr>
<td>{{ g.id }}</td>
<td>{{ g.symbol }}</td>
<td>{{ g.direction }}</td>
<td>{{ g.leg_count }}</td>
<td>{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %}</td>
<td>{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %}</td>
<td>{% if g.avg_entry_display %}{{ g.avg_entry_display }}{% elif g.avg_entry is not none %}{{ g.avg_entry }}{% else %}—{% endif %}</td>
<td>{% if g.reward_at_tp_usdt is not none %}{{ '%.2f'|format(g.reward_at_tp_usdt) }}{% else %}—{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8" style="color:#8892b0">暂无</td></tr>
{% endfor %}
</table>
</div>
<h3 class="roll-section-title">最近滚仓腿</h3>
<div class="table-wrap">
<table>
<tr><th>#</th><th></th><th>方式</th><th>张数</th><th>触发/限价</th><th>新SL</th><th>状态</th><th>操作</th></tr>
{% for leg in roll_legs %}
<tr>
<td>{{ leg.leg_index }}</td>
<td>{{ leg.roll_group_id }}</td>
<td>{{ leg.add_mode }}</td>
<td>{% if leg.amount %}{{ leg.amount }}{% else %}—{% endif %}</td>
<td>{% if leg.limit_price %}{{ leg.limit_price }}{% elif leg.breakthrough_price %}{{ leg.breakthrough_price }}{% elif leg.fill_price %}{{ leg.fill_price }}{% else %}—{% endif %}</td>
<td>{{ leg.new_stop_loss }}</td>
<td>{{ leg.status_label or leg.status }}</td>
<td>
{% if leg.status == 'pending' %}
<form action="{{ url_for('strategy_roll_cancel_leg', leg_id=leg.id) }}" method="post" style="margin:0" onsubmit="return confirm('确认删除本条滚仓监控?')">
<button type="submit" style="padding:2px 8px;font-size:.75rem">删除</button>
</form>
{% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" style="color:#8892b0">暂无</td></tr>
{% endfor %}
</table>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More