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 |
|
| 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m |
|
||||||
| 增量同步 | 默认每 **4 小时** 补新 5m 至当前 |
|
| 增量同步 | 默认每 **4 小时** 补新 5m 至当前 |
|
||||||
| 展示周期 | Tab:**5m / 15m / 1h / 4h**,默认 **15m** |
|
| 展示周期 | Tab:**5m / 15m / 1h / 4h**,默认 **15m** |
|
||||||
| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) |
|
| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓);历史段为建档→平仓,可拖动/滚轮缩放看建仓前全局,**不拉到「现在」** |
|
||||||
| 时间跳转 | 上方输入 `YYYY-MM-DD HH:MM` 后点「跳转」 |
|
| 时间跳转 | 上方输入 `YYYY-MM-DD HH:MM` 后点「跳转」 |
|
||||||
| 图片 | **不上传** |
|
| 图片 | **不上传** |
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"})
|
|||||||
ARCHIVE_DEFAULT_TIMEFRAME = "15m"
|
ARCHIVE_DEFAULT_TIMEFRAME = "15m"
|
||||||
ARCHIVE_SEED_LOOKBACK_DAYS = 30
|
ARCHIVE_SEED_LOOKBACK_DAYS = 30
|
||||||
ARCHIVE_VISIBLE_BARS_DEFAULT = 200
|
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_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_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365"))
|
||||||
ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000"))
|
ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000"))
|
||||||
@@ -594,6 +600,47 @@ def _fill_missing_bars(
|
|||||||
return out
|
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(
|
def resolve_archive_chart(
|
||||||
exchange_key: str,
|
exchange_key: str,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -604,9 +651,13 @@ def resolve_archive_chart(
|
|||||||
closed_ms: int | None = None,
|
closed_ms: int | None = None,
|
||||||
mode: str = "hold",
|
mode: str = "hold",
|
||||||
bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT,
|
bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT,
|
||||||
|
range_mode: str = "window",
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""从永久 5m 库聚合出档案 K 线视窗。"""
|
"""从永久 5m 库聚合出档案 K 线视窗。
|
||||||
|
|
||||||
|
range_mode=history:建档起点 → 平仓(不含「到现在」),供拖动/缩放查看建仓前全局形态。
|
||||||
|
"""
|
||||||
tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME)
|
tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME)
|
||||||
if tf not in ARCHIVE_TIMEFRAMES:
|
if tf not in ARCHIVE_TIMEFRAMES:
|
||||||
return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"}
|
return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"}
|
||||||
@@ -619,13 +670,21 @@ def resolve_archive_chart(
|
|||||||
period_5m = TIMEFRAME_MS["5m"]
|
period_5m = TIMEFRAME_MS["5m"]
|
||||||
hold_open = int(opened_ms) if opened_ms else None
|
hold_open = int(opened_ms) if opened_ms else None
|
||||||
hold_close = int(closed_ms) if closed_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
|
hold_len = hold_close - hold_open
|
||||||
pad = max(period * 24, hold_len // 3, period_5m * 12)
|
pad = max(period * 24, hold_len // 3, period_5m * 12)
|
||||||
start_ms = max(0, hold_open - pad)
|
start_ms = max(0, hold_open - pad)
|
||||||
end_ms = hold_close + 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
|
anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open
|
||||||
else:
|
else:
|
||||||
visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500))
|
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)
|
agg = aggregate_ohlcv_bars(filled_5m, tf)
|
||||||
merged = [b for b in agg if start_ms <= int(b["open_time_ms"]) <= end_ms]
|
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)
|
candles = _to_candles(merged)
|
||||||
if not candles:
|
if not candles:
|
||||||
return {"ok": False, "msg": "视窗内无 K 线"}
|
return {"ok": False, "msg": "视窗内无 K 线"}
|
||||||
@@ -662,6 +725,7 @@ def resolve_archive_chart(
|
|||||||
"symbol": sym,
|
"symbol": sym,
|
||||||
"timeframe": tf,
|
"timeframe": tf,
|
||||||
"mode": (mode or "hold").strip().lower(),
|
"mode": (mode or "hold").strip().lower(),
|
||||||
|
"range_mode": rm,
|
||||||
"anchor_ms": anchor,
|
"anchor_ms": anchor,
|
||||||
"opened_ms": hold_open,
|
"opened_ms": hold_open,
|
||||||
"closed_ms": hold_close,
|
"closed_ms": hold_close,
|
||||||
|
|||||||
@@ -1734,6 +1734,7 @@ def api_archive_ohlcv(
|
|||||||
anchor_ms: str = "",
|
anchor_ms: str = "",
|
||||||
opened_ms: str = "",
|
opened_ms: str = "",
|
||||||
closed_ms: str = "",
|
closed_ms: str = "",
|
||||||
|
range: str = "",
|
||||||
at: str = "",
|
at: str = "",
|
||||||
bars: str = "",
|
bars: str = "",
|
||||||
):
|
):
|
||||||
@@ -1758,6 +1759,7 @@ def api_archive_ohlcv(
|
|||||||
closed_ms=close_ms,
|
closed_ms=close_ms,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
bars=bar_n,
|
bars=bar_n,
|
||||||
|
range_mode=(range or "").strip().lower() or "window",
|
||||||
)
|
)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
raise HTTPException(status_code=404, detail=result.get("msg") or "无 K 线")
|
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;
|
if (!chart || !candles.length || !tr) return;
|
||||||
|
const mode = (elViewMode && elViewMode.value) || "hold";
|
||||||
const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null;
|
const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null;
|
||||||
const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null;
|
const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null;
|
||||||
let fromIdx = 0;
|
let openIdx = 0;
|
||||||
let toIdx = candles.length - 1;
|
let closeIdx = candles.length - 1;
|
||||||
if (openSec != null) {
|
if (openSec != null) {
|
||||||
for (let i = 0; i < candles.length; i++) {
|
for (let i = 0; i < candles.length; i++) {
|
||||||
if (candles[i].time >= openSec) {
|
if (candles[i].time >= openSec) {
|
||||||
fromIdx = Math.max(0, i - 12);
|
openIdx = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,11 +271,21 @@
|
|||||||
if (closeSec != null) {
|
if (closeSec != null) {
|
||||||
for (let i = candles.length - 1; i >= 0; i--) {
|
for (let i = candles.length - 1; i >= 0; i--) {
|
||||||
if (candles[i].time <= closeSec) {
|
if (candles[i].time <= closeSec) {
|
||||||
toIdx = Math.min(candles.length - 1, i + 12);
|
closeIdx = i;
|
||||||
break;
|
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) {
|
if (toIdx <= fromIdx) {
|
||||||
toIdx = Math.min(candles.length - 1, fromIdx + 80);
|
toIdx = Math.min(candles.length - 1, fromIdx + 80);
|
||||||
}
|
}
|
||||||
@@ -303,9 +315,24 @@
|
|||||||
vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
||||||
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
||||||
},
|
},
|
||||||
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2" },
|
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true },
|
||||||
timeScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", timeVisible: true },
|
timeScale: {
|
||||||
|
borderColor: isDark ? "#2a3348" : "#d0d7e2",
|
||||||
|
timeVisible: true,
|
||||||
|
secondsVisible: false,
|
||||||
|
},
|
||||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||||
|
handleScroll: {
|
||||||
|
mouseWheel: true,
|
||||||
|
pressedMouseMove: true,
|
||||||
|
horzTouchDrag: true,
|
||||||
|
vertTouchDrag: false,
|
||||||
|
},
|
||||||
|
handleScale: {
|
||||||
|
axisPressedMouseMove: true,
|
||||||
|
mouseWheel: true,
|
||||||
|
pinch: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
candleSeries = chart.addCandlestickSeries({
|
candleSeries = chart.addCandlestickSeries({
|
||||||
upColor: "#22c55e",
|
upColor: "#22c55e",
|
||||||
@@ -335,19 +362,23 @@
|
|||||||
const tr = pickAnchorTrade();
|
const tr = pickAnchorTrade();
|
||||||
const anchor = anchorMsForTrade(tr);
|
const anchor = anchorMsForTrade(tr);
|
||||||
const jump = (elJumpAt && elJumpAt.value || "").trim();
|
const jump = (elJumpAt && elJumpAt.value || "").trim();
|
||||||
|
const openMs = tradeOpenMs(tr);
|
||||||
|
const closeMs = tradeCloseMs(tr);
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
exchange_key: selected.exchange_key,
|
exchange_key: selected.exchange_key,
|
||||||
symbol: selected.symbol,
|
symbol: selected.symbol,
|
||||||
timeframe: timeframe,
|
timeframe: timeframe,
|
||||||
mode: (elViewMode && elViewMode.value) || "hold",
|
mode: (elViewMode && elViewMode.value) || "hold",
|
||||||
bars: "200",
|
|
||||||
});
|
});
|
||||||
|
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);
|
if (jump) params.set("at", jump);
|
||||||
else if (anchor) params.set("anchor_ms", String(anchor));
|
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 线…");
|
setStatus("加载 K 线…");
|
||||||
const r = await apiFetch("/api/archive/ohlcv?" + params.toString());
|
const r = await apiFetch("/api/archive/ohlcv?" + params.toString());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
@@ -375,11 +406,12 @@
|
|||||||
candleSeries.setMarkers(buildTradeMarkers(tr, candles, timeframe));
|
candleSeries.setMarkers(buildTradeMarkers(tr, candles, timeframe));
|
||||||
}
|
}
|
||||||
if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) {
|
if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) {
|
||||||
focusHoldRange(candles, tr, timeframe);
|
focusInitialTradeView(candles, tr, timeframe);
|
||||||
} else if (candles.length > 10) {
|
} else if (candles.length > 10) {
|
||||||
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 });
|
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() {
|
function renderTrades() {
|
||||||
|
|||||||
@@ -189,7 +189,7 @@
|
|||||||
<div id="page-archive" class="page hidden">
|
<div id="page-archive" class="page hidden">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><span class="head-tag">ARC</span> 币种档案</h1>
|
<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>
|
||||||
<div class="archive-toolbar toolbar">
|
<div class="archive-toolbar toolbar">
|
||||||
<label class="archive-field">
|
<label class="archive-field">
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<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/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/ai_review_render.js?v=2"></script>
|
||||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ def test_fill_missing_bars_continuity():
|
|||||||
assert any(b.get("filled") for b in filled)
|
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:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
db = Path(td) / "archive.db"
|
db = Path(td) / "archive.db"
|
||||||
init_db(db)
|
init_db(db)
|
||||||
@@ -125,11 +125,13 @@ def test_resolve_archive_chart_hold_window():
|
|||||||
opened_ms=open_ms,
|
opened_ms=open_ms,
|
||||||
closed_ms=close_ms,
|
closed_ms=close_ms,
|
||||||
mode="hold",
|
mode="hold",
|
||||||
|
range_mode="history",
|
||||||
db_path=db,
|
db_path=db,
|
||||||
)
|
)
|
||||||
assert out["ok"] is True
|
assert out["ok"] is True
|
||||||
assert out.get("opened_ms") == open_ms
|
assert out.get("range_mode") == "history"
|
||||||
assert len(out["candles"]) >= 10
|
assert out.get("window_end_ms") <= close_ms + 4 * 3600_000
|
||||||
|
assert len(out["candles"]) >= 40
|
||||||
|
|
||||||
|
|
||||||
def test_list_with_overlay_filters():
|
def test_list_with_overlay_filters():
|
||||||
|
|||||||
Reference in New Issue
Block a user