feat(hub): add open/close arrows on archive chart with continuous klines
Span chart window across hold period, fill 5m gaps for smooth aggregation, and mark entry/exit with lightweight-charts arrows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -537,12 +537,71 @@ def _to_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _snap_to_bar_grid(ts_ms: int, origin_ms: int, step_ms: int) -> int:
|
||||
step = max(1, int(step_ms))
|
||||
origin = int(origin_ms)
|
||||
if ts_ms <= origin:
|
||||
return origin
|
||||
idx = (int(ts_ms) - origin + step - 1) // step
|
||||
return origin + idx * step
|
||||
|
||||
|
||||
def _fill_missing_bars(
|
||||
bars: list[dict[str, Any]],
|
||||
period_ms: int,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""5m 缺口用上一根收盘价填平,保证聚合后 K 线时间轴连续。"""
|
||||
by_ts: dict[int, dict[str, Any]] = {}
|
||||
for b in bars or []:
|
||||
try:
|
||||
by_ts[int(b["open_time_ms"])] = b
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not by_ts:
|
||||
return []
|
||||
keys = sorted(by_ts.keys())
|
||||
step_ms = max(1, int(period_ms))
|
||||
origin = keys[0]
|
||||
aligned_start = _snap_to_bar_grid(int(start_ms), origin, step_ms)
|
||||
aligned_end = max(int(end_ms), keys[-1])
|
||||
out: list[dict[str, Any]] = []
|
||||
last: dict[str, Any] | None = None
|
||||
for ts_key in keys:
|
||||
if ts_key <= aligned_start:
|
||||
last = by_ts[ts_key]
|
||||
ts = aligned_start
|
||||
while ts <= aligned_end:
|
||||
cur = by_ts.get(ts)
|
||||
if cur is not None:
|
||||
last = cur
|
||||
out.append(cur)
|
||||
elif last is not None:
|
||||
c = float(last["close"])
|
||||
out.append(
|
||||
{
|
||||
"open_time_ms": ts,
|
||||
"open": c,
|
||||
"high": c,
|
||||
"low": c,
|
||||
"close": c,
|
||||
"volume": 0.0,
|
||||
"filled": True,
|
||||
}
|
||||
)
|
||||
ts += step_ms
|
||||
return out
|
||||
|
||||
|
||||
def resolve_archive_chart(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME,
|
||||
*,
|
||||
anchor_ms: int | None = None,
|
||||
opened_ms: int | None = None,
|
||||
closed_ms: int | None = None,
|
||||
mode: str = "hold",
|
||||
bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT,
|
||||
db_path: Path | None = None,
|
||||
@@ -557,20 +616,40 @@ def resolve_archive_chart(
|
||||
return {"ok": False, "msg": "缺少 exchange_key 或 symbol"}
|
||||
|
||||
period = TIMEFRAME_MS[tf]
|
||||
visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500))
|
||||
anchor = int(anchor_ms) if anchor_ms else _now_ms()
|
||||
half = visible // 2
|
||||
start_ms = max(0, anchor - half * period)
|
||||
end_ms = anchor + half * period
|
||||
period_5m = TIMEFRAME_MS["5m"]
|
||||
hold_open = int(opened_ms) if opened_ms else None
|
||||
hold_close = int(closed_ms) if closed_ms else None
|
||||
if hold_open and hold_close and hold_close >= hold_open:
|
||||
hold_len = hold_close - hold_open
|
||||
pad = max(period * 24, hold_len // 3, period_5m * 12)
|
||||
start_ms = max(0, hold_open - pad)
|
||||
end_ms = hold_close + pad
|
||||
visible = int((end_ms - start_ms) / period) + 8
|
||||
visible = max(50, min(visible, 500))
|
||||
anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open
|
||||
else:
|
||||
visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500))
|
||||
anchor = int(anchor_ms) if anchor_ms else _now_ms()
|
||||
half = visible // 2
|
||||
start_ms = max(0, anchor - half * period)
|
||||
end_ms = anchor + half * period
|
||||
|
||||
raw_5m = load_bars_5m_range(ex_k, sym, start_ms - period * 3, end_ms + period * 3, db_path=db_path)
|
||||
raw_5m = load_bars_5m_range(
|
||||
ex_k,
|
||||
sym,
|
||||
start_ms - period_5m * 6,
|
||||
end_ms + period_5m * 6,
|
||||
db_path=db_path,
|
||||
)
|
||||
if not raw_5m:
|
||||
return {"ok": False, "msg": "档案库暂无 K 线,请等待同步或手动刷新"}
|
||||
|
||||
filled_5m = _fill_missing_bars(raw_5m, period_5m, start_ms - period_5m * 2, end_ms + period_5m * 2)
|
||||
|
||||
if tf == "5m":
|
||||
merged = [b for b in raw_5m if start_ms <= int(b["open_time_ms"]) <= end_ms]
|
||||
merged = [b for b in filled_5m if start_ms <= int(b["open_time_ms"]) <= end_ms]
|
||||
else:
|
||||
agg = aggregate_ohlcv_bars(raw_5m, tf)
|
||||
agg = aggregate_ohlcv_bars(filled_5m, tf)
|
||||
merged = [b for b in agg if start_ms <= int(b["open_time_ms"]) <= end_ms]
|
||||
|
||||
candles = _to_candles(merged)
|
||||
@@ -584,10 +663,13 @@ def resolve_archive_chart(
|
||||
"timeframe": tf,
|
||||
"mode": (mode or "hold").strip().lower(),
|
||||
"anchor_ms": anchor,
|
||||
"opened_ms": hold_open,
|
||||
"closed_ms": hold_close,
|
||||
"window_start_ms": start_ms,
|
||||
"window_end_ms": end_ms,
|
||||
"candles": candles,
|
||||
"bar_count": len(candles),
|
||||
"gaps_filled": sum(1 for b in filled_5m if b.get("filled")),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1732,6 +1732,8 @@ def api_archive_ohlcv(
|
||||
timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME,
|
||||
mode: str = "hold",
|
||||
anchor_ms: str = "",
|
||||
opened_ms: str = "",
|
||||
closed_ms: str = "",
|
||||
at: str = "",
|
||||
bars: str = "",
|
||||
):
|
||||
@@ -1741,6 +1743,8 @@ def api_archive_ohlcv(
|
||||
raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol")
|
||||
init_archive_db()
|
||||
anchor = _parse_anchor_ms(at, anchor_ms)
|
||||
open_ms = _parse_anchor_ms("", opened_ms)
|
||||
close_ms = _parse_anchor_ms("", closed_ms)
|
||||
try:
|
||||
bar_n = int(bars) if (bars or "").strip().isdigit() else ARCHIVE_VISIBLE_BARS_DEFAULT
|
||||
except ValueError:
|
||||
@@ -1750,6 +1754,8 @@ def api_archive_ohlcv(
|
||||
sym,
|
||||
timeframe,
|
||||
anchor_ms=anchor,
|
||||
opened_ms=open_ms,
|
||||
closed_ms=close_ms,
|
||||
mode=mode,
|
||||
bars=bar_n,
|
||||
)
|
||||
|
||||
@@ -157,13 +157,127 @@
|
||||
return trades[0];
|
||||
}
|
||||
|
||||
function parseTimeMs(raw) {
|
||||
if (raw == null || raw === "") return null;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
const v = Math.trunc(raw);
|
||||
return v > 1e12 ? v : v * 1000;
|
||||
}
|
||||
const s = String(raw).trim().replace("Z", "").replace("T", " ");
|
||||
if (!s) return null;
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/);
|
||||
if (!m) return null;
|
||||
const dt = new Date(
|
||||
Number(m[1]),
|
||||
Number(m[2]) - 1,
|
||||
Number(m[3]),
|
||||
Number(m[4] || 0),
|
||||
Number(m[5] || 0),
|
||||
Number(m[6] || 0)
|
||||
);
|
||||
const ms = dt.getTime();
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function tradeOpenMs(tr) {
|
||||
if (!tr) return null;
|
||||
return tr.opened_at_ms || parseTimeMs(tr.opened_at);
|
||||
}
|
||||
|
||||
function tradeCloseMs(tr) {
|
||||
if (!tr) return null;
|
||||
return tr.closed_at_ms || parseTimeMs(tr.closed_at);
|
||||
}
|
||||
|
||||
function anchorMsForTrade(tr) {
|
||||
if (!tr) return null;
|
||||
const mode = (elViewMode && elViewMode.value) || "hold";
|
||||
if (mode === "entry") {
|
||||
return tr.opened_at_ms || null;
|
||||
return tradeOpenMs(tr);
|
||||
}
|
||||
return tr.closed_at_ms || tr.opened_at_ms || null;
|
||||
return tradeCloseMs(tr) || tradeOpenMs(tr);
|
||||
}
|
||||
|
||||
function msToBarTime(ms, tf) {
|
||||
const period = TF_MS[tf] || TF_MS["15m"];
|
||||
const aligned = Math.floor(Number(ms) / period) * period;
|
||||
return Math.floor(aligned / 1000);
|
||||
}
|
||||
|
||||
function snapToCandleTime(targetSec, candles) {
|
||||
if (!candles || !candles.length) return targetSec;
|
||||
let best = candles[0].time;
|
||||
let bestDiff = Math.abs(candles[0].time - targetSec);
|
||||
for (let i = 0; i < candles.length; i++) {
|
||||
const d = Math.abs(candles[i].time - targetSec);
|
||||
if (d < bestDiff) {
|
||||
bestDiff = d;
|
||||
best = candles[i].time;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function isLongDirection(dir) {
|
||||
const d = String(dir || "").trim().toLowerCase();
|
||||
return d === "long" || d === "多" || d === "buy";
|
||||
}
|
||||
|
||||
function buildTradeMarkers(tr, candles, tf) {
|
||||
if (!tr || !candles.length) return [];
|
||||
const long = isLongDirection(tr.direction);
|
||||
const openMs = tradeOpenMs(tr);
|
||||
const closeMs = tradeCloseMs(tr);
|
||||
const markers = [];
|
||||
if (openMs) {
|
||||
markers.push({
|
||||
time: snapToCandleTime(msToBarTime(openMs, tf), candles),
|
||||
position: long ? "belowBar" : "aboveBar",
|
||||
color: long ? "#22c55e" : "#ef4444",
|
||||
shape: long ? "arrowUp" : "arrowDown",
|
||||
text: "开",
|
||||
});
|
||||
}
|
||||
if (closeMs) {
|
||||
markers.push({
|
||||
time: snapToCandleTime(msToBarTime(closeMs, tf), candles),
|
||||
position: long ? "aboveBar" : "belowBar",
|
||||
color: "#f59e0b",
|
||||
shape: long ? "arrowDown" : "arrowUp",
|
||||
text: "平",
|
||||
});
|
||||
}
|
||||
return markers.sort(function (a, b) {
|
||||
return a.time > b.time ? 1 : a.time < b.time ? -1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function focusHoldRange(candles, tr, tf) {
|
||||
if (!chart || !candles.length || !tr) return;
|
||||
const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null;
|
||||
const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null;
|
||||
let fromIdx = 0;
|
||||
let toIdx = candles.length - 1;
|
||||
if (openSec != null) {
|
||||
for (let i = 0; i < candles.length; i++) {
|
||||
if (candles[i].time >= openSec) {
|
||||
fromIdx = Math.max(0, i - 12);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (closeSec != null) {
|
||||
for (let i = candles.length - 1; i >= 0; i--) {
|
||||
if (candles[i].time <= closeSec) {
|
||||
toIdx = Math.min(candles.length - 1, i + 12);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toIdx <= fromIdx) {
|
||||
toIdx = Math.min(candles.length - 1, fromIdx + 80);
|
||||
}
|
||||
chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 });
|
||||
}
|
||||
|
||||
function destroyChart() {
|
||||
@@ -230,6 +344,10 @@
|
||||
});
|
||||
if (jump) params.set("at", jump);
|
||||
else if (anchor) params.set("anchor_ms", String(anchor));
|
||||
const openMs = tradeOpenMs(tr);
|
||||
const closeMs = tradeCloseMs(tr);
|
||||
if (openMs) params.set("opened_ms", String(openMs));
|
||||
if (closeMs) params.set("closed_ms", String(closeMs));
|
||||
setStatus("加载 K 线…");
|
||||
const r = await apiFetch("/api/archive/ohlcv?" + params.toString());
|
||||
const j = await r.json();
|
||||
@@ -253,10 +371,15 @@
|
||||
};
|
||||
})
|
||||
);
|
||||
if (candles.length > 10) {
|
||||
if (candleSeries.setMarkers) {
|
||||
candleSeries.setMarkers(buildTradeMarkers(tr, candles, timeframe));
|
||||
}
|
||||
if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) {
|
||||
focusHoldRange(candles, tr, timeframe);
|
||||
} else if (candles.length > 10) {
|
||||
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 });
|
||||
}
|
||||
setStatus("K 线 " + candles.length + " 根 · " + timeframe);
|
||||
setStatus("K 线 " + candles.length + " 根 · " + timeframe + (tr ? " · 已标注开/平" : ""));
|
||||
}
|
||||
|
||||
function renderTrades() {
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
<div id="toast"></div>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="/assets/chart.js?v=20260604-upnl-contracts"></script>
|
||||
<script src="/assets/archive.js?v=20260607-hub-archive-v2"></script>
|
||||
<script src="/assets/archive.js?v=20260607-hub-archive-v3"></script>
|
||||
<script src="/assets/ai_review_render.js?v=2"></script>
|
||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||
</body>
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
|
||||
from hub_ohlcv_lib import aggregate_ohlcv_bars
|
||||
from hub_symbol_archive_lib import (
|
||||
_fill_missing_bars,
|
||||
init_db,
|
||||
resolve_archive_chart,
|
||||
upsert_bars_5m,
|
||||
@@ -16,7 +17,15 @@ from hub_symbol_archive_lib import (
|
||||
)
|
||||
|
||||
|
||||
def _seed_5m_bars(db: Path, start_ms: int, count: int, step: int = 300_000) -> None:
|
||||
def _seed_5m_bars(
|
||||
db: Path,
|
||||
start_ms: int,
|
||||
count: int,
|
||||
step: int = 300_000,
|
||||
*,
|
||||
ex: str = "gate",
|
||||
sym: str = "ONDO",
|
||||
) -> None:
|
||||
bars = []
|
||||
price = 1.0
|
||||
for i in range(count):
|
||||
@@ -32,7 +41,7 @@ def _seed_5m_bars(db: Path, start_ms: int, count: int, step: int = 300_000) -> N
|
||||
"volume": 100 + i,
|
||||
}
|
||||
)
|
||||
upsert_bars_5m("gate", "ONDO", bars, db_path=db)
|
||||
upsert_bars_5m(ex, sym, bars, db_path=db)
|
||||
|
||||
|
||||
def test_aggregate_15m_from_5m():
|
||||
@@ -76,6 +85,53 @@ def test_resolve_archive_chart_15m():
|
||||
assert len(out["candles"]) >= 10
|
||||
|
||||
|
||||
def test_fill_missing_bars_continuity():
|
||||
period = 300_000
|
||||
start = (1_700_000_000_000 // period) * period
|
||||
bars = [
|
||||
{
|
||||
"open_time_ms": start,
|
||||
"open": 1.0,
|
||||
"high": 1.1,
|
||||
"low": 0.9,
|
||||
"close": 1.05,
|
||||
"volume": 10,
|
||||
},
|
||||
{
|
||||
"open_time_ms": start + period * 2,
|
||||
"open": 1.05,
|
||||
"high": 1.15,
|
||||
"low": 1.0,
|
||||
"close": 1.1,
|
||||
"volume": 8,
|
||||
},
|
||||
]
|
||||
filled = _fill_missing_bars(bars, period, start, start + period * 2)
|
||||
assert len(filled) >= 3
|
||||
assert any(b.get("filled") for b in filled)
|
||||
|
||||
|
||||
def test_resolve_archive_chart_hold_window():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
open_ms = 1_700_000_000_000
|
||||
close_ms = open_ms + 6 * 3600_000
|
||||
_seed_5m_bars(db, open_ms - 20 * 300_000, 200, ex="gate", sym="BNB/USDT")
|
||||
out = resolve_archive_chart(
|
||||
"gate",
|
||||
"BNB/USDT",
|
||||
"15m",
|
||||
opened_ms=open_ms,
|
||||
closed_ms=close_ms,
|
||||
mode="hold",
|
||||
db_path=db,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out.get("opened_ms") == open_ms
|
||||
assert len(out["candles"]) >= 10
|
||||
|
||||
|
||||
def test_list_with_overlay_filters():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
|
||||
Reference in New Issue
Block a user