fix: unify order/key focus K-line theme, PnL, RR and exchange price tick
Share focus_chart templates and APIs across four instances; align chart Y-axis, price lines and meta bar with exchange symbol precision and live unrealized PnL. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6549,39 +6549,51 @@ def api_order_kline():
|
|||||||
"volume": float(bar[5]),
|
"volume": float(bar[5]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
from focus_chart_lib import (
|
||||||
|
build_order_kline_order_payload,
|
||||||
|
load_swap_positions_for_order_kline,
|
||||||
|
metrics_for_order_item,
|
||||||
|
)
|
||||||
|
|
||||||
current_price = get_price(order_item["symbol"])
|
current_price = get_price(order_item["symbol"])
|
||||||
margin = float(order_item.get("margin_capital") or 0)
|
positions = load_swap_positions_for_order_kline(
|
||||||
leverage = float(order_item.get("leverage") or 0)
|
exchange,
|
||||||
entry = float(order_item.get("trigger_price") or 0)
|
private_configured=exchange_private_api_configured(),
|
||||||
float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
float_pct = round((float_pnl / margin * 100), 2) if margin > 0 else 0
|
)
|
||||||
|
ex_metrics = metrics_for_order_item(
|
||||||
|
order_item,
|
||||||
|
positions,
|
||||||
|
resolve_ex_sym_fn=resolve_monitor_exchange_symbol,
|
||||||
|
select_live_fn=_select_live_position_row,
|
||||||
|
parse_metrics_fn=parse_ccxt_position_metrics,
|
||||||
|
)
|
||||||
|
order_payload = build_order_kline_order_payload(
|
||||||
|
order_item,
|
||||||
|
ticker_price=current_price,
|
||||||
|
format_price_fn=format_price_for_symbol,
|
||||||
|
calc_pnl_fn=calc_pnl,
|
||||||
|
calc_rr_ratio_fn=calc_rr_ratio,
|
||||||
|
ex_metrics=ex_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
from focus_chart_lib import kline_api_price_fields
|
||||||
|
|
||||||
|
price_fields = kline_api_price_fields(
|
||||||
|
exchange,
|
||||||
|
exchange_symbol,
|
||||||
|
candles,
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"order": {
|
"order": order_payload,
|
||||||
"id": order_item["id"],
|
|
||||||
"symbol": order_item["symbol"],
|
|
||||||
"direction": order_item.get("direction") or "long",
|
|
||||||
"trigger_price": order_item.get("trigger_price"),
|
|
||||||
"stop_loss": order_item.get("stop_loss"),
|
|
||||||
"take_profit": order_item.get("take_profit"),
|
|
||||||
"trigger_price_display": format_price_for_symbol(exchange_symbol, order_item.get("trigger_price")),
|
|
||||||
"stop_loss_display": format_price_for_symbol(exchange_symbol, order_item.get("stop_loss")),
|
|
||||||
"take_profit_display": format_price_for_symbol(exchange_symbol, order_item.get("take_profit")),
|
|
||||||
"margin_capital": order_item.get("margin_capital"),
|
|
||||||
"leverage": order_item.get("leverage"),
|
|
||||||
"position_ratio": order_item.get("position_ratio"),
|
|
||||||
"rr_ratio": order_item.get("rr_ratio"),
|
|
||||||
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
|
|
||||||
"current_price": round(float(current_price), 8) if current_price else None,
|
|
||||||
"current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price else None,
|
|
||||||
"float_pnl": round(float(float_pnl), FUNDS_DECIMALS),
|
|
||||||
"float_pct": float_pct,
|
|
||||||
},
|
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
|
**price_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -6675,8 +6687,6 @@ def api_key_kline():
|
|||||||
"direction": key_row["direction"] or "long",
|
"direction": key_row["direction"] or "long",
|
||||||
"upper": upper,
|
"upper": upper,
|
||||||
"lower": lower,
|
"lower": lower,
|
||||||
"upper_display": format_price_for_symbol(exchange_symbol, upper) if upper is not None else None,
|
|
||||||
"lower_display": format_price_for_symbol(exchange_symbol, lower) if lower is not None else None,
|
|
||||||
"notification_count": int(key_row["notification_count"] or 0),
|
"notification_count": int(key_row["notification_count"] or 0),
|
||||||
"upper_diff": upper_diff,
|
"upper_diff": upper_diff,
|
||||||
"upper_pct": upper_pct,
|
"upper_pct": upper_pct,
|
||||||
@@ -6684,16 +6694,35 @@ def api_key_kline():
|
|||||||
"lower_pct": lower_pct,
|
"lower_pct": lower_pct,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from focus_chart_lib import enrich_key_kline_response
|
||||||
|
|
||||||
|
price_display, key_info = enrich_key_kline_response(
|
||||||
|
symbol=symbol,
|
||||||
|
current_price=current_price,
|
||||||
|
key_info=key_info,
|
||||||
|
format_price_fn=format_price_for_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
from focus_chart_lib import kline_api_price_fields
|
||||||
|
|
||||||
|
price_fields = kline_api_price_fields(
|
||||||
|
exchange,
|
||||||
|
exchange_symbol,
|
||||||
|
candles,
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
||||||
"current_price_display": format_price_for_symbol(exchange_symbol, current_price) if current_price is not None else None,
|
"current_price_display": price_display,
|
||||||
"key_monitor": key_info,
|
"key_monitor": key_info,
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
|
**price_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong 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 = data.current_price_display || 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 = key.upper_display || fmt(key.upper,8);
|
|
||||||
document.getElementById("m-lower").innerText = key.lower_display || 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>
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong 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 = order.trigger_price_display || fmt(order.trigger_price, 8);
|
|
||||||
document.getElementById("m-sl").innerText = order.stop_loss_display || fmt(order.stop_loss, 8);
|
|
||||||
document.getElementById("m-tp").innerText = order.take_profit_display || 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 = order.current_price_display || fmt(order.current_price, 8);
|
|
||||||
const pnlEl = document.getElementById("m-pnl");
|
|
||||||
pnlEl.innerText = `${fmt(order.float_pnl, 2)}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>
|
|
||||||
+58
-27
@@ -6551,40 +6551,51 @@ def api_order_kline():
|
|||||||
"volume": float(bar[5]),
|
"volume": float(bar[5]),
|
||||||
})
|
})
|
||||||
|
|
||||||
current_price = get_price(order_item["symbol"])
|
from focus_chart_lib import (
|
||||||
margin = float(order_item.get("margin_capital") or 0)
|
build_order_kline_order_payload,
|
||||||
leverage = float(order_item.get("leverage") or 0)
|
load_swap_positions_for_order_kline,
|
||||||
entry = float(order_item.get("trigger_price") or 0)
|
metrics_for_order_item,
|
||||||
float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0
|
)
|
||||||
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0
|
|
||||||
|
current_price = get_price(order_item["symbol"])
|
||||||
|
positions = load_swap_positions_for_order_kline(
|
||||||
|
exchange,
|
||||||
|
private_configured=exchange_private_api_configured(),
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
ex_metrics = metrics_for_order_item(
|
||||||
|
order_item,
|
||||||
|
positions,
|
||||||
|
resolve_ex_sym_fn=resolve_monitor_exchange_symbol,
|
||||||
|
select_live_fn=_select_live_position_row,
|
||||||
|
parse_metrics_fn=parse_ccxt_position_metrics,
|
||||||
|
)
|
||||||
|
order_payload = build_order_kline_order_payload(
|
||||||
|
order_item,
|
||||||
|
ticker_price=current_price,
|
||||||
|
format_price_fn=format_price_for_symbol,
|
||||||
|
calc_pnl_fn=calc_pnl,
|
||||||
|
calc_rr_ratio_fn=calc_rr_ratio,
|
||||||
|
ex_metrics=ex_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
from focus_chart_lib import kline_api_price_fields
|
||||||
|
|
||||||
|
price_fields = kline_api_price_fields(
|
||||||
|
exchange,
|
||||||
|
exchange_symbol,
|
||||||
|
candles,
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
sym = order_item["symbol"]
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"order": {
|
"order": order_payload,
|
||||||
"id": order_item["id"],
|
|
||||||
"symbol": sym,
|
|
||||||
"direction": order_item.get("direction") or "long",
|
|
||||||
"trigger_price": order_item.get("trigger_price"),
|
|
||||||
"stop_loss": order_item.get("stop_loss"),
|
|
||||||
"take_profit": order_item.get("take_profit"),
|
|
||||||
"trigger_price_display": format_price_for_symbol(sym, order_item.get("trigger_price")),
|
|
||||||
"stop_loss_display": format_price_for_symbol(sym, order_item.get("stop_loss")),
|
|
||||||
"take_profit_display": format_price_for_symbol(sym, order_item.get("take_profit")),
|
|
||||||
"margin_capital": order_item.get("margin_capital"),
|
|
||||||
"leverage": order_item.get("leverage"),
|
|
||||||
"position_ratio": order_item.get("position_ratio"),
|
|
||||||
"rr_ratio": order_item.get("rr_ratio"),
|
|
||||||
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
|
|
||||||
"current_price": round(float(current_price), 8) if current_price else None,
|
|
||||||
"current_price_display": format_price_for_symbol(sym, current_price) if current_price else None,
|
|
||||||
"float_pnl": round(float(float_pnl), 2),
|
|
||||||
"float_pct": float_pct,
|
|
||||||
},
|
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
|
**price_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -6685,15 +6696,35 @@ def api_key_kline():
|
|||||||
"lower_pct": lower_pct,
|
"lower_pct": lower_pct,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from focus_chart_lib import enrich_key_kline_response
|
||||||
|
|
||||||
|
price_display, key_info = enrich_key_kline_response(
|
||||||
|
symbol=symbol,
|
||||||
|
current_price=current_price,
|
||||||
|
key_info=key_info,
|
||||||
|
format_price_fn=format_price_for_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
from focus_chart_lib import kline_api_price_fields
|
||||||
|
|
||||||
|
price_fields = kline_api_price_fields(
|
||||||
|
exchange,
|
||||||
|
exchange_symbol,
|
||||||
|
candles,
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
||||||
|
"current_price_display": price_display,
|
||||||
"key_monitor": key_info,
|
"key_monitor": key_info,
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
|
**price_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong 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>
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong 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 = order.trigger_price_display || fmt(order.trigger_price, 8);
|
|
||||||
document.getElementById("m-sl").innerText = order.stop_loss_display || fmt(order.stop_loss, 8);
|
|
||||||
document.getElementById("m-tp").innerText = order.take_profit_display || 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 = order.current_price_display || fmt(order.current_price, 8);
|
|
||||||
const pnlEl = document.getElementById("m-pnl");
|
|
||||||
pnlEl.innerText = `${fmt(order.float_pnl, 2)}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>
|
|
||||||
@@ -6290,35 +6290,51 @@ def api_order_kline():
|
|||||||
"volume": float(bar[5]),
|
"volume": float(bar[5]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
from focus_chart_lib import (
|
||||||
|
build_order_kline_order_payload,
|
||||||
|
load_swap_positions_for_order_kline,
|
||||||
|
metrics_for_order_item,
|
||||||
|
)
|
||||||
|
|
||||||
current_price = get_price(order_item["symbol"])
|
current_price = get_price(order_item["symbol"])
|
||||||
margin = float(order_item.get("margin_capital") or 0)
|
positions = load_swap_positions_for_order_kline(
|
||||||
leverage = float(order_item.get("leverage") or 0)
|
exchange,
|
||||||
entry = float(order_item.get("trigger_price") or 0)
|
private_configured=exchange_private_api_configured(),
|
||||||
float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0
|
)
|
||||||
|
ex_metrics = metrics_for_order_item(
|
||||||
|
order_item,
|
||||||
|
positions,
|
||||||
|
resolve_ex_sym_fn=resolve_monitor_exchange_symbol,
|
||||||
|
select_live_fn=_select_live_position_row,
|
||||||
|
parse_metrics_fn=parse_ccxt_position_metrics,
|
||||||
|
)
|
||||||
|
order_payload = build_order_kline_order_payload(
|
||||||
|
order_item,
|
||||||
|
ticker_price=current_price,
|
||||||
|
format_price_fn=format_price_for_symbol,
|
||||||
|
calc_pnl_fn=calc_pnl,
|
||||||
|
calc_rr_ratio_fn=calc_rr_ratio,
|
||||||
|
ex_metrics=ex_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
from focus_chart_lib import kline_api_price_fields
|
||||||
|
|
||||||
|
price_fields = kline_api_price_fields(
|
||||||
|
exchange,
|
||||||
|
exchange_symbol,
|
||||||
|
candles,
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"order": {
|
"order": order_payload,
|
||||||
"id": order_item["id"],
|
|
||||||
"symbol": order_item["symbol"],
|
|
||||||
"direction": order_item.get("direction") or "long",
|
|
||||||
"trigger_price": order_item.get("trigger_price"),
|
|
||||||
"stop_loss": order_item.get("stop_loss"),
|
|
||||||
"take_profit": order_item.get("take_profit"),
|
|
||||||
"margin_capital": order_item.get("margin_capital"),
|
|
||||||
"leverage": order_item.get("leverage"),
|
|
||||||
"position_ratio": order_item.get("position_ratio"),
|
|
||||||
"rr_ratio": order_item.get("rr_ratio"),
|
|
||||||
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
|
|
||||||
"current_price": round(float(current_price), 8) if current_price else None,
|
|
||||||
"float_pnl": round(float(float_pnl), 6),
|
|
||||||
"float_pct": float_pct,
|
|
||||||
},
|
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
|
**price_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -6419,15 +6435,35 @@ def api_key_kline():
|
|||||||
"lower_pct": lower_pct,
|
"lower_pct": lower_pct,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from focus_chart_lib import enrich_key_kline_response
|
||||||
|
|
||||||
|
price_display, key_info = enrich_key_kline_response(
|
||||||
|
symbol=symbol,
|
||||||
|
current_price=current_price,
|
||||||
|
key_info=key_info,
|
||||||
|
format_price_fn=format_price_for_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
from focus_chart_lib import kline_api_price_fields
|
||||||
|
|
||||||
|
price_fields = kline_api_price_fields(
|
||||||
|
exchange,
|
||||||
|
exchange_symbol,
|
||||||
|
candles,
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
||||||
|
"current_price_display": price_display,
|
||||||
"key_monitor": key_info,
|
"key_monitor": key_info,
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
|
**price_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong 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>
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong 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>
|
|
||||||
+57
-20
@@ -6148,34 +6148,51 @@ def api_order_kline():
|
|||||||
"volume": float(bar[5]),
|
"volume": float(bar[5]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
from focus_chart_lib import (
|
||||||
|
build_order_kline_order_payload,
|
||||||
|
load_swap_positions_for_order_kline,
|
||||||
|
metrics_for_order_item,
|
||||||
|
)
|
||||||
|
|
||||||
current_price = get_price(order_item["symbol"])
|
current_price = get_price(order_item["symbol"])
|
||||||
margin = float(order_item.get("margin_capital") or 0)
|
positions = load_swap_positions_for_order_kline(
|
||||||
leverage = float(order_item.get("leverage") or 0)
|
exchange,
|
||||||
entry = float(order_item.get("trigger_price") or 0)
|
private_configured=exchange_private_api_configured(),
|
||||||
float_pnl = calc_pnl(order_item.get("direction") or "long", entry, current_price, margin, leverage) if current_price else 0
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0
|
)
|
||||||
|
ex_metrics = metrics_for_order_item(
|
||||||
|
order_item,
|
||||||
|
positions,
|
||||||
|
resolve_ex_sym_fn=resolve_monitor_exchange_symbol,
|
||||||
|
select_live_fn=_select_live_position_row,
|
||||||
|
parse_metrics_fn=parse_ccxt_position_metrics,
|
||||||
|
)
|
||||||
|
order_payload = build_order_kline_order_payload(
|
||||||
|
order_item,
|
||||||
|
ticker_price=current_price,
|
||||||
|
format_price_fn=format_price_for_symbol,
|
||||||
|
calc_pnl_fn=calc_pnl,
|
||||||
|
calc_rr_ratio_fn=calc_rr_ratio,
|
||||||
|
ex_metrics=ex_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
from focus_chart_lib import kline_api_price_fields
|
||||||
|
|
||||||
|
price_fields = kline_api_price_fields(
|
||||||
|
exchange,
|
||||||
|
exchange_symbol,
|
||||||
|
candles,
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"order": {
|
"order": order_payload,
|
||||||
"id": order_item["id"],
|
|
||||||
"symbol": order_item["symbol"],
|
|
||||||
"direction": order_item.get("direction") or "long",
|
|
||||||
"trigger_price": order_item.get("trigger_price"),
|
|
||||||
"stop_loss": order_item.get("stop_loss"),
|
|
||||||
"take_profit": order_item.get("take_profit"),
|
|
||||||
"margin_capital": order_item.get("margin_capital"),
|
|
||||||
"leverage": order_item.get("leverage"),
|
|
||||||
"position_ratio": order_item.get("position_ratio"),
|
|
||||||
"rr_ratio": order_item.get("rr_ratio"),
|
|
||||||
"current_price": round(float(current_price), 8) if current_price else None,
|
|
||||||
"float_pnl": round(float(float_pnl), 6),
|
|
||||||
"float_pct": float_pct,
|
|
||||||
},
|
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
|
**price_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -6275,15 +6292,35 @@ def api_key_kline():
|
|||||||
"lower_pct": lower_pct,
|
"lower_pct": lower_pct,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from focus_chart_lib import enrich_key_kline_response
|
||||||
|
|
||||||
|
price_display, key_info = enrich_key_kline_response(
|
||||||
|
symbol=symbol,
|
||||||
|
current_price=current_price,
|
||||||
|
key_info=key_info,
|
||||||
|
format_price_fn=format_price_for_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
from focus_chart_lib import kline_api_price_fields
|
||||||
|
|
||||||
|
price_fields = kline_api_price_fields(
|
||||||
|
exchange,
|
||||||
|
exchange_symbol,
|
||||||
|
candles,
|
||||||
|
ensure_markets_fn=ensure_markets_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"timeframe": timeframe,
|
"timeframe": timeframe,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
||||||
|
"current_price_display": price_display,
|
||||||
"key_monitor": key_info,
|
"key_monitor": key_info,
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"updated_at": app_now_str(),
|
"updated_at": app_now_str(),
|
||||||
|
**price_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong 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>
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="/static/instance_theme.js?v=4"></script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between">
|
|
||||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
|
||||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
||||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<a class="btn" href="/">返回首页</a>
|
|
||||||
<strong 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);
|
|
||||||
const rr = order.rr_ratio;
|
|
||||||
document.getElementById("m-rr").innerText = (rr === null || typeof rr === "undefined") ? "-:1" : `${Number(rr).toFixed(2)}:1`;
|
|
||||||
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,187 @@
|
|||||||
|
"""实盘/关键位放大 K 线:订单元数据与交易所浮盈、价格展示精度。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
from hub_ohlcv_lib import (
|
||||||
|
normalize_price_tick,
|
||||||
|
price_tick_from_market,
|
||||||
|
round_ohlcv_bars_to_tick,
|
||||||
|
)
|
||||||
|
from order_monitor_display_lib import (
|
||||||
|
apply_order_live_price_display,
|
||||||
|
apply_order_price_display_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_kline_price_tick(
|
||||||
|
exchange: Any,
|
||||||
|
exchange_symbol: str,
|
||||||
|
*,
|
||||||
|
ensure_markets_fn: Callable[[], None],
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""交易所最小价格变动单位,供 lightweight-charts 右侧刻度与标记线对齐。"""
|
||||||
|
if not exchange_symbol:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ensure_markets_fn()
|
||||||
|
return normalize_price_tick(price_tick_from_market(exchange, exchange_symbol))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def align_candles_to_price_tick(
|
||||||
|
candles: list[dict[str, Any]],
|
||||||
|
price_tick: Optional[float],
|
||||||
|
) -> None:
|
||||||
|
if price_tick is not None and candles:
|
||||||
|
round_ohlcv_bars_to_tick(candles, price_tick)
|
||||||
|
|
||||||
|
|
||||||
|
def kline_api_price_fields(
|
||||||
|
exchange: Any,
|
||||||
|
exchange_symbol: str,
|
||||||
|
candles: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
ensure_markets_fn: Callable[[], None],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
tick = resolve_kline_price_tick(
|
||||||
|
exchange, exchange_symbol, ensure_markets_fn=ensure_markets_fn
|
||||||
|
)
|
||||||
|
align_candles_to_price_tick(candles, tick)
|
||||||
|
return {"price_tick": tick}
|
||||||
|
|
||||||
|
|
||||||
|
def load_swap_positions_for_order_kline(
|
||||||
|
exchange: Any,
|
||||||
|
*,
|
||||||
|
private_configured: bool,
|
||||||
|
ensure_markets_fn: Callable[[], None],
|
||||||
|
settle: str = "usdt",
|
||||||
|
) -> list:
|
||||||
|
if not private_configured:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
ensure_markets_fn()
|
||||||
|
try:
|
||||||
|
return exchange.fetch_positions(None, {"settle": settle}) or []
|
||||||
|
except Exception:
|
||||||
|
return exchange.fetch_positions() or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_for_order_item(
|
||||||
|
order_item: dict[str, Any],
|
||||||
|
positions: list,
|
||||||
|
*,
|
||||||
|
resolve_ex_sym_fn: Callable[[Any], str],
|
||||||
|
select_live_fn: Callable[[list, str, str], Any],
|
||||||
|
parse_metrics_fn: Callable[..., Optional[dict]],
|
||||||
|
) -> Optional[dict]:
|
||||||
|
if not positions:
|
||||||
|
return None
|
||||||
|
ex_sym = resolve_ex_sym_fn(order_item)
|
||||||
|
direction = order_item.get("direction") or "long"
|
||||||
|
prow = select_live_fn(positions, ex_sym, direction)
|
||||||
|
if not prow:
|
||||||
|
return None
|
||||||
|
lev = order_item.get("leverage")
|
||||||
|
return parse_metrics_fn(prow, order_leverage=lev)
|
||||||
|
|
||||||
|
|
||||||
|
def build_order_kline_order_payload(
|
||||||
|
order_item: dict[str, Any],
|
||||||
|
*,
|
||||||
|
ticker_price: Any,
|
||||||
|
format_price_fn: Callable[[Any, Any], str],
|
||||||
|
calc_pnl_fn: Callable[..., float],
|
||||||
|
calc_rr_ratio_fn: Callable[..., Optional[float]],
|
||||||
|
ex_metrics: Optional[dict] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
sym = order_item.get("symbol") or ""
|
||||||
|
direction = order_item.get("direction") or "long"
|
||||||
|
margin = float(order_item.get("margin_capital") or 0)
|
||||||
|
leverage = float(order_item.get("leverage") or 0)
|
||||||
|
entry = float(order_item.get("trigger_price") or 0)
|
||||||
|
|
||||||
|
float_pnl = 0.0
|
||||||
|
float_pct = 0.0
|
||||||
|
if ticker_price and entry > 0:
|
||||||
|
float_pnl = float(
|
||||||
|
calc_pnl_fn(direction, entry, ticker_price, margin, leverage)
|
||||||
|
)
|
||||||
|
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0.0
|
||||||
|
|
||||||
|
px_for_fmt = ticker_price
|
||||||
|
mark_raw = None
|
||||||
|
if ex_metrics and ex_metrics.get("mark_price") is not None:
|
||||||
|
mark_raw = ex_metrics["mark_price"]
|
||||||
|
try:
|
||||||
|
px_for_fmt = float(mark_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if ex_metrics and ex_metrics.get("unrealized_pnl") is not None:
|
||||||
|
float_pnl = round(float(ex_metrics["unrealized_pnl"]), 2)
|
||||||
|
denom = ex_metrics.get("initial_margin") or margin
|
||||||
|
float_pct = (
|
||||||
|
round((float_pnl / float(denom)) * 100, 4)
|
||||||
|
if denom and float(denom) > 0
|
||||||
|
else float_pct
|
||||||
|
)
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"id": order_item["id"],
|
||||||
|
"symbol": sym,
|
||||||
|
"direction": direction,
|
||||||
|
"trigger_price": order_item.get("trigger_price"),
|
||||||
|
"stop_loss": order_item.get("stop_loss"),
|
||||||
|
"take_profit": order_item.get("take_profit"),
|
||||||
|
"trigger_price_display": format_price_fn(sym, order_item.get("trigger_price")),
|
||||||
|
"stop_loss_display": format_price_fn(sym, order_item.get("stop_loss")),
|
||||||
|
"take_profit_display": format_price_fn(sym, order_item.get("take_profit")),
|
||||||
|
"margin_capital": order_item.get("margin_capital"),
|
||||||
|
"leverage": order_item.get("leverage"),
|
||||||
|
"position_ratio": order_item.get("position_ratio"),
|
||||||
|
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
|
||||||
|
"current_price": round(float(px_for_fmt), 8) if px_for_fmt is not None else None,
|
||||||
|
"float_pnl": round(float(float_pnl), 2),
|
||||||
|
"float_pct": float_pct,
|
||||||
|
}
|
||||||
|
apply_order_price_display_fields(
|
||||||
|
payload,
|
||||||
|
direction=direction,
|
||||||
|
entry_price=order_item.get("trigger_price"),
|
||||||
|
initial_stop_loss=order_item.get("initial_stop_loss"),
|
||||||
|
stop_loss=order_item.get("stop_loss"),
|
||||||
|
take_profit=order_item.get("take_profit"),
|
||||||
|
calc_rr_ratio_fn=calc_rr_ratio_fn,
|
||||||
|
)
|
||||||
|
apply_order_live_price_display(
|
||||||
|
payload,
|
||||||
|
sym,
|
||||||
|
ticker_price,
|
||||||
|
mark_raw,
|
||||||
|
format_price_fn,
|
||||||
|
)
|
||||||
|
payload["current_price_display"] = payload.get("price_display") or (
|
||||||
|
format_price_fn(sym, px_for_fmt) if px_for_fmt is not None else None
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_key_kline_response(
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
current_price: Any,
|
||||||
|
key_info: Optional[dict[str, Any]],
|
||||||
|
format_price_fn: Callable[[Any, Any], str],
|
||||||
|
) -> tuple[Any, Optional[dict[str, Any]]]:
|
||||||
|
price_display = format_price_fn(symbol, current_price) if current_price is not None else None
|
||||||
|
if key_info is None:
|
||||||
|
return price_display, None
|
||||||
|
enriched = dict(key_info)
|
||||||
|
enriched["upper_display"] = format_price_fn(symbol, key_info.get("upper"))
|
||||||
|
enriched["lower_display"] = format_price_fn(symbol, key_info.get("lower"))
|
||||||
|
return price_display, enriched
|
||||||
@@ -51,6 +51,8 @@ def install_instance_theme_static(app) -> None:
|
|||||||
assets = {
|
assets = {
|
||||||
"instance_theme.js": "application/javascript; charset=utf-8",
|
"instance_theme.js": "application/javascript; charset=utf-8",
|
||||||
"instance_theme.css": "text/css; charset=utf-8",
|
"instance_theme.css": "text/css; charset=utf-8",
|
||||||
|
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
||||||
|
"focus_chart_page.css": "text/css; charset=utf-8",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, mime in assets.items():
|
for name, mime in assets.items():
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */
|
||||||
|
body.focus-page {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
padding: 14px;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--focus-bg, #0b0d14);
|
||||||
|
color: var(--focus-fg, #eaeaea);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] body.focus-page {
|
||||||
|
--focus-bg: #eef3f8;
|
||||||
|
--focus-fg: #142232;
|
||||||
|
--focus-card-bg: #fff;
|
||||||
|
--focus-card-border: #b8c8d8;
|
||||||
|
--focus-meta-bg: #fff;
|
||||||
|
--focus-meta-border: #9eb4c8;
|
||||||
|
--focus-meta-label: #2a4a66;
|
||||||
|
--focus-meta-value: #0a1628;
|
||||||
|
--focus-status: #4a6078;
|
||||||
|
--focus-chart-bg: #f0f4f9;
|
||||||
|
--focus-chart-border: #b8c8d8;
|
||||||
|
--focus-btn-bg: #fff;
|
||||||
|
--focus-btn-fg: #006e9a;
|
||||||
|
--focus-btn-border: rgba(0, 95, 140, 0.22);
|
||||||
|
--focus-input-bg: #fff;
|
||||||
|
--focus-input-fg: #142232;
|
||||||
|
--focus-input-border: #b8c8d8;
|
||||||
|
--focus-title: #0a1628;
|
||||||
|
--focus-pnl-up: #0a7a3d;
|
||||||
|
--focus-pnl-down: #c62828;
|
||||||
|
--focus-dir-short: #b71c1c;
|
||||||
|
--focus-dir-long: #0a7a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] body.focus-page {
|
||||||
|
--focus-bg: #0b0d14;
|
||||||
|
--focus-fg: #eaeaea;
|
||||||
|
--focus-card-bg: #121726;
|
||||||
|
--focus-card-border: #2a3150;
|
||||||
|
--focus-meta-bg: #141b2f;
|
||||||
|
--focus-meta-border: #3d4f72;
|
||||||
|
--focus-meta-label: #c8d8f0;
|
||||||
|
--focus-meta-value: #f0f4ff;
|
||||||
|
--focus-status: #95a2c2;
|
||||||
|
--focus-chart-bg: #0f1320;
|
||||||
|
--focus-chart-border: #2a3150;
|
||||||
|
--focus-btn-bg: #151a2a;
|
||||||
|
--focus-btn-fg: #8fc8ff;
|
||||||
|
--focus-btn-border: #304164;
|
||||||
|
--focus-input-bg: #1a1a29;
|
||||||
|
--focus-input-fg: #fff;
|
||||||
|
--focus-input-border: #2e2e45;
|
||||||
|
--focus-title: #dbe4ff;
|
||||||
|
--focus-pnl-up: #3ddc84;
|
||||||
|
--focus-pnl-down: #ff7070;
|
||||||
|
--focus-dir-short: #ff8a80;
|
||||||
|
--focus-dir-long: #69f0ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.focus-page * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .container {
|
||||||
|
width: min(98vw, 1900px);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .card {
|
||||||
|
background: var(--focus-card-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--focus-card-border);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .btn {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--focus-btn-border);
|
||||||
|
background: var(--focus-btn-bg);
|
||||||
|
color: var(--focus-btn-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .btn:hover {
|
||||||
|
filter: brightness(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page select,
|
||||||
|
.focus-page input,
|
||||||
|
.focus-page button {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--focus-input-border);
|
||||||
|
background: var(--focus-input-bg);
|
||||||
|
color: var(--focus-input-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .focus-title {
|
||||||
|
color: var(--focus-title);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-item {
|
||||||
|
background: var(--focus-meta-bg);
|
||||||
|
border: 1px solid var(--focus-meta-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 10px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-item .k {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--focus-meta-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-item .v {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 5px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--focus-meta-value);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-item--emph {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: var(--focus-meta-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-item--emph .k {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-item--emph .v {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-item--pnl .v {
|
||||||
|
font-size: 1.14rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-pnl-up {
|
||||||
|
color: var(--focus-pnl-up) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-pnl-down {
|
||||||
|
color: var(--focus-pnl-down) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-dir-long {
|
||||||
|
color: var(--focus-dir-long) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .meta-dir-short {
|
||||||
|
color: var(--focus-dir-short) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .status {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--focus-status);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .status.err {
|
||||||
|
color: var(--focus-pnl-down);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page #chart-wrap {
|
||||||
|
height: 560px;
|
||||||
|
background: var(--focus-chart-bg);
|
||||||
|
border: 1px solid var(--focus-chart-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page #chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .empty {
|
||||||
|
padding: 18px;
|
||||||
|
color: var(--focus-status);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-page .exchange-tag {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #b8f5d0;
|
||||||
|
background: #14241e;
|
||||||
|
border: 1px solid #2d6a4f;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .focus-page .exchange-tag {
|
||||||
|
color: #0a5c38;
|
||||||
|
background: #e8f5ee;
|
||||||
|
border-color: #7bc9a0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* 实盘/关键位放大 K 线:交易所 tick 精度、主题感知图表、高对比 meta。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
let activePriceTick = null;
|
||||||
|
|
||||||
|
function currentTheme() {
|
||||||
|
return document.documentElement.getAttribute("data-theme") === "light"
|
||||||
|
? "light"
|
||||||
|
: "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartTheme(theme) {
|
||||||
|
if (theme === "light") {
|
||||||
|
return {
|
||||||
|
layout: { background: { color: "#f0f4f9" }, textColor: "#142232" },
|
||||||
|
grid: { vertLines: { color: "#d0dae4" }, horzLines: { color: "#d0dae4" } },
|
||||||
|
rightPriceScale: { borderColor: "#b8c8d8" },
|
||||||
|
timeScale: { borderColor: "#b8c8d8" },
|
||||||
|
candle: {
|
||||||
|
upColor: "#0a7a3d",
|
||||||
|
downColor: "#c62828",
|
||||||
|
wickUpColor: "#0a7a3d",
|
||||||
|
wickDownColor: "#c62828",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
|
||||||
|
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
|
||||||
|
rightPriceScale: { borderColor: "#2a3150" },
|
||||||
|
timeScale: { borderColor: "#2a3150" },
|
||||||
|
candle: {
|
||||||
|
upColor: "#4cd97f",
|
||||||
|
downColor: "#ff6666",
|
||||||
|
wickUpColor: "#4cd97f",
|
||||||
|
wickDownColor: "#ff6666",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 };
|
||||||
|
|
||||||
|
function decimalsFromTick(tick) {
|
||||||
|
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
|
||||||
|
const minMove = Number(tick);
|
||||||
|
if (minMove >= 1) return 0;
|
||||||
|
const raw = String(minMove);
|
||||||
|
const sci = raw.match(/e-(\d+)/i);
|
||||||
|
if (sci) return Math.min(12, parseInt(sci[1], 10));
|
||||||
|
const fixed = minMove.toFixed(12);
|
||||||
|
const frac = fixed.split(".")[1] || "";
|
||||||
|
const trimmed = frac.replace(/0+$/, "");
|
||||||
|
if (trimmed.length) return Math.min(12, trimmed.length);
|
||||||
|
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickToPriceFormat(tick) {
|
||||||
|
try {
|
||||||
|
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
|
||||||
|
return { type: "price", precision: 2, minMove: 0.01 };
|
||||||
|
}
|
||||||
|
const minMove = Number(tick);
|
||||||
|
let prec = decimalsFromTick(minMove);
|
||||||
|
if (prec == null || prec < 0) prec = 4;
|
||||||
|
prec = Math.min(12, Math.max(0, Math.floor(prec)));
|
||||||
|
return { type: "price", precision: prec, minMove: minMove };
|
||||||
|
} catch (_) {
|
||||||
|
return SAFE_PRICE_FORMAT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToTick(v, tick) {
|
||||||
|
if (v == null || Number.isNaN(Number(v))) return v;
|
||||||
|
const n = Number(v);
|
||||||
|
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n;
|
||||||
|
const t = Number(tick);
|
||||||
|
const rounded = Math.round(n / t) * t;
|
||||||
|
const dec = decimalsFromTick(t);
|
||||||
|
if (dec == null) return rounded;
|
||||||
|
return parseFloat(rounded.toFixed(dec));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPriceByTick(v, tick) {
|
||||||
|
if (v == null || Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(roundToTick(v, tick));
|
||||||
|
if (n === 0) return "0";
|
||||||
|
const dec = decimalsFromTick(tick);
|
||||||
|
if (dec != null) return n.toFixed(dec);
|
||||||
|
const av = Math.abs(n);
|
||||||
|
let d = 8;
|
||||||
|
if (av >= 10000) d = 2;
|
||||||
|
else if (av >= 100) d = 3;
|
||||||
|
else if (av >= 1) d = 4;
|
||||||
|
else if (av >= 0.01) d = 6;
|
||||||
|
const text = n.toFixed(d);
|
||||||
|
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivePriceTick(tick) {
|
||||||
|
activePriceTick =
|
||||||
|
tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0
|
||||||
|
? null
|
||||||
|
: Number(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSigned(v, digits) {
|
||||||
|
digits = digits === undefined ? 2 : digits;
|
||||||
|
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(v);
|
||||||
|
const sign = n > 0 ? "+" : "";
|
||||||
|
return sign + n.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedPrice(v) {
|
||||||
|
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
|
||||||
|
const n = Number(v);
|
||||||
|
const body = fmtPriceByTick(Math.abs(n), activePriceTick);
|
||||||
|
if (body === "-") return "-";
|
||||||
|
return (n > 0 ? "+" : n < 0 ? "-" : "") + body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRrRatio(rr) {
|
||||||
|
if (rr === null || typeof rr === "undefined") return "-:1";
|
||||||
|
const n = Number(rr);
|
||||||
|
if (Number.isNaN(n)) return "-:1";
|
||||||
|
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
|
||||||
|
return body + ":1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPrice(orderOrData, field, rawField) {
|
||||||
|
const dispKey = field + "_display";
|
||||||
|
if (orderOrData && orderOrData[dispKey] && orderOrData[dispKey] !== "-") {
|
||||||
|
return String(orderOrData[dispKey]);
|
||||||
|
}
|
||||||
|
const raw = orderOrData ? orderOrData[rawField || field] : null;
|
||||||
|
if (raw === null || typeof raw === "undefined" || Number.isNaN(Number(raw))) return "-";
|
||||||
|
return fmtPriceByTick(raw, activePriceTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineTitle(label, display) {
|
||||||
|
const d = display && display !== "-" ? display : "";
|
||||||
|
return d ? label + " " + d : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintOrderMeta(order) {
|
||||||
|
const symEl = document.getElementById("m-symbol");
|
||||||
|
const dirEl = document.getElementById("m-direction");
|
||||||
|
const pnlEl = document.getElementById("m-pnl");
|
||||||
|
if (symEl) symEl.textContent = order.symbol || "-";
|
||||||
|
if (dirEl) {
|
||||||
|
const isShort = order.direction === "short";
|
||||||
|
dirEl.textContent = isShort ? "做空" : "做多";
|
||||||
|
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
|
||||||
|
}
|
||||||
|
const set = function (id, text) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
};
|
||||||
|
set("m-entry", displayPrice(order, "trigger_price"));
|
||||||
|
set("m-sl", displayPrice(order, "stop_loss"));
|
||||||
|
set("m-tp", displayPrice(order, "take_profit"));
|
||||||
|
set("m-rr", formatRrRatio(order.rr_ratio));
|
||||||
|
set(
|
||||||
|
"m-breakeven",
|
||||||
|
order.breakeven_enabled === false || order.breakeven_enabled === 0 ? "关闭" : "开启"
|
||||||
|
);
|
||||||
|
set(
|
||||||
|
"m-price",
|
||||||
|
order.current_price_display ||
|
||||||
|
order.price_display ||
|
||||||
|
displayPrice(order, "current_price")
|
||||||
|
);
|
||||||
|
if (pnlEl) {
|
||||||
|
pnlEl.textContent =
|
||||||
|
formatSigned(order.float_pnl, 2) +
|
||||||
|
"U (" +
|
||||||
|
formatSigned(order.float_pct, 2) +
|
||||||
|
"%)";
|
||||||
|
pnlEl.className = "v";
|
||||||
|
const pnl = Number(order.float_pnl || 0);
|
||||||
|
if (pnl > 0) pnlEl.classList.add("meta-pnl-up");
|
||||||
|
else if (pnl < 0) pnlEl.classList.add("meta-pnl-down");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintKeyMeta(data) {
|
||||||
|
const key = data.key_monitor || null;
|
||||||
|
const symEl = document.getElementById("m-symbol");
|
||||||
|
if (symEl) symEl.textContent = data.symbol || "-";
|
||||||
|
const set = function (id, text) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
};
|
||||||
|
set(
|
||||||
|
"m-price",
|
||||||
|
data.current_price_display || displayPrice(data, "current_price")
|
||||||
|
);
|
||||||
|
const dirEl = document.getElementById("m-direction");
|
||||||
|
if (!key) {
|
||||||
|
set("m-type", "未匹配到关键位");
|
||||||
|
set("m-direction", "-");
|
||||||
|
if (dirEl) dirEl.className = "v";
|
||||||
|
set("m-upper", "-");
|
||||||
|
set("m-lower", "-");
|
||||||
|
set("m-updiff", "-");
|
||||||
|
set("m-lowdiff", "-");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set("m-type", key.monitor_type || "-");
|
||||||
|
if (dirEl) {
|
||||||
|
const isShort = key.direction === "short";
|
||||||
|
dirEl.textContent = isShort ? "做空" : "做多";
|
||||||
|
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
|
||||||
|
}
|
||||||
|
set("m-upper", key.upper_display || displayPrice(key, "upper"));
|
||||||
|
set("m-lower", key.lower_display || displayPrice(key, "lower"));
|
||||||
|
if (activePriceTick != null) {
|
||||||
|
set(
|
||||||
|
"m-updiff",
|
||||||
|
formatSignedPrice(key.upper_diff) +
|
||||||
|
" (" +
|
||||||
|
formatSigned(key.upper_pct, 2) +
|
||||||
|
"%)"
|
||||||
|
);
|
||||||
|
set(
|
||||||
|
"m-lowdiff",
|
||||||
|
formatSignedPrice(key.lower_diff) +
|
||||||
|
" (" +
|
||||||
|
formatSigned(key.lower_pct, 2) +
|
||||||
|
"%)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
set(
|
||||||
|
"m-updiff",
|
||||||
|
formatSigned(key.upper_diff, 4) + " (" + formatSigned(key.upper_pct, 2) + "%)"
|
||||||
|
);
|
||||||
|
set(
|
||||||
|
"m-lowdiff",
|
||||||
|
formatSigned(key.lower_diff, 4) + " (" + formatSigned(key.lower_pct, 2) + "%)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPriceFormatToSeries(series, pf) {
|
||||||
|
if (!series || !series.applyOptions) return;
|
||||||
|
try {
|
||||||
|
series.applyOptions({ priceFormat: pf });
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT });
|
||||||
|
} catch (_2) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFocusChart(host) {
|
||||||
|
if (!global.LightweightCharts) return null;
|
||||||
|
const th = chartTheme(currentTheme());
|
||||||
|
const chart = global.LightweightCharts.createChart(host, {
|
||||||
|
layout: th.layout,
|
||||||
|
grid: th.grid,
|
||||||
|
rightPriceScale: th.rightPriceScale,
|
||||||
|
timeScale: Object.assign({ timeVisible: true, secondsVisible: false }, th.timeScale),
|
||||||
|
crosshair: { mode: 0 },
|
||||||
|
localization: {
|
||||||
|
priceFormatter: function (p) {
|
||||||
|
return fmtPriceByTick(p, activePriceTick);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let candleSeries = null;
|
||||||
|
|
||||||
|
function applyChartPriceFormat() {
|
||||||
|
let pf = SAFE_PRICE_FORMAT;
|
||||||
|
try {
|
||||||
|
pf = tickToPriceFormat(activePriceTick);
|
||||||
|
} catch (_) {
|
||||||
|
pf = SAFE_PRICE_FORMAT;
|
||||||
|
}
|
||||||
|
applyPriceFormatToSeries(candleSeries, pf);
|
||||||
|
try {
|
||||||
|
chart.applyOptions({
|
||||||
|
localization: {
|
||||||
|
priceFormatter: function (p) {
|
||||||
|
return fmtPriceByTick(p, activePriceTick);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPriceTick(tick) {
|
||||||
|
setActivePriceTick(tick);
|
||||||
|
applyChartPriceFormat();
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = Object.assign({ borderVisible: false }, th.candle);
|
||||||
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
|
candleSeries = chart.addCandlestickSeries(opts);
|
||||||
|
} else if (
|
||||||
|
typeof chart.addSeries === "function" &&
|
||||||
|
global.LightweightCharts.CandlestickSeries
|
||||||
|
) {
|
||||||
|
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, opts);
|
||||||
|
}
|
||||||
|
applyChartPriceFormat();
|
||||||
|
|
||||||
|
const priceLines = [];
|
||||||
|
function resetPriceLines() {
|
||||||
|
if (!candleSeries) return;
|
||||||
|
priceLines.forEach(function (line) {
|
||||||
|
try {
|
||||||
|
candleSeries.removePriceLine(line);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
priceLines.length = 0;
|
||||||
|
}
|
||||||
|
function addLine(price, title, color) {
|
||||||
|
if (!candleSeries || price === null || typeof price === "undefined") return;
|
||||||
|
const p = Number(roundToTick(price, activePriceTick));
|
||||||
|
if (Number.isNaN(p) || p <= 0) return;
|
||||||
|
priceLines.push(
|
||||||
|
candleSeries.createPriceLine({
|
||||||
|
price: p,
|
||||||
|
color: color,
|
||||||
|
lineWidth: 1,
|
||||||
|
lineStyle: 0,
|
||||||
|
axisLabelVisible: true,
|
||||||
|
title: title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function applyTheme() {
|
||||||
|
const t = chartTheme(currentTheme());
|
||||||
|
chart.applyOptions({
|
||||||
|
layout: t.layout,
|
||||||
|
grid: t.grid,
|
||||||
|
rightPriceScale: t.rightPriceScale,
|
||||||
|
timeScale: t.timeScale,
|
||||||
|
localization: {
|
||||||
|
priceFormatter: function (p) {
|
||||||
|
return fmtPriceByTick(p, activePriceTick);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (candleSeries && typeof candleSeries.applyOptions === "function") {
|
||||||
|
candleSeries.applyOptions(t.candle);
|
||||||
|
}
|
||||||
|
applyChartPriceFormat();
|
||||||
|
}
|
||||||
|
function resize() {
|
||||||
|
chart.applyOptions({ width: host.clientWidth, height: host.clientHeight });
|
||||||
|
}
|
||||||
|
global.addEventListener("resize", resize);
|
||||||
|
resize();
|
||||||
|
const obs = new MutationObserver(applyTheme);
|
||||||
|
obs.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["data-theme"],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
chart: chart,
|
||||||
|
candleSeries: candleSeries,
|
||||||
|
resetPriceLines: resetPriceLines,
|
||||||
|
addLine: addLine,
|
||||||
|
applyTheme: applyTheme,
|
||||||
|
setPriceTick: setPriceTick,
|
||||||
|
ensureSeries: function () {
|
||||||
|
if (candleSeries) return true;
|
||||||
|
const t = chartTheme(currentTheme());
|
||||||
|
const o = Object.assign({ borderVisible: false }, t.candle);
|
||||||
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
|
candleSeries = chart.addCandlestickSeries(o);
|
||||||
|
} else if (
|
||||||
|
typeof chart.addSeries === "function" &&
|
||||||
|
global.LightweightCharts.CandlestickSeries
|
||||||
|
) {
|
||||||
|
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, o);
|
||||||
|
}
|
||||||
|
applyChartPriceFormat();
|
||||||
|
return !!candleSeries;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
global.FocusChartPage = {
|
||||||
|
currentTheme: currentTheme,
|
||||||
|
chartTheme: chartTheme,
|
||||||
|
formatSigned: formatSigned,
|
||||||
|
formatRrRatio: formatRrRatio,
|
||||||
|
displayPrice: displayPrice,
|
||||||
|
lineTitle: lineTitle,
|
||||||
|
paintOrderMeta: paintOrderMeta,
|
||||||
|
paintKeyMeta: paintKeyMeta,
|
||||||
|
createFocusChart: createFocusChart,
|
||||||
|
setActivePriceTick: setActivePriceTick,
|
||||||
|
fmtPriceByTick: fmtPriceByTick,
|
||||||
|
};
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<script src="/static/instance_theme.js?v=5"></script>
|
||||||
|
<title>{{ exchange_display }} | 关键位放大</title>
|
||||||
|
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
|
||||||
|
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
|
||||||
|
</head>
|
||||||
|
<body class="focus-page">
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
<strong class="focus-title">关键位放大(可输入币种)</strong><span class="exchange-tag">{{ exchange_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status">最近刷新:<span id="updated-at">--</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<label>币种</label>
|
||||||
|
<input id="symbol-input" value="{{ default_symbol }}" placeholder="BTC/USDT">
|
||||||
|
<label>关键位</label>
|
||||||
|
<select id="key-id">
|
||||||
|
<option value="">无(仅看K线)</option>
|
||||||
|
{% for k in key_list %}
|
||||||
|
<option value="{{ k.id }}" {% if selected_key and k.id == selected_key.id %}selected{% endif %}>#{{ k.id }} {{ k.symbol }} {{ k.monitor_type }} {{ '做多' if k.direction == 'long' else '做空' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>周期</label>
|
||||||
|
<select id="timeframe">
|
||||||
|
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
|
||||||
|
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>K线数</label>
|
||||||
|
<select id="kline-limit">
|
||||||
|
<option value="100" {% if default_kline_limit == 100 %}selected{% endif %}>100</option>
|
||||||
|
<option value="200" {% if default_kline_limit == 200 %}selected{% endif %}>200</option>
|
||||||
|
</select>
|
||||||
|
<button id="manual-refresh" type="button">刷新</button>
|
||||||
|
<span id="load-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="meta">
|
||||||
|
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">监控类型</div><div class="v" id="m-type">-</div></div>
|
||||||
|
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">上沿/阻力</div><div class="v" id="m-upper">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">下沿/支撑</div><div class="v" id="m-lower">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">距上沿</div><div class="v" id="m-updiff">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">距下沿</div><div class="v" id="m-lowdiff">-</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card"><div id="chart-wrap"><div id="chart"></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script src="/static/focus_chart_page.js?v=2"></script>
|
||||||
|
<script>
|
||||||
|
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
|
||||||
|
const keySelect = document.getElementById("key-id");
|
||||||
|
const symbolInput = document.getElementById("symbol-input");
|
||||||
|
const tfSelect = document.getElementById("timeframe");
|
||||||
|
const limitSelect = document.getElementById("kline-limit");
|
||||||
|
const statusEl = document.getElementById("load-status");
|
||||||
|
const updatedAtEl = document.getElementById("updated-at");
|
||||||
|
const chartHost = document.getElementById("chart");
|
||||||
|
const FCP = window.FocusChartPage;
|
||||||
|
const keyMap = {};
|
||||||
|
{% for k in key_list %}
|
||||||
|
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
|
||||||
|
{% endfor %}
|
||||||
|
let fc = null;
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(fc && fc.ensureSeries()) return true;
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fc = FCP.createFocusChart(chartHost);
|
||||||
|
if(!fc || !fc.ensureSeries()){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "K线序列初始化失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSymbolByKey(){
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
if(keyId && keyMap[keyId]) symbolInput.value = keyMap[keyId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeyKline(){
|
||||||
|
if(!ensureChart()) return;
|
||||||
|
const keyId = keySelect.value;
|
||||||
|
const symbol = (symbolInput.value || "").trim().toUpperCase();
|
||||||
|
const timeframe = tfSelect.value;
|
||||||
|
const limit = limitSelect.value;
|
||||||
|
if(!symbol && !keyId){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "请先输入币种或选择关键位";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.innerText = "加载中...";
|
||||||
|
try{
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if(keyId) qs.set("key_id", keyId);
|
||||||
|
if(symbol) qs.set("symbol", symbol);
|
||||||
|
qs.set("timeframe", timeframe);
|
||||||
|
qs.set("limit", limit);
|
||||||
|
const resp = await fetch(`/api/key_kline?${qs.toString()}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
|
||||||
|
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
|
||||||
|
else FCP.setActivePriceTick(data.price_tick);
|
||||||
|
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||||
|
if(!candles.length){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "暂无K线数据";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fc.candleSeries.setData(candles);
|
||||||
|
fc.resetPriceLines();
|
||||||
|
fc.addLine(data.current_price, FCP.lineTitle("现价", data.current_price_display), "#42a5f5");
|
||||||
|
if(data.key_monitor){
|
||||||
|
const km = data.key_monitor;
|
||||||
|
fc.addLine(km.upper, FCP.lineTitle("上沿", km.upper_display), "#ffb84d");
|
||||||
|
fc.addLine(km.lower, FCP.lineTitle("下沿", km.lower_display), "#4cd97f");
|
||||||
|
}
|
||||||
|
fc.chart.timeScale().fitContent();
|
||||||
|
FCP.paintKeyMeta(data);
|
||||||
|
updatedAtEl.innerText = data.updated_at || "--";
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.innerText = `已加载 ${candles.length} 根K线`;
|
||||||
|
}catch(err){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = err && err.message ? err.message : "加载失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("manual-refresh").addEventListener("click", loadKeyKline);
|
||||||
|
keySelect.addEventListener("change", ()=>{ syncSymbolByKey(); loadKeyKline(); });
|
||||||
|
symbolInput.addEventListener("change", ()=>{
|
||||||
|
if(symbolInput.value.trim()) keySelect.value = "";
|
||||||
|
loadKeyKline();
|
||||||
|
});
|
||||||
|
tfSelect.addEventListener("change", loadKeyKline);
|
||||||
|
limitSelect.addEventListener("change", loadKeyKline);
|
||||||
|
syncSymbolByKey();
|
||||||
|
loadKeyKline();
|
||||||
|
setInterval(loadKeyKline, refreshMs);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<script src="/static/instance_theme.js?v=5"></script>
|
||||||
|
<title>{{ exchange_display }} | 实盘下单放大</title>
|
||||||
|
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
|
||||||
|
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
|
||||||
|
</head>
|
||||||
|
<body class="focus-page">
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||||
|
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<a class="btn" href="/">返回首页</a>
|
||||||
|
<strong class="focus-title">实盘下单放大(100根K线)</strong><span class="exchange-tag">{{ exchange_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status">最近刷新:<span id="updated-at">--</span></div>
|
||||||
|
</div>
|
||||||
|
{% if orders %}
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<label>订单</label>
|
||||||
|
<select id="order-id">
|
||||||
|
{% for o in orders %}
|
||||||
|
<option value="{{ o.id }}" {% if selected_order and o.id == selected_order.id %}selected{% endif %}>
|
||||||
|
#{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>周期</label>
|
||||||
|
<select id="timeframe">
|
||||||
|
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
|
||||||
|
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button id="manual-refresh" type="button">刷新</button>
|
||||||
|
<span id="load-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">当前没有激活订单,无法展示放大K线。</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if orders %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="meta">
|
||||||
|
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
|
||||||
|
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">成交价</div><div class="v" id="m-entry">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">止损</div><div class="v" id="m-sl">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">止盈</div><div class="v" id="m-tp">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">盈亏比</div><div class="v" id="m-rr">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">移动保本</div><div class="v" id="m-breakeven">-</div></div>
|
||||||
|
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
|
||||||
|
<div class="meta-item meta-item--emph meta-item--pnl"><div class="k">浮盈亏</div><div class="v" id="m-pnl">-</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div id="chart-wrap"><div id="chart"></div></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if orders %}
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script src="/static/focus_chart_page.js?v=2"></script>
|
||||||
|
<script>
|
||||||
|
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
|
||||||
|
const orderSelect = document.getElementById("order-id");
|
||||||
|
const tfSelect = document.getElementById("timeframe");
|
||||||
|
const statusEl = document.getElementById("load-status");
|
||||||
|
const updatedAtEl = document.getElementById("updated-at");
|
||||||
|
const chartHost = document.getElementById("chart");
|
||||||
|
const FCP = window.FocusChartPage;
|
||||||
|
let fc = null;
|
||||||
|
|
||||||
|
function ensureChart(){
|
||||||
|
if(fc && fc.ensureSeries()) return true;
|
||||||
|
if(!window.LightweightCharts){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "图表库加载失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fc = FCP.createFocusChart(chartHost);
|
||||||
|
if(!fc || !fc.ensureSeries()){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "K线序列初始化失败";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrderKline(){
|
||||||
|
if(!ensureChart()) return;
|
||||||
|
const orderId = orderSelect.value;
|
||||||
|
const timeframe = tfSelect.value;
|
||||||
|
if(!orderId) return;
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.innerText = "加载中...";
|
||||||
|
try{
|
||||||
|
const resp = await fetch(`/api/order_kline?order_id=${encodeURIComponent(orderId)}&timeframe=${encodeURIComponent(timeframe)}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
|
||||||
|
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
|
||||||
|
else FCP.setActivePriceTick(data.price_tick);
|
||||||
|
const candles = Array.isArray(data.candles) ? data.candles : [];
|
||||||
|
if(!candles.length){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = "暂无K线数据";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fc.candleSeries.setData(candles);
|
||||||
|
fc.resetPriceLines();
|
||||||
|
const o = data.order || {};
|
||||||
|
fc.addLine(o.trigger_price, FCP.lineTitle("成交价", o.trigger_price_display), "#42a5f5");
|
||||||
|
fc.addLine(o.stop_loss, FCP.lineTitle("止损", o.stop_loss_display), "#ff6666");
|
||||||
|
fc.addLine(o.take_profit, FCP.lineTitle("止盈", o.take_profit_display), "#4cd97f");
|
||||||
|
const markPx = o.current_price;
|
||||||
|
if(markPx) fc.addLine(markPx, FCP.lineTitle("现价", o.current_price_display), "#ffb74d");
|
||||||
|
fc.chart.timeScale().fitContent();
|
||||||
|
FCP.paintOrderMeta(o);
|
||||||
|
updatedAtEl.innerText = data.updated_at || "--";
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.innerText = `已加载 ${candles.length} 根K线`;
|
||||||
|
}catch(err){
|
||||||
|
statusEl.className = "status err";
|
||||||
|
statusEl.innerText = err && err.message ? err.message : "加载失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("manual-refresh").addEventListener("click", loadOrderKline);
|
||||||
|
orderSelect.addEventListener("change", loadOrderKline);
|
||||||
|
tfSelect.addEventListener("change", loadOrderKline);
|
||||||
|
loadOrderKline();
|
||||||
|
setInterval(loadOrderKline, refreshMs);
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user