refactor: 将共用代码迁入 lib/ 模块化目录
统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""crypto_monitor shared libraries."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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.Row(Row 无 .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
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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()}"
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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(空格格式)比会误判为超出上界。
|
||||
单列时不用 COALESCE(SQLite 要求 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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白。 */
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 四所实例共用 UI:复盘详情、盈亏着色等。
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 交易日历组件:内照明心档案 + 四所统计分析共用。
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 [],
|
||||
}
|
||||
@@ -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 "(暂无资金历史快照)"
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),建议等待,避免新开仓"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}"}
|
||||
@@ -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
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -0,0 +1,187 @@
|
||||
"""实盘/关键位放大 K 线:订单元数据与交易所浮盈、价格展示精度。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.hub.hub_ohlcv_lib import (
|
||||
normalize_price_tick,
|
||||
price_tick_from_market,
|
||||
round_ohlcv_bars_to_tick,
|
||||
)
|
||||
from lib.trade.order_monitor_display_lib import (
|
||||
apply_order_live_price_display,
|
||||
apply_order_price_display_fields,
|
||||
)
|
||||
|
||||
|
||||
def resolve_kline_price_tick(
|
||||
exchange: Any,
|
||||
exchange_symbol: str,
|
||||
*,
|
||||
ensure_markets_fn: Callable[[], None],
|
||||
) -> Optional[float]:
|
||||
"""交易所最小价格变动单位,供 lightweight-charts 右侧刻度与标记线对齐。"""
|
||||
if not exchange_symbol:
|
||||
return None
|
||||
try:
|
||||
ensure_markets_fn()
|
||||
return normalize_price_tick(price_tick_from_market(exchange, exchange_symbol))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def align_candles_to_price_tick(
|
||||
candles: list[dict[str, Any]],
|
||||
price_tick: Optional[float],
|
||||
) -> None:
|
||||
if price_tick is not None and candles:
|
||||
round_ohlcv_bars_to_tick(candles, price_tick)
|
||||
|
||||
|
||||
def kline_api_price_fields(
|
||||
exchange: Any,
|
||||
exchange_symbol: str,
|
||||
candles: list[dict[str, Any]],
|
||||
*,
|
||||
ensure_markets_fn: Callable[[], None],
|
||||
) -> dict[str, Any]:
|
||||
tick = resolve_kline_price_tick(
|
||||
exchange, exchange_symbol, ensure_markets_fn=ensure_markets_fn
|
||||
)
|
||||
align_candles_to_price_tick(candles, tick)
|
||||
return {"price_tick": tick}
|
||||
|
||||
|
||||
def load_swap_positions_for_order_kline(
|
||||
exchange: Any,
|
||||
*,
|
||||
private_configured: bool,
|
||||
ensure_markets_fn: Callable[[], None],
|
||||
settle: str = "usdt",
|
||||
) -> list:
|
||||
if not private_configured:
|
||||
return []
|
||||
try:
|
||||
ensure_markets_fn()
|
||||
try:
|
||||
return exchange.fetch_positions(None, {"settle": settle}) or []
|
||||
except Exception:
|
||||
return exchange.fetch_positions() or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def metrics_for_order_item(
|
||||
order_item: dict[str, Any],
|
||||
positions: list,
|
||||
*,
|
||||
resolve_ex_sym_fn: Callable[[Any], str],
|
||||
select_live_fn: Callable[[list, str, str], Any],
|
||||
parse_metrics_fn: Callable[..., Optional[dict]],
|
||||
) -> Optional[dict]:
|
||||
if not positions:
|
||||
return None
|
||||
ex_sym = resolve_ex_sym_fn(order_item)
|
||||
direction = order_item.get("direction") or "long"
|
||||
prow = select_live_fn(positions, ex_sym, direction)
|
||||
if not prow:
|
||||
return None
|
||||
lev = order_item.get("leverage")
|
||||
return parse_metrics_fn(prow, order_leverage=lev)
|
||||
|
||||
|
||||
def build_order_kline_order_payload(
|
||||
order_item: dict[str, Any],
|
||||
*,
|
||||
ticker_price: Any,
|
||||
format_price_fn: Callable[[Any, Any], str],
|
||||
calc_pnl_fn: Callable[..., float],
|
||||
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
||||
ex_metrics: Optional[dict] = None,
|
||||
) -> dict[str, Any]:
|
||||
sym = order_item.get("symbol") or ""
|
||||
direction = order_item.get("direction") or "long"
|
||||
margin = float(order_item.get("margin_capital") or 0)
|
||||
leverage = float(order_item.get("leverage") or 0)
|
||||
entry = float(order_item.get("trigger_price") or 0)
|
||||
|
||||
float_pnl = 0.0
|
||||
float_pct = 0.0
|
||||
if ticker_price and entry > 0:
|
||||
float_pnl = float(
|
||||
calc_pnl_fn(direction, entry, ticker_price, margin, leverage)
|
||||
)
|
||||
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0.0
|
||||
|
||||
px_for_fmt = ticker_price
|
||||
mark_raw = None
|
||||
if ex_metrics and ex_metrics.get("mark_price") is not None:
|
||||
mark_raw = ex_metrics["mark_price"]
|
||||
try:
|
||||
px_for_fmt = float(mark_raw)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if ex_metrics and ex_metrics.get("unrealized_pnl") is not None:
|
||||
float_pnl = round(float(ex_metrics["unrealized_pnl"]), 2)
|
||||
denom = ex_metrics.get("initial_margin") or margin
|
||||
float_pct = (
|
||||
round((float_pnl / float(denom)) * 100, 4)
|
||||
if denom and float(denom) > 0
|
||||
else float_pct
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"id": order_item["id"],
|
||||
"symbol": sym,
|
||||
"direction": direction,
|
||||
"trigger_price": order_item.get("trigger_price"),
|
||||
"stop_loss": order_item.get("stop_loss"),
|
||||
"take_profit": order_item.get("take_profit"),
|
||||
"trigger_price_display": format_price_fn(sym, order_item.get("trigger_price")),
|
||||
"stop_loss_display": format_price_fn(sym, order_item.get("stop_loss")),
|
||||
"take_profit_display": format_price_fn(sym, order_item.get("take_profit")),
|
||||
"margin_capital": order_item.get("margin_capital"),
|
||||
"leverage": order_item.get("leverage"),
|
||||
"position_ratio": order_item.get("position_ratio"),
|
||||
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
|
||||
"current_price": round(float(px_for_fmt), 8) if px_for_fmt is not None else None,
|
||||
"float_pnl": round(float(float_pnl), 2),
|
||||
"float_pct": float_pct,
|
||||
}
|
||||
apply_order_price_display_fields(
|
||||
payload,
|
||||
direction=direction,
|
||||
entry_price=order_item.get("trigger_price"),
|
||||
initial_stop_loss=order_item.get("initial_stop_loss"),
|
||||
stop_loss=order_item.get("stop_loss"),
|
||||
take_profit=order_item.get("take_profit"),
|
||||
calc_rr_ratio_fn=calc_rr_ratio_fn,
|
||||
)
|
||||
apply_order_live_price_display(
|
||||
payload,
|
||||
sym,
|
||||
ticker_price,
|
||||
mark_raw,
|
||||
format_price_fn,
|
||||
)
|
||||
payload["current_price_display"] = payload.get("price_display") or (
|
||||
format_price_fn(sym, px_for_fmt) if px_for_fmt is not None else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def enrich_key_kline_response(
|
||||
*,
|
||||
symbol: str,
|
||||
current_price: Any,
|
||||
key_info: Optional[dict[str, Any]],
|
||||
format_price_fn: Callable[[Any, Any], str],
|
||||
) -> tuple[Any, Optional[dict[str, Any]]]:
|
||||
price_display = format_price_fn(symbol, current_price) if current_price is not None else None
|
||||
if key_info is None:
|
||||
return price_display, None
|
||||
enriched = dict(key_info)
|
||||
enriched["upper_display"] = format_price_fn(symbol, key_info.get("upper"))
|
||||
enriched["lower_display"] = format_price_fn(symbol, key_info.get("lower"))
|
||||
return price_display, enriched
|
||||
@@ -0,0 +1,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": []}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""中控 iframe 内软导航:服务端跳过重型同步,避免切 tab 等待数秒。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Request
|
||||
|
||||
|
||||
def request_is_hub_soft_nav(req: Request | None = None) -> bool:
|
||||
"""embed=1 且带 X-Instance-Soft-Nav 头:实例页内 fetch 换页,非整页刷新。"""
|
||||
try:
|
||||
from flask import request as flask_request
|
||||
|
||||
r = req or flask_request
|
||||
if str(r.args.get("embed") or "").strip() != "1":
|
||||
return False
|
||||
flag = (r.headers.get("X-Instance-Soft-Nav") or "").strip().lower()
|
||||
return flag in ("1", "true", "yes")
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,452 @@
|
||||
"""交易复盘 / 订单 K 线拼图(Binance / Gate / OKX 共用)。"""
|
||||
|
||||
import math
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
Image = None # type: ignore
|
||||
ImageDraw = None # type: ignore
|
||||
ImageFont = None # type: ignore
|
||||
|
||||
JOURNAL_CHART_TF_CHOICES = ("1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1d")
|
||||
JOURNAL_CHART_DEFAULT_TF1 = "15m"
|
||||
JOURNAL_CHART_DEFAULT_TF2 = "1h"
|
||||
JOURNAL_CHART_DEFAULT_LIMIT = 300
|
||||
JOURNAL_CHART_LIMIT_MIN = 50
|
||||
JOURNAL_CHART_LIMIT_MAX = 500
|
||||
JOURNAL_CHART_ANCHOR_CLOSE = "close"
|
||||
JOURNAL_CHART_ANCHOR_NOW = "now"
|
||||
JOURNAL_CHART_DEFAULT_ANCHOR = JOURNAL_CHART_ANCHOR_CLOSE
|
||||
|
||||
|
||||
def _load_font(size):
|
||||
if not ImageFont:
|
||||
return None
|
||||
for name in ("msyh.ttc", "Microsoft YaHei.ttf", "arial.ttf", "Arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(name, size)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
return ImageFont.load_default()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ohlcv_to_rows(ohlcv):
|
||||
rows = []
|
||||
for bar in ohlcv or []:
|
||||
if not bar or len(bar) < 6:
|
||||
continue
|
||||
try:
|
||||
rows.append(
|
||||
{
|
||||
"ts": int(bar[0]),
|
||||
"o": float(bar[1]),
|
||||
"h": float(bar[2]),
|
||||
"l": float(bar[3]),
|
||||
"c": float(bar[4]),
|
||||
"v": float(bar[5]),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def marker_tag_label(tag):
|
||||
t = str(tag or "").strip().upper()
|
||||
if t == "ENTRY":
|
||||
return "开仓"
|
||||
if t == "EXIT":
|
||||
return "平仓"
|
||||
if t == "STOP":
|
||||
return "止损"
|
||||
return str(tag or "")
|
||||
|
||||
|
||||
def pick_marker_point(rows, target_ts_ms, target_price=None):
|
||||
if not rows or target_ts_ms is None:
|
||||
return None, None
|
||||
idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms)))
|
||||
if target_price is not None:
|
||||
try:
|
||||
p = float(target_price)
|
||||
if p > 0:
|
||||
return idx, p
|
||||
except Exception:
|
||||
pass
|
||||
return idx, float(rows[idx]["c"])
|
||||
|
||||
|
||||
def parse_positive_price(raw):
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
p = float(s)
|
||||
return p if p > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_journal_chart_anchor(raw):
|
||||
s = str(raw or "").strip().lower()
|
||||
if s in (JOURNAL_CHART_ANCHOR_NOW, "current", "当前", "当前时间"):
|
||||
return JOURNAL_CHART_ANCHOR_NOW
|
||||
return JOURNAL_CHART_ANCHOR_CLOSE
|
||||
|
||||
|
||||
def parse_journal_chart_limit(raw, fallback=None):
|
||||
fb = int(fallback if fallback is not None else JOURNAL_CHART_DEFAULT_LIMIT)
|
||||
try:
|
||||
n = int(str(raw or "").strip() or fb)
|
||||
except (TypeError, ValueError):
|
||||
n = fb
|
||||
return max(JOURNAL_CHART_LIMIT_MIN, min(JOURNAL_CHART_LIMIT_MAX, n))
|
||||
|
||||
|
||||
def normalize_chart_timeframe(raw):
|
||||
tf = str(raw or "").strip().lower()
|
||||
if tf in JOURNAL_CHART_TF_CHOICES:
|
||||
return tf
|
||||
return ""
|
||||
|
||||
|
||||
def timeframe_period_ms(tf):
|
||||
s = (tf or "").strip().lower()
|
||||
if s.endswith("m"):
|
||||
try:
|
||||
return int(s[:-1]) * 60 * 1000
|
||||
except ValueError:
|
||||
pass
|
||||
if s.endswith("h"):
|
||||
try:
|
||||
return int(s[:-1]) * 3600 * 1000
|
||||
except ValueError:
|
||||
pass
|
||||
if s.endswith("d"):
|
||||
try:
|
||||
return int(s[:-1]) * 86400 * 1000
|
||||
except ValueError:
|
||||
pass
|
||||
return 300000
|
||||
|
||||
|
||||
def _to_int_ms(value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
v = int(value)
|
||||
return v if v > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def trade_review_fetch_window(entry_ts_ms, exit_ts_ms, timeframe, limit, anchor=None, now_ms=None):
|
||||
"""
|
||||
复盘 K 线窗口(anchor=close):
|
||||
- 有开/平仓:从开仓前若干根起,到平仓 K 线止(覆盖整笔交易 + 入场前背景)
|
||||
- 仅开仓:以开仓时间为终点向前 limit 根
|
||||
- 仅平仓:以平仓时间为终点向前 limit 根
|
||||
anchor=now:以当前时间为终点向前 limit 根(可看平仓后走势)
|
||||
"""
|
||||
period = timeframe_period_ms(timeframe)
|
||||
lim = max(2, int(limit))
|
||||
entry_ms = _to_int_ms(entry_ts_ms)
|
||||
exit_ms = _to_int_ms(exit_ts_ms)
|
||||
anch = (anchor or JOURNAL_CHART_DEFAULT_ANCHOR).strip().lower()
|
||||
|
||||
if anch == JOURNAL_CHART_ANCHOR_NOW:
|
||||
end_ms = _to_int_ms(now_ms)
|
||||
if not end_ms:
|
||||
return None
|
||||
since_ms = end_ms - period * (lim + 10)
|
||||
return {
|
||||
"since_ms": since_ms,
|
||||
"end_ms": end_ms,
|
||||
"window_start_ms": since_ms,
|
||||
"fetch_limit": lim + 20,
|
||||
"display_limit": lim,
|
||||
}
|
||||
|
||||
if entry_ms and exit_ms:
|
||||
if exit_ms < entry_ms:
|
||||
entry_ms, exit_ms = exit_ms, entry_ms
|
||||
span_bars = max(1, (exit_ms - entry_ms) // period + 1)
|
||||
pre_bars = max(40, min(120, lim // 3))
|
||||
need = span_bars + pre_bars
|
||||
fetch_limit = min(JOURNAL_CHART_LIMIT_MAX, max(lim, need + 15))
|
||||
since_ms = entry_ms - period * pre_bars
|
||||
return {
|
||||
"since_ms": since_ms,
|
||||
"end_ms": exit_ms,
|
||||
"window_start_ms": since_ms,
|
||||
"fetch_limit": fetch_limit,
|
||||
"display_limit": lim,
|
||||
}
|
||||
if entry_ms:
|
||||
end_ms = entry_ms
|
||||
since_ms = end_ms - period * (lim + 10)
|
||||
return {
|
||||
"since_ms": since_ms,
|
||||
"end_ms": end_ms,
|
||||
"window_start_ms": since_ms,
|
||||
"fetch_limit": lim + 20,
|
||||
"display_limit": lim,
|
||||
}
|
||||
if exit_ms:
|
||||
end_ms = exit_ms
|
||||
since_ms = end_ms - period * (lim + 10)
|
||||
return {
|
||||
"since_ms": since_ms,
|
||||
"end_ms": end_ms,
|
||||
"window_start_ms": since_ms,
|
||||
"fetch_limit": lim + 20,
|
||||
"display_limit": lim,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def trim_rows_for_trade_review(rows, window):
|
||||
if not window:
|
||||
return list(rows or [])
|
||||
start_ms = int(window["window_start_ms"])
|
||||
end_ms = int(window["end_ms"])
|
||||
lim = int(window["display_limit"])
|
||||
filt = [r for r in (rows or []) if start_ms <= int(r["ts"]) <= end_ms]
|
||||
if len(filt) > lim:
|
||||
filt = filt[-lim:]
|
||||
return filt
|
||||
|
||||
|
||||
def parse_journal_chart_timeframes(tf1, tf2, fallback_tfs=None):
|
||||
"""复盘表单:最多两个周期,去重保序。"""
|
||||
out = []
|
||||
for raw in (tf1, tf2):
|
||||
tf = normalize_chart_timeframe(raw)
|
||||
if tf and tf not in out:
|
||||
out.append(tf)
|
||||
if out:
|
||||
return out[:2]
|
||||
fb = [normalize_chart_timeframe(x) for x in (fallback_tfs or (JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2))]
|
||||
fb = [x for x in fb if x]
|
||||
return fb[:2] if fb else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
|
||||
|
||||
|
||||
def marker_points_for_timeframe(rows, marker_payload):
|
||||
points = []
|
||||
if not marker_payload or not rows:
|
||||
return points
|
||||
entry_idx, entry_price = pick_marker_point(
|
||||
rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")
|
||||
)
|
||||
exit_idx, exit_price = pick_marker_point(
|
||||
rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")
|
||||
)
|
||||
if entry_idx is not None and entry_price is not None:
|
||||
points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"})
|
||||
if exit_idx is not None and exit_price is not None:
|
||||
points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"})
|
||||
return points
|
||||
|
||||
|
||||
def price_levels_from_marker_payload(marker_payload):
|
||||
levels = []
|
||||
if not marker_payload:
|
||||
return levels
|
||||
sl = parse_positive_price(marker_payload.get("stop_loss_price"))
|
||||
if sl is not None:
|
||||
levels.append({"price": sl, "label": "止损", "color": (255, 152, 0)})
|
||||
return levels
|
||||
|
||||
|
||||
def render_candles_subplot(
|
||||
rows,
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
bg_rgb=(255, 255, 255),
|
||||
marker_points=None,
|
||||
price_levels=None,
|
||||
):
|
||||
if not Image or not ImageDraw:
|
||||
raise RuntimeError("缺少依赖:Pillow(pip install Pillow)")
|
||||
img = Image.new("RGB", (width, height), bg_rgb)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = _load_font(14)
|
||||
small = _load_font(12)
|
||||
|
||||
pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28
|
||||
plot_w = max(10, width - pad_l - pad_r)
|
||||
plot_h = max(10, height - pad_t - pad_b)
|
||||
|
||||
header_bg = (245, 247, 250)
|
||||
draw.rectangle((0, 0, width, pad_t), fill=header_bg)
|
||||
if font:
|
||||
draw.text((10, 6), title, fill=(25, 35, 60), font=font)
|
||||
else:
|
||||
draw.text((10, 6), title, fill=(25, 35, 60))
|
||||
|
||||
if not rows:
|
||||
if small:
|
||||
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small)
|
||||
else:
|
||||
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120))
|
||||
return img
|
||||
|
||||
lo = min(r["l"] for r in rows)
|
||||
hi = max(r["h"] for r in rows)
|
||||
for pl in price_levels or []:
|
||||
try:
|
||||
p = float(pl.get("price"))
|
||||
if p > 0:
|
||||
lo = min(lo, p)
|
||||
hi = max(hi, p)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if hi <= lo:
|
||||
hi = lo + 1e-12
|
||||
|
||||
n = len(rows)
|
||||
marker_by_idx = {}
|
||||
for mp in marker_points or []:
|
||||
try:
|
||||
idx = int(mp.get("idx"))
|
||||
except Exception:
|
||||
continue
|
||||
if idx < 0 or idx >= n:
|
||||
continue
|
||||
marker_by_idx.setdefault(idx, []).append(mp)
|
||||
|
||||
x0 = pad_l
|
||||
for i, r in enumerate(rows):
|
||||
x1 = pad_l + int((i + 1) * plot_w / n)
|
||||
x_mid = (x0 + x1) // 2
|
||||
wick_x = x_mid
|
||||
y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h)
|
||||
y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h)
|
||||
y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h)
|
||||
y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h)
|
||||
top = min(y_open, y_close)
|
||||
bot = max(y_open, y_close)
|
||||
up = r["c"] >= r["o"]
|
||||
wick_color = (120, 120, 120)
|
||||
edge_color = (20, 20, 20)
|
||||
draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color)
|
||||
body_w = max(1, (x1 - x0) - 2)
|
||||
left = x0 + 1
|
||||
if bot - top < 2:
|
||||
mid = (top + bot) // 2
|
||||
draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color)
|
||||
else:
|
||||
if up:
|
||||
draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1)
|
||||
else:
|
||||
draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1)
|
||||
for j, mp in enumerate(marker_by_idx.get(i, [])):
|
||||
tag = str(mp.get("tag") or "")
|
||||
label = marker_tag_label(tag)
|
||||
m_price = float(mp.get("price") or r["c"])
|
||||
y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h)
|
||||
y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m))
|
||||
x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14
|
||||
x_draw = int(x_mid + x_off)
|
||||
if tag == "ENTRY":
|
||||
m_color = (0, 195, 95)
|
||||
tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)]
|
||||
text_y = y_m - 36
|
||||
else:
|
||||
m_color = (235, 65, 65)
|
||||
tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)]
|
||||
text_y = y_m + 12
|
||||
draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1)
|
||||
draw.polygon(tri, fill=m_color)
|
||||
draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3)
|
||||
if font:
|
||||
draw.text((x_draw + 8, text_y), label, fill=m_color, font=font)
|
||||
else:
|
||||
draw.text((x_draw + 8, text_y), label, fill=m_color)
|
||||
x0 = x1
|
||||
|
||||
x_right = pad_l + plot_w
|
||||
for pl in price_levels or []:
|
||||
try:
|
||||
p = float(pl.get("price"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if p <= 0:
|
||||
continue
|
||||
y_sl = pad_t + int((hi - p) / (hi - lo) * plot_h)
|
||||
color = tuple(pl.get("color") or (255, 152, 0))
|
||||
label = str(pl.get("label") or "止损")
|
||||
for xx in range(pad_l, x_right, 10):
|
||||
draw.line((xx, y_sl, min(xx + 6, x_right), y_sl), fill=color, width=2)
|
||||
if font:
|
||||
draw.text((x_right - 72, y_sl - 18), label, fill=color, font=small or font)
|
||||
else:
|
||||
draw.text((x_right - 72, y_sl - 18), label, fill=color)
|
||||
|
||||
if len(marker_points or []) >= 2:
|
||||
try:
|
||||
entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None)
|
||||
exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None)
|
||||
if entry is not None and exitp is not None:
|
||||
ex_i, ex_p = int(entry["idx"]), float(entry["price"])
|
||||
xx_i, xx_p = int(exitp["idx"]), float(exitp["price"])
|
||||
x_ex = pad_l + int((ex_i + 0.5) * plot_w / n)
|
||||
x_xx = pad_l + int((xx_i + 0.5) * plot_w / n)
|
||||
y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h)
|
||||
y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h)
|
||||
draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if small:
|
||||
draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small)
|
||||
return img
|
||||
|
||||
|
||||
def compose_chart_panels(panels, layout="grid", cell_w=980, cell_h=520, gap=10):
|
||||
if not panels or not Image:
|
||||
return None
|
||||
if layout == "vertical":
|
||||
cols = 1
|
||||
rows_n = len(panels)
|
||||
else:
|
||||
cols = 2
|
||||
rows_n = int(math.ceil(len(panels) / cols))
|
||||
w = cols * cell_w + (cols - 1) * gap
|
||||
h = rows_n * cell_h + (rows_n - 1) * gap
|
||||
out = Image.new("RGB", (w, h), (255, 255, 255))
|
||||
idx = 0
|
||||
for r in range(rows_n):
|
||||
for c in range(cols):
|
||||
if idx >= len(panels):
|
||||
break
|
||||
x = c * (cell_w + gap)
|
||||
y = r * (cell_h + gap)
|
||||
out.paste(panels[idx], (x, y))
|
||||
idx += 1
|
||||
|
||||
if ImageDraw and layout != "vertical" and rows_n >= 1:
|
||||
draw_out = ImageDraw.Draw(out)
|
||||
line_col = (220, 225, 232)
|
||||
x_mid = cell_w + gap // 2
|
||||
if w > x_mid >= 0:
|
||||
draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2)
|
||||
for rr in range(1, rows_n):
|
||||
y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
|
||||
if 0 <= y_mid <= h:
|
||||
draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
|
||||
elif ImageDraw and layout == "vertical" and rows_n >= 2:
|
||||
draw_out = ImageDraw.Draw(out)
|
||||
line_col = (220, 225, 232)
|
||||
for rr in range(1, rows_n):
|
||||
y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
|
||||
if 0 <= y_mid <= h:
|
||||
draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
|
||||
return out
|
||||
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 %}
|
||||
@@ -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 }} CSV,UTF-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>
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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、下沿 L(H > L)。
|
||||
做多:自 H 向下回撤 ratio,E = H - ratio*(H-L);SL=L,TP=H。
|
||||
做空:自 L 向上反弹 ratio,E = L + ratio*(H-L);SL=H,TP=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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
|
||||
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
|
||||
|
||||
__all__ = ["StrategyExchangeAdapter"]
|
||||
@@ -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"))
|
||||
@@ -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="手动平仓,滚仓监控已结束"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 "做空"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>突破 >{{ r.amp_min_pct }}%;确认在箱外<br>量 >前{{ r.vol_ma_bars }}均×{{ r.vol_ratio_min }}<br>成交 Top{{ r.vol_rank_max }};RR >{{ r.min_rr }}<br>标记价先破反向边界→失效</td>
|
||||
<td class="key-rule-cell">标准:SL 极值外{{ r.stop_outside_pct }}%,TP=E±H<br>1R:SL=E∓H,TP=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=H−rΔ,SL=L,TP=H<br>空:E=L+rΔ,SL=H,TP=L<br>RR >{{ 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 >{{ r.min_rr }};做多 SL<E<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 >{{ r.min_rr }};做多 SL<E<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
·
|
||||
<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
Reference in New Issue
Block a user