fix(hub): load full pre-entry history for archive chart pan/zoom
range=history serves archive seed through close (not now). Default view focuses hold period; user can scroll/zoom left to see global morphology before entry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
| 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m |
|
||||
| 增量同步 | 默认每 **4 小时** 补新 5m 至当前 |
|
||||
| 展示周期 | Tab:**5m / 15m / 1h / 4h**,默认 **15m** |
|
||||
| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) |
|
||||
| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓);历史段为建档→平仓,可拖动/滚轮缩放看建仓前全局,**不拉到「现在」** |
|
||||
| 时间跳转 | 上方输入 `YYYY-MM-DD HH:MM` 后点「跳转」 |
|
||||
| 图片 | **不上传** |
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"})
|
||||
ARCHIVE_DEFAULT_TIMEFRAME = "15m"
|
||||
ARCHIVE_SEED_LOOKBACK_DAYS = 30
|
||||
ARCHIVE_VISIBLE_BARS_DEFAULT = 200
|
||||
ARCHIVE_MAX_CANDLES: dict[str, int] = {
|
||||
"5m": 4000,
|
||||
"15m": 2500,
|
||||
"1h": 1200,
|
||||
"4h": 600,
|
||||
}
|
||||
ARCHIVE_SYNC_INTERVAL_SEC = int(os.getenv("HUB_ARCHIVE_SYNC_INTERVAL_SEC", str(4 * 3600)))
|
||||
ARCHIVE_TRADE_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365"))
|
||||
ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000"))
|
||||
@@ -594,6 +600,47 @@ def _fill_missing_bars(
|
||||
return out
|
||||
|
||||
|
||||
def _archive_earliest_bar_ms(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> int | None:
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT MIN(open_time_ms) AS mn FROM archive_bars_5m WHERE exchange_key=? AND symbol=?",
|
||||
(ex_k, sym),
|
||||
).fetchone()
|
||||
if row and row["mn"] is not None:
|
||||
return int(row["mn"])
|
||||
finally:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
|
||||
def _trim_bars_for_cap(
|
||||
bars: list[dict[str, Any]],
|
||||
*,
|
||||
end_ms: int,
|
||||
max_n: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""超长时优先保留到平仓,再从最古老端截断。"""
|
||||
if len(bars) <= max_n:
|
||||
return bars
|
||||
cut_end = len(bars)
|
||||
for i in range(len(bars) - 1, -1, -1):
|
||||
if int(bars[i]["open_time_ms"]) <= int(end_ms):
|
||||
cut_end = i + 1
|
||||
break
|
||||
essential = bars[:cut_end]
|
||||
if len(essential) <= max_n:
|
||||
return essential
|
||||
return essential[len(essential) - max_n :]
|
||||
|
||||
|
||||
def resolve_archive_chart(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
@@ -604,9 +651,13 @@ def resolve_archive_chart(
|
||||
closed_ms: int | None = None,
|
||||
mode: str = "hold",
|
||||
bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT,
|
||||
range_mode: str = "window",
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""从永久 5m 库聚合出档案 K 线视窗。"""
|
||||
"""从永久 5m 库聚合出档案 K 线视窗。
|
||||
|
||||
range_mode=history:建档起点 → 平仓(不含「到现在」),供拖动/缩放查看建仓前全局形态。
|
||||
"""
|
||||
tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME)
|
||||
if tf not in ARCHIVE_TIMEFRAMES:
|
||||
return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"}
|
||||
@@ -619,13 +670,21 @@ def resolve_archive_chart(
|
||||
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:
|
||||
rm = (range_mode or "window").strip().lower()
|
||||
if hold_open and hold_close and hold_close >= hold_open and rm == "history":
|
||||
seed_back = max(0, hold_open - ARCHIVE_SEED_LOOKBACK_DAYS * 86400000)
|
||||
earliest = _archive_earliest_bar_ms(ex_k, sym, db_path=db_path)
|
||||
if earliest is not None:
|
||||
start_ms = min(earliest, seed_back)
|
||||
else:
|
||||
start_ms = seed_back
|
||||
end_ms = hold_close + max(period * 16, period_5m * 8)
|
||||
anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open
|
||||
elif 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))
|
||||
@@ -652,6 +711,10 @@ def resolve_archive_chart(
|
||||
agg = aggregate_ohlcv_bars(filled_5m, tf)
|
||||
merged = [b for b in agg if start_ms <= int(b["open_time_ms"]) <= end_ms]
|
||||
|
||||
max_n = ARCHIVE_MAX_CANDLES.get(tf, 2000)
|
||||
if rm == "history" and merged:
|
||||
merged = _trim_bars_for_cap(merged, end_ms=end_ms, max_n=max_n)
|
||||
|
||||
candles = _to_candles(merged)
|
||||
if not candles:
|
||||
return {"ok": False, "msg": "视窗内无 K 线"}
|
||||
@@ -662,6 +725,7 @@ def resolve_archive_chart(
|
||||
"symbol": sym,
|
||||
"timeframe": tf,
|
||||
"mode": (mode or "hold").strip().lower(),
|
||||
"range_mode": rm,
|
||||
"anchor_ms": anchor,
|
||||
"opened_ms": hold_open,
|
||||
"closed_ms": hold_close,
|
||||
|
||||
@@ -1734,6 +1734,7 @@ def api_archive_ohlcv(
|
||||
anchor_ms: str = "",
|
||||
opened_ms: str = "",
|
||||
closed_ms: str = "",
|
||||
range: str = "",
|
||||
at: str = "",
|
||||
bars: str = "",
|
||||
):
|
||||
@@ -1758,6 +1759,7 @@ def api_archive_ohlcv(
|
||||
closed_ms=close_ms,
|
||||
mode=mode,
|
||||
bars=bar_n,
|
||||
range_mode=(range or "").strip().lower() or "window",
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=404, detail=result.get("msg") or "无 K 线")
|
||||
|
||||
@@ -252,16 +252,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function focusHoldRange(candles, tr, tf) {
|
||||
/** 初始只聚焦持仓段;完整历史已加载,可向左拖动/滚轮缩小查看建仓前全局。 */
|
||||
function focusInitialTradeView(candles, tr, tf) {
|
||||
if (!chart || !candles.length || !tr) return;
|
||||
const mode = (elViewMode && elViewMode.value) || "hold";
|
||||
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;
|
||||
let openIdx = 0;
|
||||
let closeIdx = 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);
|
||||
openIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -269,11 +271,21 @@
|
||||
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);
|
||||
closeIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const span = Math.max(24, closeIdx - openIdx + 20);
|
||||
let fromIdx;
|
||||
let toIdx;
|
||||
if (mode === "entry") {
|
||||
fromIdx = Math.max(0, openIdx - Math.floor(span * 0.35));
|
||||
toIdx = Math.min(candles.length - 1, openIdx + Math.floor(span * 0.65));
|
||||
} else {
|
||||
fromIdx = Math.max(0, openIdx - 10);
|
||||
toIdx = Math.min(candles.length - 1, closeIdx + 14);
|
||||
}
|
||||
if (toIdx <= fromIdx) {
|
||||
toIdx = Math.min(candles.length - 1, fromIdx + 80);
|
||||
}
|
||||
@@ -303,9 +315,24 @@
|
||||
vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
||||
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
||||
},
|
||||
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2" },
|
||||
timeScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", timeVisible: true },
|
||||
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true },
|
||||
timeScale: {
|
||||
borderColor: isDark ? "#2a3348" : "#d0d7e2",
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
},
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||
handleScroll: {
|
||||
mouseWheel: true,
|
||||
pressedMouseMove: true,
|
||||
horzTouchDrag: true,
|
||||
vertTouchDrag: false,
|
||||
},
|
||||
handleScale: {
|
||||
axisPressedMouseMove: true,
|
||||
mouseWheel: true,
|
||||
pinch: true,
|
||||
},
|
||||
});
|
||||
candleSeries = chart.addCandlestickSeries({
|
||||
upColor: "#22c55e",
|
||||
@@ -335,19 +362,23 @@
|
||||
const tr = pickAnchorTrade();
|
||||
const anchor = anchorMsForTrade(tr);
|
||||
const jump = (elJumpAt && elJumpAt.value || "").trim();
|
||||
const openMs = tradeOpenMs(tr);
|
||||
const closeMs = tradeCloseMs(tr);
|
||||
const params = new URLSearchParams({
|
||||
exchange_key: selected.exchange_key,
|
||||
symbol: selected.symbol,
|
||||
timeframe: timeframe,
|
||||
mode: (elViewMode && elViewMode.value) || "hold",
|
||||
bars: "200",
|
||||
});
|
||||
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));
|
||||
if (openMs && closeMs) {
|
||||
params.set("range", "history");
|
||||
params.set("opened_ms", String(openMs));
|
||||
params.set("closed_ms", String(closeMs));
|
||||
} else {
|
||||
params.set("bars", "200");
|
||||
if (jump) params.set("at", jump);
|
||||
else if (anchor) params.set("anchor_ms", String(anchor));
|
||||
}
|
||||
setStatus("加载 K 线…");
|
||||
const r = await apiFetch("/api/archive/ohlcv?" + params.toString());
|
||||
const j = await r.json();
|
||||
@@ -375,11 +406,12 @@
|
||||
candleSeries.setMarkers(buildTradeMarkers(tr, candles, timeframe));
|
||||
}
|
||||
if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) {
|
||||
focusHoldRange(candles, tr, timeframe);
|
||||
focusInitialTradeView(candles, tr, timeframe);
|
||||
} else if (candles.length > 10) {
|
||||
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 });
|
||||
}
|
||||
setStatus("K 线 " + candles.length + " 根 · " + timeframe + (tr ? " · 已标注开/平" : ""));
|
||||
const histHint = openMs && closeMs ? " · 可拖动/滚轮缩放查看建仓前走势" : "";
|
||||
setStatus("K 线 " + candles.length + " 根 · " + timeframe + (tr ? " · 已标注开/平" : "") + histHint);
|
||||
}
|
||||
|
||||
function renderTrades() {
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
<div id="page-archive" class="page hidden">
|
||||
<div class="page-head">
|
||||
<h1><span class="head-tag">ARC</span> 币种档案</h1>
|
||||
<p class="page-desc">一所一币一行 · 交易时间线 · 永久 5m K 线(15m/1h/4h 聚合)</p>
|
||||
<p class="page-desc">一所一币一行 · 交易时间线 · 建档 30 天 K 线可拖动缩放(默认聚焦持仓段,不含拉到「现在」)</p>
|
||||
</div>
|
||||
<div class="archive-toolbar toolbar">
|
||||
<label class="archive-field">
|
||||
@@ -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-v3"></script>
|
||||
<script src="/assets/archive.js?v=20260607-hub-archive-v4"></script>
|
||||
<script src="/assets/ai_review_render.js?v=2"></script>
|
||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||
</body>
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_fill_missing_bars_continuity():
|
||||
assert any(b.get("filled") for b in filled)
|
||||
|
||||
|
||||
def test_resolve_archive_chart_hold_window():
|
||||
def test_resolve_archive_chart_history_range():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
@@ -125,11 +125,13 @@ def test_resolve_archive_chart_hold_window():
|
||||
opened_ms=open_ms,
|
||||
closed_ms=close_ms,
|
||||
mode="hold",
|
||||
range_mode="history",
|
||||
db_path=db,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out.get("opened_ms") == open_ms
|
||||
assert len(out["candles"]) >= 10
|
||||
assert out.get("range_mode") == "history"
|
||||
assert out.get("window_end_ms") <= close_ms + 4 * 3600_000
|
||||
assert len(out["candles"]) >= 40
|
||||
|
||||
|
||||
def test_list_with_overlay_filters():
|
||||
|
||||
Reference in New Issue
Block a user