增加新账户
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
APP_ENV=production
|
||||||
|
# 服务监听地址(云服务器通常用 0.0.0.0)
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
# 服务端口
|
||||||
|
APP_PORT=5001
|
||||||
|
# 是否开启调试模式(生产建议 false)
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# 登录账号
|
||||||
|
APP_USERNAME=dekun
|
||||||
|
# 登录密码(请改成你自己的强密码)
|
||||||
|
APP_PASSWORD=ChangeMe123!
|
||||||
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
|
APP_AUTH_DISABLED=true
|
||||||
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# 企业微信机器人 Webhook(用于行情/风控推送)
|
||||||
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY
|
||||||
|
|
||||||
|
# 数据库文件路径(相对路径会自动按项目目录解析)
|
||||||
|
DB_PATH=crypto.db
|
||||||
|
# 交易截图上传目录
|
||||||
|
UPLOAD_DIR=static/images
|
||||||
|
|
||||||
|
# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量
|
||||||
|
# TOTAL_CAPITAL=100
|
||||||
|
# 每天起始基数(U)
|
||||||
|
DAILY_START_CAPITAL=30
|
||||||
|
# 日内回撤后基数(U)
|
||||||
|
DAILY_LOSS_CAPITAL=20
|
||||||
|
# 日内盈利后基数(U)
|
||||||
|
DAILY_PROFIT_CAPITAL=50
|
||||||
|
# BTC 默认杠杆倍数
|
||||||
|
BTC_LEVERAGE=10
|
||||||
|
# 山寨币默认杠杆倍数
|
||||||
|
ALT_LEVERAGE=5
|
||||||
|
# 交易日重置小时(北京时间)
|
||||||
|
TRADING_DAY_RESET_HOUR=8
|
||||||
|
# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分)
|
||||||
|
TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||||
|
|
||||||
|
# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单)
|
||||||
|
LIVE_TRADING_ENABLED=true
|
||||||
|
# Binance API Key(需开通合约、万向划转等权限)
|
||||||
|
BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY
|
||||||
|
# Binance API Secret
|
||||||
|
BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET
|
||||||
|
# 保证金模式:cross=全仓,isolated=逐仓
|
||||||
|
BINANCE_MARGIN_MODE=cross
|
||||||
|
# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向
|
||||||
|
BINANCE_POSITION_MODE=hedge
|
||||||
|
# 条件单触发参考价:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价(更易触发时用标记价)
|
||||||
|
BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE
|
||||||
|
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Binance·测试网)
|
||||||
|
# EXCHANGE_DISPLAY_NAME=Binance
|
||||||
|
# 企业微信推送里展示的账户备注
|
||||||
|
# BINANCE_ACCOUNT_LABEL=binance实盘账户
|
||||||
|
|
||||||
|
# 关键位监控:5m收线突破过滤参数
|
||||||
|
KLINE_TIMEFRAME=5m
|
||||||
|
KEY_BREAKOUT_LIMIT_PCT=1.5
|
||||||
|
KEY_ALERT_MAX_TIMES=3
|
||||||
|
KEY_ALERT_INTERVAL_MINUTES=5
|
||||||
|
|
||||||
|
# 资金与仓位刷新周期(秒)
|
||||||
|
BALANCE_REFRESH_SECONDS=60
|
||||||
|
# 后台监控轮询周期(秒)
|
||||||
|
MONITOR_POLL_SECONDS=3
|
||||||
|
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
||||||
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
|
|
||||||
|
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT(ccxt:funding↔swap 等)
|
||||||
|
AUTO_TRANSFER_ENABLED=false
|
||||||
|
AUTO_TRANSFER_AMOUNT=30
|
||||||
|
AUTO_TRANSFER_FROM=funding
|
||||||
|
AUTO_TRANSFER_TO=swap
|
||||||
|
TRANSFER_CCY=USDT
|
||||||
|
# 强制清仓整点(北京时间,默认 0=凌晨00点)
|
||||||
|
FORCE_CLOSE_BJ_HOUR=0
|
||||||
|
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||||
|
FORCE_CLOSE_ENABLED=false
|
||||||
|
|
||||||
|
# 推送与AI超时(秒)
|
||||||
|
WECHAT_TIMEOUT_SECONDS=10
|
||||||
|
AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# AI 复盘服务地址(本机 Ollama 默认地址)
|
||||||
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
|
# AI 模型名称
|
||||||
|
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||||
|
|
||||||
|
# Binance 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口
|
||||||
|
# 1) 先在本机建立隧道(示例):
|
||||||
|
# ssh -N -D 127.0.0.1:1080 user@vps -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes
|
||||||
|
# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名):
|
||||||
|
# BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
#
|
||||||
|
# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用:
|
||||||
|
# BINANCE_HTTP_PROXY=http://127.0.0.1:3128
|
||||||
|
# BINANCE_HTTPS_PROXY=http://127.0.0.1:3128
|
||||||
|
|
||||||
|
# 开仓多周期K线图(可选)
|
||||||
|
# ORDER_CHART_ENABLED=true
|
||||||
|
# ORDER_CHART_TFS=4h,1h,15m,5m
|
||||||
|
# ORDER_CHART_LIMIT=100
|
||||||
|
# ORDER_CHART_DIR=static/images/order_charts
|
||||||
|
# DAILY_OPEN_ALERT_THRESHOLD=5
|
||||||
|
# 以损定仓(按交易账户资金的百分比)
|
||||||
|
# RISK_PERCENT=2
|
||||||
|
# 移动保本触发(达到多少R触发)与偏移(百分比)
|
||||||
|
# BREAKEVEN_RR_TRIGGER=1.0
|
||||||
|
# 移动保本阶梯(每多少R继续上移一次,默认1R)
|
||||||
|
# BREAKEVEN_STEP_R=1.0
|
||||||
|
# BREAKEVEN_OFFSET_PCT=0.02
|
||||||
|
# 开单风格默认值:trend / swing
|
||||||
|
# DEFAULT_TRADE_STYLE=trend
|
||||||
|
|
||||||
|
APP_TIMEZONE=Asia/Shanghai
|
||||||
|
AUTO_TRANSFER_BJ_HOUR=8
|
||||||
|
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# crypto_monitor_binance
|
||||||
|
|
||||||
|
基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Binance(USDT-M 永续)**,通过 **ccxt** 访问。
|
||||||
|
|
||||||
|
## 功能概要
|
||||||
|
|
||||||
|
- **关键位监控**:价格与硬条件校验、企业微信推送(可选)
|
||||||
|
- **下单监控**:本地风控(含移动保本逻辑)、触达止盈/止损后尝试市价平仓并记账
|
||||||
|
- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 `BINANCE_API_KEY` / `BINANCE_API_SECRET` 时,支持合约开仓、平仓、余额读取与内部划转(依赖 API 权限)
|
||||||
|
- **止盈止损(Binance)**:市价成交后挂 **`STOP_MARKET`**(止损)、**`TAKE_PROFIT_MARKET`**(止盈);双向持仓带 `positionSide`;不显式传 `reduceOnly`(避免 API `-1106`)。触发参考价由 `BINANCE_TRIGGER_WORKING_TYPE` 控制(最新价 / 标记价)
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.10+(建议)
|
||||||
|
- 依赖:`flask`、`requests`、`ccxt`、`werkzeug`、`Pillow`(K 线图可选);经 SOCKS 代理时需 **`PySocks`**
|
||||||
|
|
||||||
|
安装示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install flask requests ccxt werkzeug PySocks Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
页面上的 **「当日资金(交易账户)」** 与 **「可开仓」可用 U** 仅统计 **Binance U 本位永续合约账户**(`fetch_balance` 的 `swap` / FAPI `assets` 中的 USDT),**不会**再用现货余额顶替。
|
||||||
|
|
||||||
|
## 配置说明(`.env`)
|
||||||
|
|
||||||
|
项目启动时会加载**项目根目录**下的 `.env`。与 Binance 相关的常用变量:
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `BINANCE_API_KEY` / `BINANCE_API_SECRET` | 币安 API(需合约等权限) |
|
||||||
|
| `LIVE_TRADING_ENABLED` | `true` 时允许真实下单;`false` 仅本地逻辑 |
|
||||||
|
| `BINANCE_MARGIN_MODE` | `cross` 全仓 / `isolated` 逐仓 |
|
||||||
|
| `BINANCE_POSITION_MODE` | `hedge` 双向(需账户开启双向持仓)/ `oneway` 单向 |
|
||||||
|
| `BINANCE_TRIGGER_WORKING_TYPE` | `CONTRACT_PRICE` 或 `MARK_PRICE`(条件单触发参考) |
|
||||||
|
| `BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` | 可选代理(与部署文档一致) |
|
||||||
|
| `EXCHANGE_DISPLAY_NAME` | 页面展示的交易所名称,默认 `Binance` |
|
||||||
|
| `BINANCE_ACCOUNT_LABEL` | 推送文案中的账户备注 |
|
||||||
|
|
||||||
|
其余变量(登录、企业微信、风控参数、数据库路径等)见仓库内 **`.env` 示例注释** 或 `app.py` 顶部默认值。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
**Windows(UTF-8 控制台)** 可使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_utf8.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听端口由 `.env` 的 `APP_PORT` 决定(未设置时多为 `5000`)。
|
||||||
|
|
||||||
|
## 部署(Linux / PM2 / SSH SOCKS)
|
||||||
|
|
||||||
|
详见 **[部署文档.md](./部署文档.md)**(Ubuntu + PM2 + 可选 SOCKS 访问 Binance)。
|
||||||
|
|
||||||
|
## 自检脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/verify_binance_funding.py
|
||||||
|
```
|
||||||
|
|
||||||
|
用于核对 Key 前缀(不含 Secret)并尝试读取资金钱包 / 合约钱包 USDT(需网络与 API 权限)。
|
||||||
|
|
||||||
|
## 数据与脚本
|
||||||
|
|
||||||
|
- 默认 SQLite:`crypto.db`(路径由 `DB_PATH` 指定)
|
||||||
|
- `scripts/fix_breakeven_labels.py`:批量修正「止损」但盈亏为正的记录标签(见部署文档附录)
|
||||||
|
|
||||||
|
## 风险与合规
|
||||||
|
|
||||||
|
实盘交易有亏损风险。请自行确认 API 权限、IP 白名单、杠杆与保证金模式与币安账户设置一致,并遵守当地法律法规与 Binance 用户协议。
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* PM2 进程定义(Ubuntu / Linux)。
|
||||||
|
*
|
||||||
|
* 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**,
|
||||||
|
* 与 `.env` 里 `BINANCE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。
|
||||||
|
*
|
||||||
|
* 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。
|
||||||
|
*
|
||||||
|
* 启动:
|
||||||
|
* pm2 start ecosystem.config.cjs
|
||||||
|
* 保存开机列表:
|
||||||
|
* pm2 save && pm2 startup
|
||||||
|
*/
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const PY = path.join(ROOT, ".venv", "bin", "python");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "crypto_binance",
|
||||||
|
cwd: ROOT,
|
||||||
|
script: path.join(ROOT, "app.py"),
|
||||||
|
interpreter: PY,
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: "800M",
|
||||||
|
// app.py 会从项目根目录加载 .env,此处无需重复 env_file
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
一次性修复历史交易记录标签:
|
||||||
|
将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。
|
||||||
|
|
||||||
|
默认条件(可通过参数修改):
|
||||||
|
- monitor_type = 下单监控
|
||||||
|
- result = 止损
|
||||||
|
- pnl_amount > 0
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
1) 仅预览(不落库):
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
|
||||||
|
2) 执行修复:
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.")
|
||||||
|
parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db")
|
||||||
|
parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)")
|
||||||
|
parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)")
|
||||||
|
parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview only, no write")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Execute update")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
db_path = Path(args.db).expanduser().resolve()
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"[ERR] DB not found: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.dry_run and args.apply:
|
||||||
|
print("[ERR] --dry-run and --apply are mutually exclusive.")
|
||||||
|
return 1
|
||||||
|
if not args.dry_run and not args.apply:
|
||||||
|
print("[INFO] No mode provided, defaulting to --dry-run.")
|
||||||
|
args.dry_run = True
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
where_sql = """
|
||||||
|
monitor_type = ?
|
||||||
|
AND result = ?
|
||||||
|
AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0
|
||||||
|
"""
|
||||||
|
params = (args.monitor_type, args.from_result)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params)
|
||||||
|
will_change = int(cur.fetchone()["c"])
|
||||||
|
print(f"[INFO] Candidate rows: {will_change}")
|
||||||
|
|
||||||
|
if will_change == 0:
|
||||||
|
print("[INFO] Nothing to update.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, symbol, result, pnl_amount, closed_at
|
||||||
|
FROM trade_records
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
sample = cur.fetchall()
|
||||||
|
print("[INFO] Sample (latest 10):")
|
||||||
|
for r in sample:
|
||||||
|
print(
|
||||||
|
f" id={r['id']} symbol={r['symbol']} result={r['result']} "
|
||||||
|
f"pnl={r['pnl_amount']} closed_at={r['closed_at']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("[DRY-RUN] No write executed.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE trade_records SET result=? WHERE {where_sql}",
|
||||||
|
(args.to_result, *params),
|
||||||
|
)
|
||||||
|
changed = int(cur.rowcount)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[DONE] Updated rows: {changed}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
python scripts/verify_binance_funding.py
|
||||||
|
|
||||||
|
打印 BINANCE_API_KEY 前 8 位便于与 Binance 控制台核对(不含 Secret)。用于服务器自检。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, BASE)
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
for line in open(path, "r", encoding="utf-8", errors="ignore"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
k = k.strip().lstrip("\ufeff")
|
||||||
|
if k.replace("_", "").isalnum():
|
||||||
|
os.environ[k] = v.strip().strip('"').strip("'")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
load_env(os.path.join(BASE, ".env"))
|
||||||
|
k = (os.getenv("BINANCE_API_KEY") or "").strip()
|
||||||
|
s = (os.getenv("BINANCE_API_SECRET") or "").strip()
|
||||||
|
if not k or "REPLACE" in k.upper():
|
||||||
|
print("WARN: BINANCE_API_KEY 为空或仍像占位符,请核对 .env")
|
||||||
|
if not s or "REPLACE" in s.upper():
|
||||||
|
print("WARN: BINANCE_API_SECRET 为空或仍像占位符,请核对 .env")
|
||||||
|
print("BINANCE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)")
|
||||||
|
|
||||||
|
import app as mod # noqa: E402
|
||||||
|
|
||||||
|
mod.ensure_markets_loaded()
|
||||||
|
fu = mod._fetch_binance_funding_usdt()
|
||||||
|
print(">>> _fetch_binance_funding_usdt() =", fu)
|
||||||
|
try:
|
||||||
|
sw = mod._fetch_binance_swap_usdt_total()
|
||||||
|
print(">>> _fetch_binance_swap_usdt_total() (合约账户) =", sw)
|
||||||
|
sf = mod._fetch_binance_swap_usdt_free()
|
||||||
|
print(">>> _fetch_binance_swap_usdt_free() (合约可用) =", sf)
|
||||||
|
except Exception as e:
|
||||||
|
print(">>> swap balance fetch error:", e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
ok2
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 关键位放大</title>
|
||||||
|
<style>
|
||||||
|
*{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}
|
||||||
|
.container{width:min(98vw,1900px);margin:0 auto}
|
||||||
|
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150;margin-bottom:12px}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.btn{padding:7px 10px;border-radius:8px;text-decoration:none;border:1px solid #304164;background:#151a2a;color:#8fc8ff;cursor:pointer}
|
||||||
|
.btn:hover{background:#1f2740}
|
||||||
|
input,select,button{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff}
|
||||||
|
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:10px}
|
||||||
|
.meta-item{background:#141b2f;border:1px solid #27324e;border-radius:8px;padding:8px}
|
||||||
|
.meta-item .k{font-size:.76rem;color:#9fb0d8}
|
||||||
|
.meta-item .v{font-size:1rem;margin-top:4px;word-break:break-all}
|
||||||
|
.status{font-size:.84rem;color:#95a2c2}
|
||||||
|
.status.err{color:#ff8080}
|
||||||
|
#chart-wrap{height:580px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
<strong style="color:#dbe4ff">关键位放大(可输入币种)</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"><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"><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>
|
||||||
|
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 fmt = (v,d=6)=>(v===null||typeof v==="undefined"||Number.isNaN(Number(v)))?"-":Number(v).toFixed(d);
|
||||||
|
const fmtSigned = (v,d=4)=>{
|
||||||
|
if(v===null||typeof v==="undefined"||Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(v);
|
||||||
|
return `${n>0?"+":""}${n.toFixed(d)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
const keyMap = {};
|
||||||
|
{% for k in key_list %}
|
||||||
|
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart && candleSeries) return true;
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!chart){
|
||||||
|
chart = LightweightCharts.createChart(chartHost, {
|
||||||
|
layout:{background:{color:"#0f1320"},textColor:"#d6deff"},
|
||||||
|
grid:{vertLines:{color:"#1e263d"},horzLines:{color:"#1e263d"}},
|
||||||
|
rightPriceScale:{borderColor:"#2a3150"},
|
||||||
|
timeScale:{borderColor:"#2a3150",timeVisible:true,secondsVisible:false},
|
||||||
|
crosshair:{mode:0}
|
||||||
|
});
|
||||||
|
window.addEventListener("resize",()=>{
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
});
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
};
|
||||||
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
|
candleSeries = chart.addCandlestickSeries(opts);
|
||||||
|
} else if (typeof chart.addSeries === "function" && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "K线序列初始化失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries) return;
|
||||||
|
priceLines.forEach(line=>{ try { candleSeries.removePriceLine(line); } catch (_) {} });
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || price===null || typeof price==="undefined") return;
|
||||||
|
const p = Number(price);
|
||||||
|
if(Number.isNaN(p) || p<=0) return;
|
||||||
|
priceLines.push(candleSeries.createPriceLine({
|
||||||
|
price:p,color,lineWidth:1,lineStyle:0,axisLabelVisible:true,title
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMeta(data){
|
||||||
|
const key = data.key_monitor || null;
|
||||||
|
document.getElementById("m-symbol").innerText = data.symbol || "-";
|
||||||
|
document.getElementById("m-price").innerText = fmt(data.current_price,8);
|
||||||
|
|
||||||
|
if(!key){
|
||||||
|
document.getElementById("m-type").innerText = "未匹配到关键位";
|
||||||
|
document.getElementById("m-direction").innerText = "-";
|
||||||
|
document.getElementById("m-upper").innerText = "-";
|
||||||
|
document.getElementById("m-lower").innerText = "-";
|
||||||
|
document.getElementById("m-updiff").innerText = "-";
|
||||||
|
document.getElementById("m-lowdiff").innerText = "-";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("m-type").innerText = key.monitor_type || "-";
|
||||||
|
document.getElementById("m-direction").innerText = key.direction === "short" ? "做空" : "做多";
|
||||||
|
document.getElementById("m-upper").innerText = fmt(key.upper,8);
|
||||||
|
document.getElementById("m-lower").innerText = fmt(key.lower,8);
|
||||||
|
document.getElementById("m-updiff").innerText = `${fmtSigned(key.upper_diff,4)} (${fmtSigned(key.upper_pct,2)}%)`;
|
||||||
|
document.getElementById("m-lowdiff").innerText = `${fmtSigned(key.lower_diff,4)} (${fmtSigned(key.lower_pct,2)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "请求失败");
|
||||||
|
|
||||||
|
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||||
|
if(!candles.length){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "暂无K线数据";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries) throw new Error("Series init failed");
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.current_price, "现价", "#42a5f5");
|
||||||
|
if(data.key_monitor){
|
||||||
|
addLine(data.key_monitor.upper, "上沿/阻力", "#ffb84d");
|
||||||
|
addLine(data.key_monitor.lower, "下沿/支撑", "#4cd97f");
|
||||||
|
}
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintMeta(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,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>登录 · {{ exchange_display }}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #0a0a10;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.login-box {
|
||||||
|
background: #12121a;
|
||||||
|
padding: 2.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border: 1px solid #242435;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.login-box h2 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
background: linear-gradient(90deg, #4cc2ff, #7b42ff);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #a9a9ff;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #2e2e45;
|
||||||
|
background: #1a1a29;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: #4cc2ff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(90deg, #4285f4, #7b42ff);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.flash {
|
||||||
|
padding: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #331e24;
|
||||||
|
color: #ff6666;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.exchange-line {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #8892b0;
|
||||||
|
margin: -0.5rem 0 1.25rem;
|
||||||
|
}
|
||||||
|
.exchange-line strong {
|
||||||
|
color: #b8f5d0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-box">
|
||||||
|
<h2>交易监控系统登录</h2>
|
||||||
|
<p class="exchange-line">交易所:<strong>{{ exchange_display }}</strong></p>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>账号</label>
|
||||||
|
<input type="text" name="username" required placeholder="请输入账号">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" required placeholder="请输入密码">
|
||||||
|
</div>
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>实盘下单放大 | 100根K线</title>
|
||||||
|
<style>
|
||||||
|
*{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}
|
||||||
|
.container{width:min(98vw,1900px);margin:0 auto}
|
||||||
|
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150;margin-bottom:12px}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.btn{padding:7px 10px;border-radius:8px;text-decoration:none;border:1px solid #304164;background:#151a2a;color:#8fc8ff;cursor:pointer}
|
||||||
|
.btn:hover{background:#1f2740}
|
||||||
|
select,button{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff}
|
||||||
|
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:10px}
|
||||||
|
.meta-item{background:#141b2f;border:1px solid #27324e;border-radius:8px;padding:8px}
|
||||||
|
.meta-item .k{font-size:.76rem;color:#9fb0d8}
|
||||||
|
.meta-item .v{font-size:1rem;margin-top:4px;word-break:break-all}
|
||||||
|
.status{font-size:.84rem;color:#95a2c2}
|
||||||
|
.status.err{color:#ff8080}
|
||||||
|
#chart-wrap{height:560px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.empty{padding:18px;color:#95a2c2}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
<strong style="color:#dbe4ff">实盘下单放大(100根K线)</strong>
|
||||||
|
</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"><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-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-price">-</div></div>
|
||||||
|
<div class="meta-item"><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/dist/lightweight-charts.standalone.production.js"></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 fmt = (v, d=6) => (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) ? "-" : Number(v).toFixed(d);
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart){ return true; }
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
chart = LightweightCharts.createChart(chartHost, {
|
||||||
|
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
|
||||||
|
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
|
||||||
|
rightPriceScale: { borderColor: "#2a3150" },
|
||||||
|
timeScale: { borderColor: "#2a3150", timeVisible: true, secondsVisible: false },
|
||||||
|
crosshair: { mode: 0 }
|
||||||
|
});
|
||||||
|
candleSeries = chart.addCandlestickSeries({
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
});
|
||||||
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries){ return; }
|
||||||
|
priceLines.forEach(line => {
|
||||||
|
try { candleSeries.removePriceLine(line); } catch (_) {}
|
||||||
|
});
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || typeof price === "undefined" || price === null){ return; }
|
||||||
|
const p = Number(price);
|
||||||
|
if(Number.isNaN(p) || p <= 0){ return; }
|
||||||
|
priceLines.push(candleSeries.createPriceLine({
|
||||||
|
price: p, color, lineWidth: 1, lineStyle: 0, axisLabelVisible: true, title
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintOrder(order){
|
||||||
|
document.getElementById("m-symbol").innerText = order.symbol || "-";
|
||||||
|
document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多";
|
||||||
|
document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8);
|
||||||
|
document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8);
|
||||||
|
document.getElementById("m-tp").innerText = fmt(order.take_profit, 8);
|
||||||
|
document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`;
|
||||||
|
document.getElementById("m-price").innerText = fmt(order.current_price, 8);
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`;
|
||||||
|
pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "请求失败"); }
|
||||||
|
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||||
|
if(!candles.length){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "暂无K线数据";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.order.trigger_price, "成交价", "#42a5f5");
|
||||||
|
addLine(data.order.stop_loss, "止损", "#ff6666");
|
||||||
|
addLine(data.order.take_profit, "止盈", "#4cd97f");
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintOrder(data.order || {});
|
||||||
|
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,214 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 实盘下单放大</title>
|
||||||
|
<style>
|
||||||
|
*{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}
|
||||||
|
.container{width:min(98vw,1900px);margin:0 auto}
|
||||||
|
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150;margin-bottom:12px}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.btn{padding:7px 10px;border-radius:8px;text-decoration:none;border:1px solid #304164;background:#151a2a;color:#8fc8ff;cursor:pointer}
|
||||||
|
.btn:hover{background:#1f2740}
|
||||||
|
select,button{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff}
|
||||||
|
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:10px}
|
||||||
|
.meta-item{background:#141b2f;border:1px solid #27324e;border-radius:8px;padding:8px}
|
||||||
|
.meta-item .k{font-size:.76rem;color:#9fb0d8}
|
||||||
|
.meta-item .v{font-size:1rem;margin-top:4px;word-break:break-all}
|
||||||
|
.status{font-size:.84rem;color:#95a2c2}
|
||||||
|
.status.err{color:#ff8080}
|
||||||
|
#chart-wrap{height:560px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.empty{padding:18px;color:#95a2c2}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
<strong style="color:#dbe4ff">实盘下单放大(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"><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-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"><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/dist/lightweight-charts.standalone.production.js"></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 fmt = (v, d=6) => (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) ? "-" : Number(v).toFixed(d);
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart){ return true; }
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
chart = LightweightCharts.createChart(chartHost, {
|
||||||
|
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
|
||||||
|
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
|
||||||
|
rightPriceScale: { borderColor: "#2a3150" },
|
||||||
|
timeScale: { borderColor: "#2a3150", timeVisible: true, secondsVisible: false },
|
||||||
|
crosshair: { mode: 0 }
|
||||||
|
});
|
||||||
|
candleSeries = chart.addCandlestickSeries({
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
});
|
||||||
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries){ return; }
|
||||||
|
priceLines.forEach(line => {
|
||||||
|
try { candleSeries.removePriceLine(line); } catch (_) {}
|
||||||
|
});
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || typeof price === "undefined" || price === null){ return; }
|
||||||
|
const p = Number(price);
|
||||||
|
if(Number.isNaN(p) || p <= 0){ return; }
|
||||||
|
priceLines.push(candleSeries.createPriceLine({
|
||||||
|
price: p, color, lineWidth: 1, lineStyle: 0, axisLabelVisible: true, title
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintOrder(order){
|
||||||
|
document.getElementById("m-symbol").innerText = order.symbol || "-";
|
||||||
|
document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多";
|
||||||
|
document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8);
|
||||||
|
document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8);
|
||||||
|
document.getElementById("m-tp").innerText = fmt(order.take_profit, 8);
|
||||||
|
document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`;
|
||||||
|
document.getElementById("m-breakeven").innerText =
|
||||||
|
(order.breakeven_enabled === false || order.breakeven_enabled === 0) ? "关闭" : "开启";
|
||||||
|
document.getElementById("m-price").innerText = fmt(order.current_price, 8);
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`;
|
||||||
|
pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "请求失败"); }
|
||||||
|
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||||
|
if(!candles.length){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "暂无K线数据";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.order.trigger_price, "成交价", "#42a5f5");
|
||||||
|
addLine(data.order.stop_loss, "止损", "#ff6666");
|
||||||
|
addLine(data.order.take_profit, "止盈", "#4cd97f");
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintOrder(data.order || {});
|
||||||
|
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 %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if (typeof ensureChart !== 'function') return;
|
||||||
|
const oldEnsureChart = ensureChart;
|
||||||
|
ensureChart = function(){
|
||||||
|
if (chart && candleSeries) return true;
|
||||||
|
try { const ok = oldEnsureChart(); if (ok && candleSeries) return true; } catch (_) {}
|
||||||
|
if (chart && !candleSeries && typeof chart.addSeries === 'function' && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
const opts = { upColor:'#4cd97f', downColor:'#ff6666', borderVisible:false, wickUpColor:'#4cd97f', wickDownColor:'#ff6666' };
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
return !!candleSeries;
|
||||||
|
}
|
||||||
|
return !!candleSeries;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# `crypto_monitor_binance` 部署指南:SSH SOCKS + Binance + PM2(Ubuntu)
|
||||||
|
|
||||||
|
项目功能、环境变量总览与本地运行说明见 **[README.md](./README.md)**。
|
||||||
|
|
||||||
|
本文面向:**在本机或 VPS 上运行本项目**,但 **直连 Binance API 不稳定、超时或被网络策略拦截** 的场景。思路是:
|
||||||
|
|
||||||
|
- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能稳定访问 Binance 的机器(常见为一台境外 VPS)
|
||||||
|
- 项目在 `.env` 中设置 **`BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所
|
||||||
|
- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2**
|
||||||
|
- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 默认进程名为 **`crypto-monitor-binance`**
|
||||||
|
|
||||||
|
> 安全提醒:不要把 `.env`、私钥 `.pem`、Binance API Key / Secret 提交到 Git;下文只用占位符。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 你需要准备的东西
|
||||||
|
|
||||||
|
- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」)
|
||||||
|
- 一台可 SSH 登录、且 **能正常访问 Binance API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`)
|
||||||
|
- SSH:**私钥登录**(推荐,便于隧道脚本无人值守)
|
||||||
|
- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2)
|
||||||
|
- Binance 账户:已开通 **USDT-M 永续合约**;API Key 勾选 **合约**、**万向划转**(若使用资金↔合约划转)等所需权限,并配置 **IP 白名单**(若启用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 获取代码与目录
|
||||||
|
|
||||||
|
将包含 `app.py` 的项目放到固定目录,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/apps
|
||||||
|
cd ~/apps
|
||||||
|
# git clone ... 或解压同步的包
|
||||||
|
cd crypto_monitor_binance
|
||||||
|
```
|
||||||
|
|
||||||
|
下文用 **`/root/crypto_monitor_binance`** 仅为示例,请换成你的实际绝对路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配置 SSH 私钥与 `~/.ssh/config`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
# 私钥示例:~/.ssh/vps1.pem
|
||||||
|
chmod 600 ~/.ssh/vps1.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `~/.ssh/config`(示例别名 **`bn-vps`**,与你手工启动 `ssh -D ... bn-vps` 一致即可):
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host bn-vps
|
||||||
|
HostName 你的_VPS_IP
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/vps1.pem
|
||||||
|
IdentitiesOnly yes
|
||||||
|
ServerAliveInterval 30
|
||||||
|
ServerAliveCountMax 3
|
||||||
|
ExitOnForwardFailure yes
|
||||||
|
BatchMode yes
|
||||||
|
```
|
||||||
|
|
||||||
|
测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh bn-vps true
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 手工验证:SSH SOCKS + Binance API
|
||||||
|
|
||||||
|
### 3.1 本地 SOCKS(示例端口 1080)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 bn-vps
|
||||||
|
```
|
||||||
|
|
||||||
|
保持运行,另开终端继续。
|
||||||
|
|
||||||
|
### 3.2 验证经 SOCKS 可访问 Binance(公开接口)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time
|
||||||
|
```
|
||||||
|
|
||||||
|
应返回 JSON(含 `serverTime` 字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Python 虚拟环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_binance
|
||||||
|
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install -U pip
|
||||||
|
pip install flask requests ccxt werkzeug PySocks Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。
|
||||||
|
|
||||||
|
可选:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 配置 `.env`(关键:Binance + 代理)
|
||||||
|
|
||||||
|
项目通过 `app.py` 启动时 **自动加载项目根目录的 `.env`**。与交易所相关的变量使用 **`BINANCE_`** 前缀(与代码一致)。
|
||||||
|
|
||||||
|
至少确认:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_HOST=127.0.0.1
|
||||||
|
APP_PORT=5000
|
||||||
|
|
||||||
|
# 实盘(按需)
|
||||||
|
LIVE_TRADING_ENABLED=false
|
||||||
|
BINANCE_API_KEY=你的_Key
|
||||||
|
BINANCE_API_SECRET=你的_Secret
|
||||||
|
|
||||||
|
# 保证金:cross=全仓 isolated=逐仓(与币安账户/习惯一致)
|
||||||
|
BINANCE_MARGIN_MODE=cross
|
||||||
|
|
||||||
|
# 持仓模式:hedge=双向(需在币安开启双向持仓);oneway=单向
|
||||||
|
BINANCE_POSITION_MODE=hedge
|
||||||
|
|
||||||
|
# 条件单触发参考:CONTRACT_PRICE=最新成交价 MARK_PRICE=标记价
|
||||||
|
BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE
|
||||||
|
|
||||||
|
# 经本机 SSH 动态转发访问 Binance(端口与隧道一致)
|
||||||
|
BINANCE_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
|
||||||
|
# 若不用 SOCKS,可改用 HTTP 代理(一般二选一)
|
||||||
|
# BINANCE_HTTP_PROXY=http://127.0.0.1:7890
|
||||||
|
# BINANCE_HTTPS_PROXY=http://127.0.0.1:7890
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。
|
||||||
|
|
||||||
|
**止盈止损说明(应用逻辑)**:实盘开仓后,程序会在 Binance USDT-M 永续上挂 **`STOP_MARKET`(止损)** 与 **`TAKE_PROFIT_MARKET`(止盈)**;`BINANCE_POSITION_MODE=hedge` 时会自动带 **`positionSide`**,须与币安合约「双向持仓」开关一致。不显式传 **`reduceOnly`**(否则易触发 API **`-1106`**:`Parameter 'reduceOnly' sent when not required`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 自检脚本(可选)
|
||||||
|
|
||||||
|
在已配置 `.env` 且网络可达的前提下:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_binance
|
||||||
|
source .venv/bin/activate
|
||||||
|
python scripts/verify_binance_funding.py
|
||||||
|
```
|
||||||
|
|
||||||
|
用于粗测资金钱包与合约钱包 USDT 读取(需有效 API 与权限)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 手工启动 Flask(验证)
|
||||||
|
|
||||||
|
1. SOCKS 已监听 `127.0.0.1:1080`(若使用代理)
|
||||||
|
2. 已 `source .venv/bin/activate`
|
||||||
|
3. `.env` 已按需配置 `BINANCE_SOCKS_PROXY` 等
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_binance
|
||||||
|
source .venv/bin/activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 安装 PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo npm i -g pm2
|
||||||
|
pm2 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. PM2:使用仓库内 `ecosystem.config.cjs`(推荐)
|
||||||
|
|
||||||
|
在项目根目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_binance
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
pm2 status
|
||||||
|
pm2 logs --lines 200
|
||||||
|
```
|
||||||
|
|
||||||
|
默认只启动 **`crypto-monitor-binance`**(`.venv/bin/python app.py`)。
|
||||||
|
|
||||||
|
### 本机已可直连 Binance、不需要隧道时
|
||||||
|
|
||||||
|
`.env` 里应 **去掉或留空** `BINANCE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。
|
||||||
|
|
||||||
|
### 开机自启
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
# 按屏幕提示执行一条 sudo 命令
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 等价手工命令(不使用 ecosystem 文件时)
|
||||||
|
|
||||||
|
### 10.1 SSH SOCKS(自行后台常驻,不推荐用 PM2)
|
||||||
|
|
||||||
|
示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 bn-vps \
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
|
||||||
|
-o ExitOnForwardFailure=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Flask
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_binance
|
||||||
|
pm2 start /root/crypto_monitor_binance/.venv/bin/python --name crypto-monitor-binance -- \
|
||||||
|
/root/crypto_monitor_binance/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 交易所「连接不上」排查清单
|
||||||
|
|
||||||
|
1. **`.env` 是否为 Binance 变量**:`BINANCE_SOCKS_PROXY` / `BINANCE_HTTP_PROXY` / `BINANCE_API_KEY` / `BINANCE_API_SECRET` 等前缀需与代码一致。
|
||||||
|
2. **隧道是否在本机端口监听**(若配置了 `BINANCE_SOCKS_PROXY`):
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep 1080 || true
|
||||||
|
```
|
||||||
|
3. **curl 复测 Binance**(与第 3.2 节相同);curl 不通则应用也不会通。
|
||||||
|
4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。
|
||||||
|
5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。
|
||||||
|
6. **API 权限与 IP 白名单**:Secret 错误、权限不足、未放行当前出口 IP 时,私有接口会失败。
|
||||||
|
7. **启动顺序**:若走代理,先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 推荐启动顺序(习惯)
|
||||||
|
|
||||||
|
1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.binance.com/api/v3/time` 成功
|
||||||
|
2. `pm2 start ecosystem.config.cjs`
|
||||||
|
3. 再确认页面与余额等接口正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 免责声明
|
||||||
|
|
||||||
|
交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py`
|
||||||
|
|
||||||
|
在 Ubuntu 上:
|
||||||
|
|
||||||
|
1)预览(不写库):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
2)确认后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。
|
||||||
Binary file not shown.
@@ -19,7 +19,7 @@ const PY = path.join(ROOT, ".venv", "bin", "python");
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "crypto-monitor-gate",
|
name: "crypto_gate",
|
||||||
cwd: ROOT,
|
cwd: ROOT,
|
||||||
script: path.join(ROOT, "app.py"),
|
script: path.join(ROOT, "app.py"),
|
||||||
interpreter: PY,
|
interpreter: PY,
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
APP_ENV=production
|
||||||
|
# 服务监听地址(云服务器通常用 0.0.0.0)
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
# 服务端口
|
||||||
|
APP_PORT=5002
|
||||||
|
# 是否开启调试模式(生产建议 false)
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# 登录账号
|
||||||
|
APP_USERNAME=dekun
|
||||||
|
# 登录密码(请改成你自己的强密码)
|
||||||
|
APP_PASSWORD=ChangeMe123!
|
||||||
|
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||||
|
APP_AUTH_DISABLED=true
|
||||||
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# 企业微信机器人 Webhook(用于行情/风控推送)
|
||||||
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY
|
||||||
|
|
||||||
|
# 数据库文件路径(相对路径会自动按项目目录解析)
|
||||||
|
DB_PATH=crypto.db
|
||||||
|
# 交易截图上传目录
|
||||||
|
UPLOAD_DIR=static/images
|
||||||
|
|
||||||
|
# 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量
|
||||||
|
# TOTAL_CAPITAL=100
|
||||||
|
# 每天起始基数(U)
|
||||||
|
DAILY_START_CAPITAL=30
|
||||||
|
# 日内回撤后基数(U)
|
||||||
|
DAILY_LOSS_CAPITAL=20
|
||||||
|
# 日内盈利后基数(U)
|
||||||
|
DAILY_PROFIT_CAPITAL=50
|
||||||
|
# BTC 默认杠杆倍数
|
||||||
|
BTC_LEVERAGE=10
|
||||||
|
# 山寨币默认杠杆倍数
|
||||||
|
ALT_LEVERAGE=5
|
||||||
|
# 交易日重置小时(北京时间)
|
||||||
|
TRADING_DAY_RESET_HOUR=8
|
||||||
|
# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分)
|
||||||
|
TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
|
||||||
|
|
||||||
|
# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单)
|
||||||
|
LIVE_TRADING_ENABLED=true
|
||||||
|
# Gate API Key(实盘)
|
||||||
|
GATE_API_KEY=REPLACE_WITH_GATE_API_KEY
|
||||||
|
# Gate API Secret(实盘)
|
||||||
|
GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET
|
||||||
|
# 保证金模式:cross=全仓,isolated=逐仓
|
||||||
|
GATE_TD_MODE=cross
|
||||||
|
# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤
|
||||||
|
GATE_POS_MODE=hedge
|
||||||
|
# 永续止盈止损:是否优先用官方仓位类触发单(POST price_orders,close-*-position);false=仅用旧版两张 ccxt 条件单
|
||||||
|
GATE_TPSL_USE_POSITION_ORDER=true
|
||||||
|
# 触发单超时(秒),默认 604800=7 天;设为 0 或负数则不向 API 传 expiration
|
||||||
|
GATE_TPSL_TRIGGER_EXPIRATION=604800
|
||||||
|
# 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0)
|
||||||
|
GATE_TPSL_PRICE_TYPE=0
|
||||||
|
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟)
|
||||||
|
# EXCHANGE_DISPLAY_NAME=Gate.io
|
||||||
|
|
||||||
|
# 关键位监控:5m收线突破过滤参数
|
||||||
|
KLINE_TIMEFRAME=5m
|
||||||
|
KEY_BREAKOUT_LIMIT_PCT=1.5
|
||||||
|
KEY_ALERT_MAX_TIMES=3
|
||||||
|
KEY_ALERT_INTERVAL_MINUTES=5
|
||||||
|
|
||||||
|
# 资金与仓位刷新周期(秒)
|
||||||
|
BALANCE_REFRESH_SECONDS=60
|
||||||
|
# 后台监控轮询周期(秒)
|
||||||
|
MONITOR_POLL_SECONDS=3
|
||||||
|
# 使用可用资金时的缓冲比例(如0.98代表用98%)
|
||||||
|
FULL_MARGIN_BUFFER_RATIO=0.98
|
||||||
|
|
||||||
|
# 自动划转:将目标账户补足到 AUTO_TRANSFER_AMOUNT
|
||||||
|
AUTO_TRANSFER_ENABLED=false
|
||||||
|
AUTO_TRANSFER_AMOUNT=30
|
||||||
|
AUTO_TRANSFER_FROM=funding
|
||||||
|
AUTO_TRANSFER_TO=swap
|
||||||
|
TRANSFER_CCY=USDT
|
||||||
|
# 强制清仓整点(北京时间,默认 0=凌晨00点)
|
||||||
|
FORCE_CLOSE_BJ_HOUR=0
|
||||||
|
# 是否启用强制清仓(默认关闭,true 才会在整点执行)
|
||||||
|
FORCE_CLOSE_ENABLED=false
|
||||||
|
|
||||||
|
# 推送与AI超时(秒)
|
||||||
|
WECHAT_TIMEOUT_SECONDS=10
|
||||||
|
AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# AI 复盘服务地址(本机 Ollama 默认地址)
|
||||||
|
OLLAMA_API=http://127.0.0.1:11434/api/generate
|
||||||
|
# AI 模型名称
|
||||||
|
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||||
|
|
||||||
|
# Gate 代理(可选):本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口
|
||||||
|
# 1) 先在本机建立隧道(示例):
|
||||||
|
# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes
|
||||||
|
# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名):
|
||||||
|
# GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
#
|
||||||
|
# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用:
|
||||||
|
# GATE_HTTP_PROXY=http://127.0.0.1:3128
|
||||||
|
# GATE_HTTPS_PROXY=http://127.0.0.1:3128
|
||||||
|
|
||||||
|
# 开仓多周期K线图(可选)
|
||||||
|
# ORDER_CHART_ENABLED=true
|
||||||
|
# ORDER_CHART_TFS=4h,1h,15m,5m
|
||||||
|
# ORDER_CHART_LIMIT=100
|
||||||
|
# ORDER_CHART_DIR=static/images/order_charts
|
||||||
|
# DAILY_OPEN_ALERT_THRESHOLD=5
|
||||||
|
# 以损定仓(按交易账户资金的百分比)
|
||||||
|
# RISK_PERCENT=2
|
||||||
|
# 移动保本触发(达到多少R触发)与偏移(百分比)
|
||||||
|
# BREAKEVEN_RR_TRIGGER=1.0
|
||||||
|
# 移动保本阶梯(每多少R继续上移一次,默认1R)
|
||||||
|
# BREAKEVEN_STEP_R=1.0
|
||||||
|
# BREAKEVEN_OFFSET_PCT=0.02
|
||||||
|
# 开单风格默认值:trend / swing
|
||||||
|
# DEFAULT_TRADE_STYLE=trend
|
||||||
|
|
||||||
|
APP_TIMEZONE=Asia/Shanghai
|
||||||
|
AUTO_TRANSFER_BJ_HOUR=8
|
||||||
|
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* PM2 进程定义(Ubuntu / Linux)。
|
||||||
|
*
|
||||||
|
* 仅托管 Flask 应用。**SSH SOCKS 隧道请在本机用 screen/tmux/systemd 等方式单独常驻**,
|
||||||
|
* 与 `.env` 里 `GATE_SOCKS_PROXY` 端口一致即可;不必交给 PM2。
|
||||||
|
*
|
||||||
|
* 使用前:项目根目录存在 `.venv`,且已安装依赖(走 SOCKS 时需 PySocks)。
|
||||||
|
*
|
||||||
|
* 启动:
|
||||||
|
* pm2 start ecosystem.config.cjs
|
||||||
|
* 保存开机列表:
|
||||||
|
* pm2 save && pm2 startup
|
||||||
|
*/
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const PY = path.join(ROOT, ".venv", "bin", "python");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "crypto_gate_bot",
|
||||||
|
cwd: ROOT,
|
||||||
|
script: path.join(ROOT, "app.py"),
|
||||||
|
interpreter: PY,
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: "800M",
|
||||||
|
// app.py 会从项目根目录加载 .env,此处无需重复 env_file
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
一次性修复历史交易记录标签:
|
||||||
|
将 trade_records 里“止损但实际盈利”的记录改为“保本止盈”。
|
||||||
|
|
||||||
|
默认条件(可通过参数修改):
|
||||||
|
- monitor_type = 下单监控
|
||||||
|
- result = 止损
|
||||||
|
- pnl_amount > 0
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
1) 仅预览(不落库):
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
|
||||||
|
2) 执行修复:
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Fix historical stop-loss records with positive pnl.")
|
||||||
|
parser.add_argument("--db", required=True, help="Path to sqlite db file, e.g. ./crypto.db")
|
||||||
|
parser.add_argument("--monitor-type", default="下单监控", help="Filter by monitor_type (default: 下单监控)")
|
||||||
|
parser.add_argument("--from-result", default="止损", help="Source result label (default: 止损)")
|
||||||
|
parser.add_argument("--to-result", default="保本止盈", help="Target result label (default: 保本止盈)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview only, no write")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Execute update")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
db_path = Path(args.db).expanduser().resolve()
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"[ERR] DB not found: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.dry_run and args.apply:
|
||||||
|
print("[ERR] --dry-run and --apply are mutually exclusive.")
|
||||||
|
return 1
|
||||||
|
if not args.dry_run and not args.apply:
|
||||||
|
print("[INFO] No mode provided, defaulting to --dry-run.")
|
||||||
|
args.dry_run = True
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
where_sql = """
|
||||||
|
monitor_type = ?
|
||||||
|
AND result = ?
|
||||||
|
AND CAST(COALESCE(pnl_amount, 0) AS REAL) > 0
|
||||||
|
"""
|
||||||
|
params = (args.monitor_type, args.from_result)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT COUNT(*) AS c FROM trade_records WHERE {where_sql}", params)
|
||||||
|
will_change = int(cur.fetchone()["c"])
|
||||||
|
print(f"[INFO] Candidate rows: {will_change}")
|
||||||
|
|
||||||
|
if will_change == 0:
|
||||||
|
print("[INFO] Nothing to update.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, symbol, result, pnl_amount, closed_at
|
||||||
|
FROM trade_records
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
sample = cur.fetchall()
|
||||||
|
print("[INFO] Sample (latest 10):")
|
||||||
|
for r in sample:
|
||||||
|
print(
|
||||||
|
f" id={r['id']} symbol={r['symbol']} result={r['result']} "
|
||||||
|
f"pnl={r['pnl_amount']} closed_at={r['closed_at']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("[DRY-RUN] No write executed.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE trade_records SET result=? WHERE {where_sql}",
|
||||||
|
(args.to_result, *params),
|
||||||
|
)
|
||||||
|
changed = int(cur.rowcount)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[DONE] Updated rows: {changed}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
在项目根目录执行(会加载根目录 .env):
|
||||||
|
python scripts/verify_gate_funding.py
|
||||||
|
|
||||||
|
依次探测:[0] swap 余额(与 App「交易账户」同源);[1]–[3] 现货 / 统一账户资金路径。
|
||||||
|
打印 GATE_API_KEY 前 8 位便于与 Gate 控制台核对(不含 Secret)。用于服务器自检。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_app():
|
||||||
|
path = os.path.join(ROOT, "app.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("crypto_app", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.chdir(ROOT)
|
||||||
|
mod = _load_app()
|
||||||
|
print("LIVE_TRADING_ENABLED =", os.getenv("LIVE_TRADING_ENABLED"))
|
||||||
|
ok, reason = mod.ensure_exchange_live_ready()
|
||||||
|
print("ensure_exchange_live_ready =", ok, repr(reason))
|
||||||
|
if not ok:
|
||||||
|
print("跳过私有接口探测")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
mod.ensure_markets_loaded()
|
||||||
|
|
||||||
|
k = (os.getenv("GATE_API_KEY") or "").strip()
|
||||||
|
s = (os.getenv("GATE_API_SECRET") or "").strip()
|
||||||
|
if not k or "REPLACE" in k.upper():
|
||||||
|
print("WARN: GATE_API_KEY 为空或仍像占位符,请核对 .env")
|
||||||
|
if not s or "REPLACE" in s.upper():
|
||||||
|
print("WARN: GATE_API_SECRET 为空或仍像占位符,请核对 .env")
|
||||||
|
print("GATE_API_KEY prefix (8 chars):", (k[:8] + "…") if len(k) > 8 else "(short)")
|
||||||
|
|
||||||
|
# 0) swap — 与 App「交易账户」余额同源(优先看此项是否与网页一致)
|
||||||
|
try:
|
||||||
|
bal = mod.exchange.fetch_balance({"type": "swap"})
|
||||||
|
v0 = mod._extract_usdt_total(bal)
|
||||||
|
print("[0] fetch_balance(swap) USDT total =", v0)
|
||||||
|
except Exception as e:
|
||||||
|
print("[0] fetch_balance(swap) FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 1) fetch_balance spot + marginMode spot
|
||||||
|
try:
|
||||||
|
bal = mod.exchange.fetch_balance({"type": "spot", "marginMode": "spot"})
|
||||||
|
v = mod._extract_usdt_total(bal)
|
||||||
|
print("[1] fetch_balance(spot,marginMode=spot) USDT total =", v)
|
||||||
|
except Exception as e:
|
||||||
|
print("[1] fetch_balance(spot) FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 2) raw spot accounts
|
||||||
|
try:
|
||||||
|
resp = mod.exchange.privateSpotGetAccounts({})
|
||||||
|
v2 = mod._parse_gate_spot_accounts_response_usdt(resp)
|
||||||
|
print("[2] privateSpotGetAccounts USDT =", v2)
|
||||||
|
except Exception as e:
|
||||||
|
print("[2] privateSpotGetAccounts FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
# 3) unified accounts raw
|
||||||
|
try:
|
||||||
|
raw = mod.exchange.privateUnifiedGetAccounts({})
|
||||||
|
body = raw
|
||||||
|
if isinstance(body, dict) and isinstance(body.get("result"), dict):
|
||||||
|
body = body["result"]
|
||||||
|
if isinstance(body, dict):
|
||||||
|
keys = sorted(body.keys())
|
||||||
|
print("[3] unified top-level keys (sample):", keys[:25], "..." if len(keys) > 25 else "")
|
||||||
|
v3 = mod._parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None
|
||||||
|
print("[3] parsed unified USDT =", v3)
|
||||||
|
except Exception as e:
|
||||||
|
print("[3] privateUnifiedGetAccounts FAILED:", type(e).__name__, e)
|
||||||
|
|
||||||
|
fu = mod._fetch_gate_funding_usdt()
|
||||||
|
print(">>> _fetch_gate_funding_usdt() =", fu)
|
||||||
|
f, t = mod.get_exchange_capitals(force=True)
|
||||||
|
print(">>> get_exchange_capitals(force=True) funding, trading =", f, t)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
ok2
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 关键位放大</title>
|
||||||
|
<style>
|
||||||
|
*{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}
|
||||||
|
.container{width:min(98vw,1900px);margin:0 auto}
|
||||||
|
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150;margin-bottom:12px}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.btn{padding:7px 10px;border-radius:8px;text-decoration:none;border:1px solid #304164;background:#151a2a;color:#8fc8ff;cursor:pointer}
|
||||||
|
.btn:hover{background:#1f2740}
|
||||||
|
input,select,button{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff}
|
||||||
|
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:10px}
|
||||||
|
.meta-item{background:#141b2f;border:1px solid #27324e;border-radius:8px;padding:8px}
|
||||||
|
.meta-item .k{font-size:.76rem;color:#9fb0d8}
|
||||||
|
.meta-item .v{font-size:1rem;margin-top:4px;word-break:break-all}
|
||||||
|
.status{font-size:.84rem;color:#95a2c2}
|
||||||
|
.status.err{color:#ff8080}
|
||||||
|
#chart-wrap{height:580px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
<strong style="color:#dbe4ff">关键位放大(可输入币种)</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"><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"><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>
|
||||||
|
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 fmt = (v,d=6)=>(v===null||typeof v==="undefined"||Number.isNaN(Number(v)))?"-":Number(v).toFixed(d);
|
||||||
|
const fmtSigned = (v,d=4)=>{
|
||||||
|
if(v===null||typeof v==="undefined"||Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(v);
|
||||||
|
return `${n>0?"+":""}${n.toFixed(d)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
const keyMap = {};
|
||||||
|
{% for k in key_list %}
|
||||||
|
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart && candleSeries) return true;
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!chart){
|
||||||
|
chart = LightweightCharts.createChart(chartHost, {
|
||||||
|
layout:{background:{color:"#0f1320"},textColor:"#d6deff"},
|
||||||
|
grid:{vertLines:{color:"#1e263d"},horzLines:{color:"#1e263d"}},
|
||||||
|
rightPriceScale:{borderColor:"#2a3150"},
|
||||||
|
timeScale:{borderColor:"#2a3150",timeVisible:true,secondsVisible:false},
|
||||||
|
crosshair:{mode:0}
|
||||||
|
});
|
||||||
|
window.addEventListener("resize",()=>{
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
});
|
||||||
|
chart.applyOptions({width:chartHost.clientWidth,height:chartHost.clientHeight});
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
};
|
||||||
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
|
candleSeries = chart.addCandlestickSeries(opts);
|
||||||
|
} else if (typeof chart.addSeries === "function" && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "K线序列初始化失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries) return;
|
||||||
|
priceLines.forEach(line=>{ try { candleSeries.removePriceLine(line); } catch (_) {} });
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || price===null || typeof price==="undefined") return;
|
||||||
|
const p = Number(price);
|
||||||
|
if(Number.isNaN(p) || p<=0) return;
|
||||||
|
priceLines.push(candleSeries.createPriceLine({
|
||||||
|
price:p,color,lineWidth:1,lineStyle:0,axisLabelVisible:true,title
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMeta(data){
|
||||||
|
const key = data.key_monitor || null;
|
||||||
|
document.getElementById("m-symbol").innerText = data.symbol || "-";
|
||||||
|
document.getElementById("m-price").innerText = fmt(data.current_price,8);
|
||||||
|
|
||||||
|
if(!key){
|
||||||
|
document.getElementById("m-type").innerText = "未匹配到关键位";
|
||||||
|
document.getElementById("m-direction").innerText = "-";
|
||||||
|
document.getElementById("m-upper").innerText = "-";
|
||||||
|
document.getElementById("m-lower").innerText = "-";
|
||||||
|
document.getElementById("m-updiff").innerText = "-";
|
||||||
|
document.getElementById("m-lowdiff").innerText = "-";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("m-type").innerText = key.monitor_type || "-";
|
||||||
|
document.getElementById("m-direction").innerText = key.direction === "short" ? "做空" : "做多";
|
||||||
|
document.getElementById("m-upper").innerText = fmt(key.upper,8);
|
||||||
|
document.getElementById("m-lower").innerText = fmt(key.lower,8);
|
||||||
|
document.getElementById("m-updiff").innerText = `${fmtSigned(key.upper_diff,4)} (${fmtSigned(key.upper_pct,2)}%)`;
|
||||||
|
document.getElementById("m-lowdiff").innerText = `${fmtSigned(key.lower_diff,4)} (${fmtSigned(key.lower_pct,2)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "请求失败");
|
||||||
|
|
||||||
|
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||||
|
if(!candles.length){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "暂无K线数据";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candleSeries) throw new Error("Series init failed");
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.current_price, "现价", "#42a5f5");
|
||||||
|
if(data.key_monitor){
|
||||||
|
addLine(data.key_monitor.upper, "上沿/阻力", "#ffb84d");
|
||||||
|
addLine(data.key_monitor.lower, "下沿/支撑", "#4cd97f");
|
||||||
|
}
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintMeta(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,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>登录 · {{ exchange_display }}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #0a0a10;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.login-box {
|
||||||
|
background: #12121a;
|
||||||
|
padding: 2.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border: 1px solid #242435;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.login-box h2 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
background: linear-gradient(90deg, #4cc2ff, #7b42ff);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #a9a9ff;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #2e2e45;
|
||||||
|
background: #1a1a29;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: #4cc2ff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(90deg, #4285f4, #7b42ff);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.flash {
|
||||||
|
padding: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #331e24;
|
||||||
|
color: #ff6666;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.exchange-line {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #8892b0;
|
||||||
|
margin: -0.5rem 0 1.25rem;
|
||||||
|
}
|
||||||
|
.exchange-line strong {
|
||||||
|
color: #b8f5d0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-box">
|
||||||
|
<h2>交易监控系统登录</h2>
|
||||||
|
<p class="exchange-line">交易所:<strong>{{ exchange_display }}</strong></p>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>账号</label>
|
||||||
|
<input type="text" name="username" required placeholder="请输入账号">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" required placeholder="请输入密码">
|
||||||
|
</div>
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>实盘下单放大 | 100根K线</title>
|
||||||
|
<style>
|
||||||
|
*{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}
|
||||||
|
.container{width:min(98vw,1900px);margin:0 auto}
|
||||||
|
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150;margin-bottom:12px}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.btn{padding:7px 10px;border-radius:8px;text-decoration:none;border:1px solid #304164;background:#151a2a;color:#8fc8ff;cursor:pointer}
|
||||||
|
.btn:hover{background:#1f2740}
|
||||||
|
select,button{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff}
|
||||||
|
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:10px}
|
||||||
|
.meta-item{background:#141b2f;border:1px solid #27324e;border-radius:8px;padding:8px}
|
||||||
|
.meta-item .k{font-size:.76rem;color:#9fb0d8}
|
||||||
|
.meta-item .v{font-size:1rem;margin-top:4px;word-break:break-all}
|
||||||
|
.status{font-size:.84rem;color:#95a2c2}
|
||||||
|
.status.err{color:#ff8080}
|
||||||
|
#chart-wrap{height:560px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.empty{padding:18px;color:#95a2c2}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
<strong style="color:#dbe4ff">实盘下单放大(100根K线)</strong>
|
||||||
|
</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"><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-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-price">-</div></div>
|
||||||
|
<div class="meta-item"><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/dist/lightweight-charts.standalone.production.js"></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 fmt = (v, d=6) => (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) ? "-" : Number(v).toFixed(d);
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart){ return true; }
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
chart = LightweightCharts.createChart(chartHost, {
|
||||||
|
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
|
||||||
|
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
|
||||||
|
rightPriceScale: { borderColor: "#2a3150" },
|
||||||
|
timeScale: { borderColor: "#2a3150", timeVisible: true, secondsVisible: false },
|
||||||
|
crosshair: { mode: 0 }
|
||||||
|
});
|
||||||
|
candleSeries = chart.addCandlestickSeries({
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
});
|
||||||
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries){ return; }
|
||||||
|
priceLines.forEach(line => {
|
||||||
|
try { candleSeries.removePriceLine(line); } catch (_) {}
|
||||||
|
});
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || typeof price === "undefined" || price === null){ return; }
|
||||||
|
const p = Number(price);
|
||||||
|
if(Number.isNaN(p) || p <= 0){ return; }
|
||||||
|
priceLines.push(candleSeries.createPriceLine({
|
||||||
|
price: p, color, lineWidth: 1, lineStyle: 0, axisLabelVisible: true, title
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintOrder(order){
|
||||||
|
document.getElementById("m-symbol").innerText = order.symbol || "-";
|
||||||
|
document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多";
|
||||||
|
document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8);
|
||||||
|
document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8);
|
||||||
|
document.getElementById("m-tp").innerText = fmt(order.take_profit, 8);
|
||||||
|
document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`;
|
||||||
|
document.getElementById("m-price").innerText = fmt(order.current_price, 8);
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`;
|
||||||
|
pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "请求失败"); }
|
||||||
|
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||||
|
if(!candles.length){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "暂无K线数据";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.order.trigger_price, "成交价", "#42a5f5");
|
||||||
|
addLine(data.order.stop_loss, "止损", "#ff6666");
|
||||||
|
addLine(data.order.take_profit, "止盈", "#4cd97f");
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintOrder(data.order || {});
|
||||||
|
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,214 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ exchange_display }} | 实盘下单放大</title>
|
||||||
|
<style>
|
||||||
|
*{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}
|
||||||
|
.container{width:min(98vw,1900px);margin:0 auto}
|
||||||
|
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150;margin-bottom:12px}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.btn{padding:7px 10px;border-radius:8px;text-decoration:none;border:1px solid #304164;background:#151a2a;color:#8fc8ff;cursor:pointer}
|
||||||
|
.btn:hover{background:#1f2740}
|
||||||
|
select,button{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff}
|
||||||
|
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:10px}
|
||||||
|
.meta-item{background:#141b2f;border:1px solid #27324e;border-radius:8px;padding:8px}
|
||||||
|
.meta-item .k{font-size:.76rem;color:#9fb0d8}
|
||||||
|
.meta-item .v{font-size:1rem;margin-top:4px;word-break:break-all}
|
||||||
|
.status{font-size:.84rem;color:#95a2c2}
|
||||||
|
.status.err{color:#ff8080}
|
||||||
|
#chart-wrap{height:560px;background:#0f1320;border:1px solid #2a3150;border-radius:10px;padding:8px}
|
||||||
|
#chart{width:100%;height:100%}
|
||||||
|
.empty{padding:18px;color:#95a2c2}
|
||||||
|
.exchange-tag{font-size:.72rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:4px 10px;border-radius:999px;margin-left:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
<strong style="color:#dbe4ff">实盘下单放大(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"><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-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"><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/dist/lightweight-charts.standalone.production.js"></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 fmt = (v, d=6) => (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) ? "-" : Number(v).toFixed(d);
|
||||||
|
|
||||||
|
let chart = null;
|
||||||
|
let candleSeries = null;
|
||||||
|
let priceLines = [];
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(chart){ return true; }
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
chart = LightweightCharts.createChart(chartHost, {
|
||||||
|
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
|
||||||
|
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
|
||||||
|
rightPriceScale: { borderColor: "#2a3150" },
|
||||||
|
timeScale: { borderColor: "#2a3150", timeVisible: true, secondsVisible: false },
|
||||||
|
crosshair: { mode: 0 }
|
||||||
|
});
|
||||||
|
candleSeries = chart.addCandlestickSeries({
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
borderVisible: false,
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666"
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
});
|
||||||
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPriceLines(){
|
||||||
|
if(!candleSeries){ return; }
|
||||||
|
priceLines.forEach(line => {
|
||||||
|
try { candleSeries.removePriceLine(line); } catch (_) {}
|
||||||
|
});
|
||||||
|
priceLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(price, title, color){
|
||||||
|
if(!candleSeries || typeof price === "undefined" || price === null){ return; }
|
||||||
|
const p = Number(price);
|
||||||
|
if(Number.isNaN(p) || p <= 0){ return; }
|
||||||
|
priceLines.push(candleSeries.createPriceLine({
|
||||||
|
price: p, color, lineWidth: 1, lineStyle: 0, axisLabelVisible: true, title
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintOrder(order){
|
||||||
|
document.getElementById("m-symbol").innerText = order.symbol || "-";
|
||||||
|
document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多";
|
||||||
|
document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8);
|
||||||
|
document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8);
|
||||||
|
document.getElementById("m-tp").innerText = fmt(order.take_profit, 8);
|
||||||
|
document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`;
|
||||||
|
document.getElementById("m-breakeven").innerText =
|
||||||
|
(order.breakeven_enabled === false || order.breakeven_enabled === 0) ? "关闭" : "开启";
|
||||||
|
document.getElementById("m-price").innerText = fmt(order.current_price, 8);
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`;
|
||||||
|
pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "请求失败"); }
|
||||||
|
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||||
|
if(!candles.length){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "暂无K线数据";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candleSeries.setData(candles);
|
||||||
|
resetPriceLines();
|
||||||
|
addLine(data.order.trigger_price, "成交价", "#42a5f5");
|
||||||
|
addLine(data.order.stop_loss, "止损", "#ff6666");
|
||||||
|
addLine(data.order.take_profit, "止盈", "#4cd97f");
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
paintOrder(data.order || {});
|
||||||
|
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 %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
if (typeof ensureChart !== 'function') return;
|
||||||
|
const oldEnsureChart = ensureChart;
|
||||||
|
ensureChart = function(){
|
||||||
|
if (chart && candleSeries) return true;
|
||||||
|
try { const ok = oldEnsureChart(); if (ok && candleSeries) return true; } catch (_) {}
|
||||||
|
if (chart && !candleSeries && typeof chart.addSeries === 'function' && window.LightweightCharts && window.LightweightCharts.CandlestickSeries) {
|
||||||
|
const opts = { upColor:'#4cd97f', downColor:'#ff6666', borderVisible:false, wickUpColor:'#4cd97f', wickDownColor:'#ff6666' };
|
||||||
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
return !!candleSeries;
|
||||||
|
}
|
||||||
|
return !!candleSeries;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 趋势回调策略(机器人)说明
|
||||||
|
|
||||||
|
本文描述本仓库内 **「趋势回调」** 自动交易计划的业务规则与实现口径,便于单独策略账户使用与审计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 适用场景
|
||||||
|
|
||||||
|
- 单独用于跑策略的 **Gate.io USDT 永续** 子账户(建议与主资金隔离)。
|
||||||
|
- 你已明确:**方向、止损价、补仓上沿、止盈价、杠杆**,并接受程序按风险预算拆分 **首仓 50% + 多档补仓 50%**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 名词与参数
|
||||||
|
|
||||||
|
| 名称 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| **合约 USDT 可用余额** | **生成预览**时通过 API 读取的 **swap 账户 USDT `free`** 快照;**确认执行**时再次读取并与快照比对偏差。 |
|
||||||
|
| **风险比例** | 默认 **5%**:指「若整笔计划在 **补仓上沿** 这一侧的最坏价格结构下触及止损」,目标亏损上限约为 **可用余额快照 × 风险比例**(实现上用 `calc_risk_fraction` 与 `prepare_order_amount` 反推总张数,受交易所最小张数与精度约束)。 |
|
||||||
|
| **止损价** | 用户填写;开仓后挂 **交易所仓位类止损触发单**(全平)。 |
|
||||||
|
| **补仓上沿** | 用户填写;**仅在该价位与止损价构成的区间内** 才允许程序触发剩余 50% 的市价补仓(做多:`止损 < 补仓上沿`;做空:`止损 > 补仓上沿`)。 |
|
||||||
|
| **止盈价** | 用户填写的 **固定价格**;**不由交易所条件止盈单触发**,由应用后台 **按标记价/行情价轮询**,达到后 **市价全平**。 |
|
||||||
|
| **杠杆** | 计划内固定写入;用于 `set_leverage` 与名义换算。 |
|
||||||
|
| **补仓档位数** | 默认 **5** 档(环境变量 `TREND_PULLBACK_DCA_LEGS` 可调);程序在满足最小张数前提下可能 **自动减少档数**。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 执行流程(时间顺序)
|
||||||
|
|
||||||
|
### 3.1 预览阶段(不下单)
|
||||||
|
|
||||||
|
1. **风控**:与「机器人下单监控」**互斥**——存在活跃机器人持仓或运行中趋势计划时,不可生成预览。
|
||||||
|
2. **读取可用余额快照** `get_available_trading_usdt()`,失败则拒绝。
|
||||||
|
3. **计算**(写入表 `trend_pullback_previews`,并跳转带 `preview_id`):
|
||||||
|
- 在 **补仓上沿 ↔ 止损** 区间内生成 `N` 个补仓触发价;
|
||||||
|
- 将 **剩余 50% 计划张数** 拆成 `N` 份写入 `leg_amounts_json`。
|
||||||
|
4. **预览有效期**:默认 **120 秒**(`TREND_PULLBACK_PREVIEW_TTL_SECONDS`),超时须重新点「生成预览」。
|
||||||
|
|
||||||
|
### 3.2 确认执行(实盘)
|
||||||
|
|
||||||
|
5. 再次校验:预览未过期;**当前可用余额**与预览快照相对偏差 ≤ `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT`(默认 **5%**),否则拒绝执行并要求重新预览。
|
||||||
|
6. **首仓**:**立即市价** 开立 **总计划张数 × 50%**(不附带交易所止盈单)。
|
||||||
|
7. **止损**:撤销旧条件单后,挂 **仅止损** 的仓位触发单;之后每次补仓成交会 **刷新** 止损挂单。
|
||||||
|
8. **补仓**:当价格 **穿越** 下一档触发价(做多为自上向下穿越)时,按该档张数 **市价加仓**;直至 `N` 档执行完毕或计划结束。
|
||||||
|
9. **止盈监控**:后台线程若发现价格触及止盈,则 **市价全平**。
|
||||||
|
10. **止损触发**:若仓位被交易所止损打光,本地检测到 **持仓为 0** 后记账为 **止损** 并结束计划。
|
||||||
|
11. **计划结束**:任一结束路径(止盈 / 止损 / 用户手动结束)均会 **撤单**(条件单 + 普通挂单,尽力而为)。
|
||||||
|
|
||||||
|
### 3.3 取消预览
|
||||||
|
|
||||||
|
用户可「取消预览」删除 `trend_pullback_previews` 中对应记录;过期记录会在新预览或页面加载时清理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 与「机器人下单监控」的差异
|
||||||
|
|
||||||
|
| 项目 | 机器人下单监控 | 趋势回调 |
|
||||||
|
|------|------------------|----------|
|
||||||
|
| 开仓 | 单次市价 + 条件止盈+止损 | 首仓 50% 市价 + 多档补仓 + **仅止损在交易所** |
|
||||||
|
| 止盈 | 条件单 + 本地监控 | **仅本地监控市价止盈** |
|
||||||
|
| 仓位基数 | 以损定仓(表单/会话基数) | **可用余额快照 × 风险比例** 推导 |
|
||||||
|
| 移动保本 | 支持 | **不支持**(未实现) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 风险声明(必读)
|
||||||
|
|
||||||
|
- 市价单存在 **滑点**;极端行情下实际亏损可能 **大于** 理论 5%。
|
||||||
|
- 补仓触发依赖应用 **轮询间隔**(`MONITOR_POLL_SECONDS`),非毫秒级高频。
|
||||||
|
- 交易所 **最小张数 / 精度** 可能导致计划张数被截断,实际风险略低于或偏离纸面计算。
|
||||||
|
- 请使用 **单独 API Key / 子账户**,并先在 `LIVE_TRADING_ENABLED=false` 环境验证流程(若需沙盒请自行对接测试网,本仓库默认实盘接口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 相关环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| `TREND_PULLBACK_DCA_LEGS` | 剩余 50% 拆档数量上限 | `5` |
|
||||||
|
| `TREND_PULLBACK_PREVIEW_TTL_SECONDS` | 预览有效时间(秒) | `120` |
|
||||||
|
| `TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT` | 确认执行时允许「当前可用 / 预览快照」最大相对偏差(%) | `5` |
|
||||||
|
| `MONITOR_POLL_SECONDS` | 监控轮询间隔(秒) | `3` |
|
||||||
|
| `LIVE_TRADING_ENABLED` | 是否允许真实下单 | `false` |
|
||||||
|
| `FULL_MARGIN_BUFFER_RATIO` | 计划保证金相对可用余额上限比例 | `0.98` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 数据库
|
||||||
|
|
||||||
|
- **`trend_pullback_previews`**:未执行的预览行(含 `expires_at_ms`),执行成功或取消后删除;过期可被清理。
|
||||||
|
- **`trend_pullback_plans`**:已执行且运行中的计划;字段含快照可用余额、计划保证金、总张数、首仓张数、补仓 JSON、网格价 JSON、已补仓档数、均价、状态等。平仓结果写入 `trade_records`,`monitor_type` 为 **`趋势回调`**。
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# `crypto_monitor_gate` 部署指南:SSH SOCKS + Gate.io + PM2(Ubuntu)
|
||||||
|
|
||||||
|
本文面向:**在本机运行本项目**,但 **直连 Gate.io API 不稳定或被重置** 的场景。思路是:
|
||||||
|
|
||||||
|
- 本机用 `ssh -D` 做动态转发,把 **SOCKS5 出口**放到能正常访问 Gate 的机器(常见为一台境外 VPS)
|
||||||
|
- 项目在 `.env` 中设置 **`GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080`**(或你实际端口),`ccxt` 经 SOCKS 访问交易所
|
||||||
|
- **SSH 隧道**:用 `ssh -D` 在本机常驻即可(screen / tmux / systemd 等),**不必交给 PM2**
|
||||||
|
- 使用 **PM2** 仅托管 **Flask 应用**;仓库根目录 **`ecosystem.config.cjs`** 只定义 `crypto-monitor-gate`
|
||||||
|
|
||||||
|
> 安全提醒:不要把 `.env`、私钥 `.pem`、Gate API Key 提交到 Git;下文只用占位符。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 你需要准备的东西
|
||||||
|
|
||||||
|
- 一台 **Ubuntu**(或同类 Linux)运行项目的机器(下文称「本机」)
|
||||||
|
- 一台可 SSH 登录、且 **能正常访问 Gate.io API** 的 VPS(示例:`HostName` 填你的服务器 IP,用户如 `root`)
|
||||||
|
- SSH:**私钥登录**(推荐,便于隧道脚本无人值守)
|
||||||
|
- 本机已安装:`python3`、`python3-venv`、`pip`、`curl`、`ssh`、`git`(可选)、`node` + `npm`(安装 PM2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 获取代码与目录
|
||||||
|
|
||||||
|
将包含 `app.py` 的项目放到固定目录,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/apps
|
||||||
|
cd ~/apps
|
||||||
|
# git clone ... 或解压同步的包
|
||||||
|
cd crypto_monitor_gate
|
||||||
|
```
|
||||||
|
|
||||||
|
下文用 **`/root/crypto_monitor_gate`** 仅为示例,请换成你的实际绝对路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配置 SSH 私钥与 `~/.ssh/config`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
# 私钥示例:~/.ssh/vps1.pem
|
||||||
|
chmod 600 ~/.ssh/vps1.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `~/.ssh/config`(示例别名 **`gate-vps`**,与你手工启动 `ssh -D ... gate-vps` 一致即可):
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host gate-vps
|
||||||
|
HostName 你的_VPS_IP
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/vps1.pem
|
||||||
|
IdentitiesOnly yes
|
||||||
|
ServerAliveInterval 30
|
||||||
|
ServerAliveCountMax 3
|
||||||
|
ExitOnForwardFailure yes
|
||||||
|
BatchMode yes
|
||||||
|
```
|
||||||
|
|
||||||
|
测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh gate-vps true
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若尚未完全改为密钥登录,可暂时注释 `BatchMode yes`,调试完成后再打开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 手工验证:SSH SOCKS + Gate API
|
||||||
|
|
||||||
|
### 3.1 本地 SOCKS(示例端口 1080)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 gate-vps
|
||||||
|
```
|
||||||
|
|
||||||
|
保持运行,另开终端继续。
|
||||||
|
|
||||||
|
### 3.2 验证经 SOCKS 可访问 Gate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -4 -sS --max-time 15 --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time
|
||||||
|
```
|
||||||
|
|
||||||
|
应返回 JSON(含服务器时间字段)。若此处失败,**不要先启动应用**:先修隧道或 VPS 出站。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Python 虚拟环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_gate
|
||||||
|
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install -U pip
|
||||||
|
pip install flask requests ccxt werkzeug PySocks Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
走 SOCKS 时 **必须** 安装 **`PySocks`**,否则易出现代理相关报错。
|
||||||
|
|
||||||
|
可选:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 配置 `.env`(关键:Gate + 代理)
|
||||||
|
|
||||||
|
项目通过 `app.py` 启动时 **自动加载项目根目录的 `.env`**。与交易所相关的变量必须是 **Gate** 前缀(**不要**再写 OKX 变量,否则代理不会生效、密钥也不会被识别)。
|
||||||
|
|
||||||
|
至少确认:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_HOST=127.0.0.1
|
||||||
|
APP_PORT=5000
|
||||||
|
|
||||||
|
# 实盘(按需)
|
||||||
|
LIVE_TRADING_ENABLED=false
|
||||||
|
GATE_API_KEY=你的_Key
|
||||||
|
GATE_API_SECRET=你的_Secret
|
||||||
|
|
||||||
|
# 经本机 SSH 动态转发访问 Gate(端口与隧道一致)
|
||||||
|
GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
|
||||||
|
# 若不用 SOCKS,可改用 HTTP 代理(一般二选一)
|
||||||
|
# GATE_HTTP_PROXY=http://127.0.0.1:7890
|
||||||
|
# GATE_HTTPS_PROXY=http://127.0.0.1:7890
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:**推荐 `socks5h://`**,由 SOCKS 端解析域名,与 `curl --proxy socks5h://...` 行为一致。
|
||||||
|
|
||||||
|
### 5.1 趋势回调策略(可选)
|
||||||
|
|
||||||
|
若使用「交易执行」页的 **趋势回调** 计划:
|
||||||
|
|
||||||
|
- 详细规则见项目根目录 **`趋势回调策略说明.md`**。
|
||||||
|
- **两阶段**:先「生成预览」(默认 **120 秒**内有效),再「确认执行」;执行时若可用余额与预览快照偏差超过 **5%** 会拒绝(可调 `.env`)。
|
||||||
|
- 补仓档位数默认 **5**,预览有效期与余额偏差阈值可在 `.env` 覆盖:
|
||||||
|
|
||||||
|
```env
|
||||||
|
TREND_PULLBACK_DCA_LEGS=5
|
||||||
|
TREND_PULLBACK_PREVIEW_TTL_SECONDS=120
|
||||||
|
TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5
|
||||||
|
```
|
||||||
|
|
||||||
|
- **生成预览**与**确认执行**时都会读取 **Gate 永续账户 USDT 可用余额**;请尽量使用 **单独子账户** 承载策略资金。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 手工启动 Flask(验证)
|
||||||
|
|
||||||
|
1. SOCKS 已监听 `127.0.0.1:1080`
|
||||||
|
2. 已 `source .venv/bin/activate`
|
||||||
|
3. `.env` 已含 `GATE_SOCKS_PROXY`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_gate
|
||||||
|
source .venv/bin/activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 中的端口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 安装 PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo npm i -g pm2
|
||||||
|
pm2 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. PM2:使用仓库内 `ecosystem.config.cjs`(推荐)
|
||||||
|
|
||||||
|
在项目根目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_gate
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
pm2 status
|
||||||
|
pm2 logs --lines 200
|
||||||
|
```
|
||||||
|
|
||||||
|
默认只启动 **`crypto-monitor-gate`**(`.venv/bin/python app.py`)。
|
||||||
|
|
||||||
|
### 本机已可直连 Gate、不需要隧道时
|
||||||
|
|
||||||
|
`.env` 里应 **去掉或留空** `GATE_SOCKS_PROXY`(除非仍要走别的代理),再 `pm2 start ecosystem.config.cjs`。
|
||||||
|
|
||||||
|
### 开机自启
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
# 按屏幕提示执行一条 sudo 命令
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 等价手工命令(不使用 ecosystem 文件时)
|
||||||
|
|
||||||
|
### 9.1 SSH SOCKS(自行后台常驻,不推荐用 PM2)
|
||||||
|
|
||||||
|
示例(前台;实际可用 `screen`/`tmux`/`-f` 后台化或 systemd):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -D 127.0.0.1:1080 gate-vps \
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
|
||||||
|
-o ExitOnForwardFailure=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Flask
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/crypto_monitor_gate
|
||||||
|
pm2 start /root/crypto_monitor_gate/.venv/bin/python --name crypto-monitor-gate -- \
|
||||||
|
/root/crypto_monitor_gate/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 交易所「连接不上」排查清单
|
||||||
|
|
||||||
|
1. **`.env` 是否为 Gate 变量**:必须是 `GATE_SOCKS_PROXY` / `GATE_API_KEY` / `GATE_API_SECRET`,不是 OKX。
|
||||||
|
2. **隧道是否在本机端口监听**(若配置了 `GATE_SOCKS_PROXY`):
|
||||||
|
```bash
|
||||||
|
ss -lntp | grep 1080 || true
|
||||||
|
```
|
||||||
|
3. **curl 复测 Gate**(与第 3.2 节相同);curl 不通则应用也不会通。
|
||||||
|
4. **PySocks**:`pip show PySocks`,缺失则 `pip install PySocks`。
|
||||||
|
5. **SSH 隧道连不上**:检查私钥权限、`~/.ssh/config`、VPS 出站与端口是否与 `.env` 一致。
|
||||||
|
6. **启动顺序**:先保证 SOCKS 已监听,再 `pm2 start` 应用(或重启应用)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 推荐启动顺序(习惯)
|
||||||
|
|
||||||
|
1. 若走代理:先启动并确认 SSH SOCKS 已监听,再 `curl --proxy socks5h://127.0.0.1:1080 https://api.gateio.ws/api/v4/spot/time` 成功
|
||||||
|
2. `pm2 start ecosystem.config.cjs`
|
||||||
|
3. 再确认页面与余额等接口正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 免责声明
|
||||||
|
|
||||||
|
交易所有合规与地区政策要求。请确保使用方式符合当地法律法规与交易所条款。本文仅描述网络与工程部署路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:数据库标签修复脚本 `scripts/fix_breakeven_labels.py`
|
||||||
|
|
||||||
|
在 Ubuntu 上:
|
||||||
|
|
||||||
|
1)预览(不写库):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
2)确认后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_breakeven_labels.py --db ./crypto.db --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
默认修复条件:`monitor_type='下单监控'` 且 `result='止损'` 且 `pnl_amount > 0` → 改为 `result='保本止盈'`。
|
||||||
@@ -2,7 +2,7 @@ APP_ENV=production
|
|||||||
# 服务监听地址(云服务器通常用 0.0.0.0)
|
# 服务监听地址(云服务器通常用 0.0.0.0)
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
# 服务端口
|
# 服务端口
|
||||||
APP_PORT=5000
|
APP_PORT=5004
|
||||||
# 是否开启调试模式(生产建议 false)
|
# 是否开启调试模式(生产建议 false)
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const PY = path.join(ROOT, ".venv", "bin", "python");
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "crypto-monitor",
|
name: "crypto_okx",
|
||||||
cwd: ROOT,
|
cwd: ROOT,
|
||||||
script: path.join(ROOT, "app.py"),
|
script: path.join(ROOT, "app.py"),
|
||||||
interpreter: PY,
|
interpreter: PY,
|
||||||
|
|||||||
Reference in New Issue
Block a user