first commit

This commit is contained in:
dekun
2026-05-07 09:04:51 +08:00
commit 7980df7d30
27 changed files with 15313 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
APP_ENV=production
# 服务监听地址(云服务器通常用 0.0.0.0)
APP_HOST=0.0.0.0
# 服务端口
APP_PORT=5000
# 是否开启调试模式(生产建议 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_ordersclose-*-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.
+33
View File
@@ -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-monitor-gate",
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>
+118
View File
@@ -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>
+257
View File
@@ -0,0 +1,257 @@
# `crypto_monitor_gate` 部署指南:SSH SOCKS + Gate.io + PM2Ubuntu
本文面向:**在本机运行本项目**,但 **直连 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://...` 行为一致。
---
## 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='保本止盈'`
+117
View File
@@ -0,0 +1,117 @@
APP_ENV=production
# 服务监听地址(云服务器通常用 0.0.0.0)
APP_HOST=0.0.0.0
# 服务端口
APP_PORT=5000
# 是否开启调试模式(生产建议 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
# 训练总资金(U
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
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true
# OKX API Key(实盘)
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY
# OKX API Secret(实盘)
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET
# OKX API Passphrase(实盘)
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE
# 保证金模式:cross=全仓,isolated=逐仓
OKX_TD_MODE=cross
# 持仓模式:hedge=双向持仓,net=单向净持仓
OKX_POS_MODE=hedge
# 仓位查询 instTypeOKX
OKX_POSITION_INST_TYPE=SWAP
# 关键位监控: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
# OKX 代理(可选):用于本机网络对 OKX TLS/SNI 不稳定时,通过 SSH 动态转发 SOCKS5 出口
# 1) 先在本机建立隧道(示例):
# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes
# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名):
# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
#
# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用:
# OKX_HTTP_PROXY=http://127.0.0.1:3128
# OKX_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 点起算新交易日/可开仓等
File diff suppressed because it is too large Load Diff
Binary file not shown.
+33
View File
@@ -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-monitor",
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())
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
ok2
@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>关键位放大 | 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}
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%}
</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>
</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>
+107
View File
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>系统登录</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;
}
</style>
</head>
<body>
<div class="login-box">
<h2>交易监控系统登录</h2>
{% 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,210 @@
<!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 %}
<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>
+331
View File
@@ -0,0 +1,331 @@
# `crypto_monitor` 本地部署 + SSH SOCKS 转发 + PM2 启动指南(Ubuntu
本文面向:**本地 Ubuntu 机器运行项目**,但 **本机直连 OKX 会被 TLS/SNI reset** 的场景。解决思路是:
- 本机启动 `ssh -D` 动态转发,把 **SOCKS5 出口**放到你可正常访问 OKX 的 VPS 上
- 项目通过环境变量 `OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080``ccxt` 走 SOCKS
- 用 `pm2` 托管 **SSH 隧道****Flask 应用**(你也可以只用 `screen`,但本文按你要求用 PM2
> 安全提醒:不要把 `.env`、私钥 `.pem`、OKX API Key 提交到 Git;文档里只用占位符。
---
## 0. 你需要准备的东西
- 一台 **Ubuntu** 本地机器(下文称“本机”)
- 一台可 SSH 登录、且 **能正常访问 OKX** 的 VPS(示例公网 IP`47.76.87.111`,用户:`root`
- VPS 登录方式:**SSH 私钥**(推荐)或密码(不推荐用于无人值守)
- 本机已安装:
- `python3``python3-venv``pip`(或 `python3-pip`
- `git`(可选)
- `curl``ssh`
- `node` + `npm`(用于安装 `pm2`
---
## 1. 从云服务器把项目同步到本地(推荐:打包下载)
在云服务器项目目录(包含 `app.py` 的目录)执行:
```bash
cd /path/to/crypto_monitor
# 可选:清理 Python 缓存,减少小文件传输
find . -type d -name __pycache__ -prune -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
tar -czf crypto_monitor.tgz .
```
下载 `crypto_monitor.tgz` 到本机后解压:
```bash
mkdir -p ~/apps
cd ~/apps
tar -xzf crypto_monitor.tgz -C crypto_monitor
cd crypto_monitor
```
---
## 2. 配置 SSH 私钥与 `~/.ssh/config`(推荐)
把私钥放到本机(示例:`~/.ssh/vps1.pem`),并设置权限:
```bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
mv ~/Downloads/vps1.pem ~/.ssh/vps1.pem
chmod 600 ~/.ssh/vps1.pem
```
编辑 `~/.ssh/config`(没有就创建),添加:
```sshconfig
Host okx-vps
HostName 47.76.87.111
User root
IdentityFile ~/.ssh/vps1.pem
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3
ExitOnForwardFailure yes
BatchMode yes
```
测试:
```bash
ssh okx-vps true
```
> 如果你还没完全切到密钥登录(还会交互要密码),先把 `BatchMode yes` 注释掉,等密钥登录稳定后再打开。
---
## 3. 先手工验证:SSH SOCKS + OKX API
### 3.1 开一个本地 SOCKS1080
```bash
ssh -N -D 127.0.0.1:1080 okx-vps
```
保持该进程运行(另开终端继续下面步骤)。
### 3.2 验证 OKX 走 SOCKS 可用
```bash
curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time
```
看到 `HTTP/2 200`(或至少 TLS 握手成功且返回 JSON)即 OK。
---
## 4. Python 虚拟环境(venv
在本机项目目录:
```bash
cd /root/crypto_monitor
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
pip install flask requests ccxt werkzeug PySocks Pillow
```
> 说明:本仓库当前没有 `requirements.txt`。如果你希望“完全复刻云服务器依赖”,可以在云服务器项目环境里执行 `pip freeze > requirements.txt` 带回本机再 `pip install -r requirements.txt`(记得删掉明显无关/体积巨大的包)。
建议减少 `.pyc` 垃圾文件(可选):
```bash
export PYTHONDONTWRITEBYTECODE=1
```
---
## 5. 配置 `.env`(本机)
复制示例环境文件(仓库里通常有 `.env`;没有就自己创建):
```bash
cp .env .env.local # 可选:备份
```
至少确认/填写这些关键项(示例):
```env
APP_HOST=127.0.0.1
APP_PORT=5000
# OKX(如需实盘)
LIVE_TRADING_ENABLED=false
OKX_API_KEY=...
OKX_API_SECRET=...
OKX_API_PASSPHRASE=...
# OKX 出口:走本机 SSH 动态转发 SOCKS
OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
# 开仓多周期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
# Ollama(如本机跑)
OLLAMA_API=http://127.0.0.1:11434/api/generate
AI_MODEL=你的模型名
```
> `OKX_SOCKS_PROXY` 使用 `socks5h`:让 SOCKS 侧做域名解析(更贴近你 `curl --proxy socks5h://...` 的成功路径)。
---
## 6. 本机手工启动(验证 Flask)
确保:
1. SOCKS 隧道已运行(127.0.0.1:1080
2. 虚拟环境已 `activate`
3. `.env` 已配置
启动:
```bash
cd ~/apps/crypto_monitor
source .venv/bin/activate
python app.py
```
浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 配的端口)。
---
## 7. 安装 PM2Node
```bash
sudo npm i -g pm2
pm2 -v
```
---
## 8. 用 PM2 启动 SSH SOCKS 隧道(推荐:密钥免交互)
### 8.1 启动隧道进程
```bash
pm2 start "ssh" --name okx-socks-tunnel -- \
-N -D 127.0.0.1:1080 okx-vps \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o ExitOnForwardFailure=yes -o BatchMode=yes
```
查看日志:
```bash
pm2 logs okx-socks-tunnel --lines 200
```
### 8.2 仍然验证 OKX
```bash
curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time
```
### 8.3 开机自启(可选)
```bash
pm2 save
pm2 startup
```
---
## 9. 用 PM2 启动 Flask`app.py`
`pm2` 管理 Python 的常用方式是直接启动解释器:
```bash
cd /root/crypto_monitor
pm2 start /root/crypto_monitor/.venv/bin/python --name crypto-monitor -- \
/root/crypto_monitor/app.py
```
> 请把路径里的 `你的用户名` 换成实际用户名;或用 `readlink -f app.py` 得到绝对路径。
查看日志:
```bash
pm2 logs crypto-monitor --lines 200
```
保存进程列表:
```bash
pm2 save
```
---
## 10. 常见问题排查(高频)
### 10.1 OKX 仍然失败:先看隧道是否在
```bash
ss -lntp | grep 1080 || true
pm2 status
```
### 10.2 `pm2` 里的 `ssh` 立刻退出
常见原因:
- 私钥权限不对(`chmod 600`
- `~/.ssh/config` 写错 `HostName/User/IdentityFile`
- 开了 `BatchMode yes` 但仍需要密码(会失败)
### 10.3 `ccxt` SOCKS 报错 / 代理不生效
本机 Python 依赖通常需要:
```bash
source .venv/bin/activate
pip install PySocks
```
### 10.4 `.pyc` 很多导致同步慢
`.pyc` 是缓存,删除不影响功能:
```bash
find . -type d -name __pycache__ -prune -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
```
---
## 11. 推荐的启动顺序(固定习惯)
1. `pm2` 启动 `okx-socks-tunnel`
2. `curl --proxy socks5h://127.0.0.1:1080 ...` 验证 OKX
3. `pm2` 启动 `crypto-monitor`
---
## 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='保本止盈'`
如果你想,我还可以再给你一条“先自动备份 DB 再执行”的一键命令。