将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务; 前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。 Co-authored-by: Cursor <cursoragent@cursor.com>
868 lines
33 KiB
TypeScript
868 lines
33 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
|
||
import { api } from '../api/client'
|
||
import type { BacktestFill } from '../api/types'
|
||
import { BacktestPanel, type ChartActions } from './BacktestPanel'
|
||
import { buildChanLookup, commitChanLookup, makeChanIndicator, saveChanStyles } from './chanIndicator'
|
||
import { advanceStream, enterReplay, exitStream, isStreamActive, streamState } from './streamState'
|
||
import { makeDatafeed } from './Datafeed'
|
||
import { freqToResolution, resolutionToFreq, type Freq } from './resolution'
|
||
|
||
const TV_SCRIPT = '/charting_library/charting_library.js'
|
||
const CHAN_AFTER_DATA_LOADED_MS = 10
|
||
// 必须跟 chanIndicator.metainfo.description 完全一致
|
||
const CHAN_STUDY_NAME = 'Chan 缠论'
|
||
|
||
let scriptPromise: Promise<void> | null = null
|
||
|
||
function loadTradingView(): Promise<void> {
|
||
if (typeof window !== 'undefined' && window.TradingView) return Promise.resolve()
|
||
if (scriptPromise) return scriptPromise
|
||
scriptPromise = new Promise((resolve, reject) => {
|
||
const s = document.createElement('script')
|
||
s.src = TV_SCRIPT
|
||
s.async = true
|
||
s.onload = () => resolve()
|
||
s.onerror = () => reject(new Error(`无法加载 ${TV_SCRIPT}`))
|
||
document.head.appendChild(s)
|
||
})
|
||
return scriptPromise
|
||
}
|
||
|
||
export interface ChartProps {
|
||
exchange: string
|
||
symbol: string
|
||
freq: Freq
|
||
/** TV 内部切了周期后回调(用户点左下角时间段按钮时),让上层同步 ControlPanel */
|
||
onFreqChange?: (freq: Freq) => void
|
||
}
|
||
|
||
export function TradingViewChart(props: ChartProps) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||
const widgetRef = useRef<any>(null)
|
||
const subRef = useRef<{ unsubscribe?: () => void } | null>(null)
|
||
const rangeSubRef = useRef<{ unsubscribe?: () => void } | null>(null)
|
||
const intervalSubRef = useRef<{ unsubscribe?: () => void } | null>(null)
|
||
const debounceTimer = useRef<number | null>(null)
|
||
const rangeDebounceTimer = useRef<number | null>(null)
|
||
const propsRef = useRef(props)
|
||
propsRef.current = props
|
||
const reloadGen = useRef(0)
|
||
// 上次成功提交的 (symbol, freq, range)。下次 refresh 若三者完全一致就跳过。
|
||
const lastCommitted = useRef<{ key: string; from: number; to: number } | null>(null)
|
||
// 串行化:同时只允许一个 refresh 跑。inFlight 期间到来的 trigger 全部合并到 pending,
|
||
// 当前 refresh 结束后若 pending=true 再跑一轮。否则 setInputValues 引发的 onDataLoaded
|
||
// 会让多个 refresh 互相 stale-abort,永远没人 commit。
|
||
const refreshInFlight = useRef(false)
|
||
const refreshPending = useRef(false)
|
||
|
||
// ---------- 回放 UI state ---------- //
|
||
const [replayDate, setReplayDate] = useState('')
|
||
const [replayUiActive, setReplayUiActive] = useState(false)
|
||
const [isAutoPlaying, setIsAutoPlaying] = useState(false)
|
||
const [replayRemaining, setReplayRemaining] = useState(0)
|
||
const [replayPanelOpen, setReplayPanelOpen] = useState(false)
|
||
const [replayHover, setReplayHover] = useState(false)
|
||
const [themeHover, setThemeHover] = useState(false)
|
||
const autoPlayTimer = useRef<number | null>(null)
|
||
// TV 顶部工具栏插槽(widget.createButton 创建)。React Portal 把「回放」开关按钮渲染
|
||
// 进去,点击展开/收起底部控制面板。这样 UI 出现在 TV 自带的顶栏里,不占用图表区。
|
||
const [headerEl, setHeaderEl] = useState<HTMLElement | null>(null)
|
||
const [themeBtnEl, setThemeBtnEl] = useState<HTMLElement | null>(null)
|
||
const [backtestBtnEl, setBacktestBtnEl] = useState<HTMLElement | null>(null)
|
||
const [backtestPanelOpen, setBacktestPanelOpen] = useState(false)
|
||
const [backtestHover, setBacktestHover] = useState(false)
|
||
// 回测成交点 shape ids,用于追加和清理箭头
|
||
const backtestFillShapeIds = useRef<any[]>([])
|
||
// 主题:从 localStorage 读,默认 light;切换时通过 widget.changeTheme 实时生效
|
||
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
||
try { return (localStorage.getItem('chart-theme') as 'light' | 'dark') ?? 'light' }
|
||
catch { return 'light' }
|
||
})
|
||
const themeRef = useRef(theme)
|
||
themeRef.current = theme
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
;(async () => {
|
||
await loadTradingView()
|
||
if (cancelled || !containerRef.current) return
|
||
|
||
const widget = new window.TradingView.widget({
|
||
container: containerRef.current,
|
||
library_path: '/charting_library/',
|
||
datafeed: makeDatafeed(),
|
||
symbol: `${propsRef.current.exchange}:${propsRef.current.symbol}`,
|
||
interval: freqToResolution(propsRef.current.freq),
|
||
fullscreen: false,
|
||
autosize: true,
|
||
theme: themeRef.current,
|
||
timezone: 'Asia/Shanghai',
|
||
locale: 'zh',
|
||
client_id: 'chan-core',
|
||
user_id: 'local',
|
||
custom_indicators_getter: () => Promise.resolve([makeChanIndicator()]),
|
||
// 用户的指标/周期收藏走 items_favoriting 单独的 localStorage 键,能存;
|
||
// 但整张图表的布局(pane 高度等)一定要 disable, 否则会反复覆盖我们的 setHeight。
|
||
disabled_features: [
|
||
'header_compare',
|
||
'header_saveload',
|
||
'study_templates',
|
||
'create_volume_indicator_by_default',
|
||
'save_chart_properties_to_local_storage',
|
||
],
|
||
enabled_features: [
|
||
'hide_left_toolbar_by_default',
|
||
'items_favoriting', // 启用周期/指标收藏功能
|
||
],
|
||
favorites: {
|
||
intervals: ['1', '5', '15', '30', '60', '240', 'D'],
|
||
chartTypes: ['Candles'],
|
||
},
|
||
// MACD 默认只画图,不显示价格刻度上的值标签 + 状态行(顶部)的值
|
||
// display 是 bitmask:1=Pane 2=DataWindow 4=PriceScale 8=StatusLine → 3 = 只 1+2
|
||
studies_overrides: {
|
||
'macd.macd.display': 3,
|
||
'macd.signal.display': 3,
|
||
'macd.histogram.display': 3,
|
||
},
|
||
overrides: {
|
||
'paneProperties.background': themeRef.current === 'light' ? '#ffffff' : '#131722',
|
||
'paneProperties.backgroundType': 'solid',
|
||
// 中国习惯:涨红跌绿;绿色用 TV 默认 #089981
|
||
'mainSeriesProperties.candleStyle.upColor': '#ef4444',
|
||
'mainSeriesProperties.candleStyle.downColor': '#089981',
|
||
'mainSeriesProperties.candleStyle.borderUpColor': '#ef4444',
|
||
'mainSeriesProperties.candleStyle.borderDownColor': '#089981',
|
||
'mainSeriesProperties.candleStyle.wickUpColor': '#ef4444',
|
||
'mainSeriesProperties.candleStyle.wickDownColor': '#089981',
|
||
|
||
},
|
||
})
|
||
widgetRef.current = widget
|
||
// 顶部工具栏插槽:headerReady 后 createButton 拿到原生 HTMLElement,用 React
|
||
// Portal 把回放/主题按钮渲染进去。align 只支持 left/right,没有 middle —
|
||
// 选 'left' 让插槽出现在左侧元素组的末尾(周期/指标按钮之后),视觉上更靠中间。
|
||
widget.headerReady?.().then(() => {
|
||
const makeSlot = (align: 'left' | 'right'): HTMLElement | null => {
|
||
try {
|
||
const el = widget.createButton({ align }) as HTMLElement
|
||
el.style.padding = '0'
|
||
el.style.border = 'none'
|
||
el.style.background = 'transparent'
|
||
return el
|
||
} catch (e) {
|
||
console.warn('createButton failed', e)
|
||
return null
|
||
}
|
||
}
|
||
// 回放/回测放左侧(周期/指标按钮之后),主题按钮放右侧(设置/搜索那侧)
|
||
const replaySlot = makeSlot('left')
|
||
const backtestSlot = makeSlot('left')
|
||
const themeSlot = makeSlot('right')
|
||
if (replaySlot) setHeaderEl(replaySlot)
|
||
if (backtestSlot) setBacktestBtnEl(backtestSlot)
|
||
if (themeSlot) setThemeBtnEl(themeSlot)
|
||
})
|
||
widget.onChartReady(async () => {
|
||
const chart = widget.activeChart?.()
|
||
if (!chart) return
|
||
try {
|
||
await chart.createStudy('MACD', false, false)
|
||
} catch (e) {
|
||
console.warn('createStudy MACD failed', e)
|
||
}
|
||
// MACD 副图大小:setHeight 单位是"伸展因子"(按 pane 比例分配总高度)。
|
||
// 默认 pane[0]≈534、pane[1]≈267 是 2:1。设 100 → 大约 5:1,MACD 占 ~17%。
|
||
// 调大调小就改这个数:30=很小,50=小,100=中,150=大,200=接近 1/3。
|
||
try {
|
||
const panes = chart.getPanes()
|
||
if (panes.length >= 2) {
|
||
panes[panes.length - 1].setHeight(150)
|
||
}
|
||
} catch (e) {
|
||
console.warn('resize MACD pane failed', e)
|
||
}
|
||
// K 线默认宽度:bar spacing 是每根 bar 占的像素数。TV 默认 ~6,调大让 K 线
|
||
// 更宽更易看。10=略宽,14=明显宽,20=很宽(首屏 bar 数会变少)。
|
||
try {
|
||
chart.getTimeScale?.()?.setBarSpacing?.(3.5)
|
||
} catch (e) {
|
||
console.warn('setBarSpacing failed', e)
|
||
}
|
||
attachDataLoadedListener(chart)
|
||
attachIntervalListener(chart)
|
||
attachVisibleRangeListener(chart)
|
||
// 监听 chan study 的样式变化,把当前 getStyleValues() 持久化到 localStorage;
|
||
// 下次 makeChanIndicator() 时把保存值合并进 metainfo defaults,跨刷新保留。
|
||
try {
|
||
widget.subscribe?.('study_properties_changed', (entityId: any) => {
|
||
const studies: any[] = chart.getAllStudies?.() ?? []
|
||
if (!studies.some((s) => s.name === CHAN_STUDY_NAME && s.id === entityId)) return
|
||
const api = chart.getStudyById?.(entityId)
|
||
const sv = api?.getStyleValues?.()
|
||
if (sv) saveChanStyles(sv)
|
||
})
|
||
} catch (e) {
|
||
console.warn('subscribe study_properties_changed failed', e)
|
||
}
|
||
void refreshChanStudy(chart)
|
||
})
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
try {
|
||
subRef.current?.unsubscribe?.()
|
||
} catch {}
|
||
try {
|
||
rangeSubRef.current?.unsubscribe?.()
|
||
} catch {}
|
||
try {
|
||
intervalSubRef.current?.unsubscribe?.()
|
||
} catch {}
|
||
try {
|
||
widgetRef.current?.remove?.()
|
||
} catch {}
|
||
widgetRef.current = null
|
||
if (debounceTimer.current) {
|
||
window.clearTimeout(debounceTimer.current)
|
||
debounceTimer.current = null
|
||
}
|
||
if (rangeDebounceTimer.current) {
|
||
window.clearTimeout(rangeDebounceTimer.current)
|
||
rangeDebounceTimer.current = null
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
// 主题切换:localStorage 持久化 + 刷新页面让 chan 指标按新主题重建默认色(makeChanIndicator
|
||
// 是在 widget 创建时调用的,主题切换不会重新读默认色)。chan 样式 localStorage 按主题分开存,
|
||
// 切回原主题时颜色不会被另一主题污染。
|
||
useEffect(() => {
|
||
const prev = (() => {
|
||
try { return localStorage.getItem('chart-theme') } catch { return null }
|
||
})()
|
||
try { localStorage.setItem('chart-theme', theme) } catch {}
|
||
document.body.style.background = theme === 'light' ? '#ffffff' : '#0e1116'
|
||
document.body.style.color = theme === 'light' ? '#131722' : '#d1d4dc'
|
||
// 初次进入页面(prev === theme)不刷新;用户真的切换主题(prev !== theme)才刷新
|
||
if (prev && prev !== theme) {
|
||
window.location.reload()
|
||
return
|
||
}
|
||
const w = widgetRef.current
|
||
if (w?.changeTheme) {
|
||
void w.changeTheme(theme).then(() => {
|
||
try {
|
||
w.applyOverrides?.({
|
||
'paneProperties.background': theme === 'light' ? '#ffffff' : '#131722',
|
||
})
|
||
} catch {}
|
||
})
|
||
}
|
||
}, [theme])
|
||
|
||
useEffect(() => {
|
||
const w = widgetRef.current
|
||
if (!w) return
|
||
try {
|
||
w.setSymbol(
|
||
`${props.exchange}:${props.symbol}`,
|
||
freqToResolution(props.freq),
|
||
() => {
|
||
const chart = w.activeChart?.()
|
||
if (!chart) return
|
||
attachDataLoadedListener(chart)
|
||
attachIntervalListener(chart)
|
||
attachVisibleRangeListener(chart)
|
||
void refreshChanStudy(chart)
|
||
},
|
||
)
|
||
} catch {
|
||
// widget 还没 ready,无视
|
||
}
|
||
}, [props.exchange, props.symbol, props.freq])
|
||
|
||
function attachVisibleRangeListener(chart: any) {
|
||
try {
|
||
rangeSubRef.current?.unsubscribe?.()
|
||
} catch {}
|
||
rangeSubRef.current = null
|
||
try {
|
||
// 用户往回拖(visible range 变了但没新数据),onDataLoaded 不会触发;
|
||
// 这里兜底:visible range 变化 → debounce 200ms → 重新拉 chan 覆盖新视区
|
||
const sub = chart.onVisibleRangeChanged().subscribe(null, () => {
|
||
if (rangeDebounceTimer.current) window.clearTimeout(rangeDebounceTimer.current)
|
||
rangeDebounceTimer.current = window.setTimeout(() => {
|
||
void refreshChanStudy(chart)
|
||
}, 200)
|
||
})
|
||
rangeSubRef.current = sub ?? null
|
||
} catch (e) {
|
||
console.warn('onVisibleRangeChanged subscribe failed', e)
|
||
}
|
||
}
|
||
|
||
function attachIntervalListener(chart: any) {
|
||
try {
|
||
intervalSubRef.current?.unsubscribe?.()
|
||
} catch {}
|
||
intervalSubRef.current = null
|
||
try {
|
||
const sub = chart.onIntervalChanged().subscribe(null, (resolution: any) => {
|
||
try {
|
||
const f = resolutionToFreq(String(resolution))
|
||
if (f !== propsRef.current.freq) {
|
||
propsRef.current.onFreqChange?.(f)
|
||
}
|
||
} catch {
|
||
// 未知 resolution(如 user 设置奇怪的 tick 周期),忽略
|
||
}
|
||
})
|
||
intervalSubRef.current = sub ?? null
|
||
} catch (e) {
|
||
console.warn('onIntervalChanged subscribe failed', e)
|
||
}
|
||
}
|
||
|
||
function attachDataLoadedListener(chart: any) {
|
||
try {
|
||
subRef.current?.unsubscribe?.()
|
||
} catch {}
|
||
subRef.current = null
|
||
try {
|
||
const sub = chart.onDataLoaded().subscribe(null, () => {
|
||
if (debounceTimer.current) window.clearTimeout(debounceTimer.current)
|
||
debounceTimer.current = window.setTimeout(() => {
|
||
void refreshChanStudy(chart)
|
||
}, CHAN_AFTER_DATA_LOADED_MS)
|
||
})
|
||
subRef.current = sub ?? null
|
||
} catch (e) {
|
||
console.warn('onDataLoaded subscribe failed', e)
|
||
}
|
||
}
|
||
|
||
async function refreshChanStudy(chart: any) {
|
||
// 串行化:同时只允许一个 refresh 跑。期间所有外部 trigger 合并成一个 pending,
|
||
// 当前 refresh 结束后若 pending=true 再跑一轮(range 没变会被下面的 dedupe 跳过)。
|
||
if (refreshInFlight.current) {
|
||
refreshPending.current = true
|
||
return
|
||
}
|
||
refreshInFlight.current = true
|
||
try {
|
||
do {
|
||
refreshPending.current = false
|
||
await doRefreshOnce(chart)
|
||
} while (refreshPending.current)
|
||
} finally {
|
||
refreshInFlight.current = false
|
||
}
|
||
}
|
||
|
||
async function doRefreshOnce(chart: any) {
|
||
++reloadGen.current
|
||
let range: { from: number; to: number } | null = null
|
||
try {
|
||
range = chart.getVisibleRange()
|
||
} catch {}
|
||
if (!range || !range.from || !range.to) return
|
||
|
||
// 关键:symbol/freq 都从 TV widget 实时取,不依赖 React props。
|
||
// 用户可能通过 TV 顶部搜索框换 symbol,或左下角按钮切 freq。
|
||
// 用 symbolExt().ticker(始终 'EXCH:CODE')而不是 chart.symbol(),后者返回的是
|
||
// resolveSymbol 里设置的 name 字段,现在 name 已经是中文(如「螺纹钢」),split 不出 code。
|
||
let exchange = propsRef.current.exchange
|
||
let symbol = propsRef.current.symbol
|
||
try {
|
||
const tk = String(chart.symbolExt?.()?.ticker ?? '')
|
||
if (tk.includes(':')) {
|
||
const [ex, sy] = tk.split(':')
|
||
exchange = ex
|
||
symbol = sy
|
||
} else if (tk) {
|
||
symbol = tk
|
||
}
|
||
} catch {}
|
||
let freq: Freq
|
||
try {
|
||
const res = String(chart.resolution())
|
||
freq = resolutionToFreq(res)
|
||
} catch {
|
||
freq = propsRef.current.freq
|
||
}
|
||
// 回放模式:把可视区右端截到 cutoffTs,chan 只取游标之前的结构。
|
||
const effectiveTo = isStreamActive()
|
||
? Math.min(range.to, streamState.cutoffTs)
|
||
: range.to
|
||
// 去重:若 (symbol,freq,range,cutoff) 跟上次成功提交一致,跳过 —— 否则
|
||
// setInputValues 引发的 onDataLoaded 会无限重入 refresh。
|
||
const key = `${exchange}:${symbol}:${freq}:${streamState.cutoffTs}`
|
||
const lc = lastCommitted.current
|
||
if (lc && lc.key === key && lc.from === range.from && lc.to === effectiveTo) return
|
||
const fromIso = unixToIso(range.from)
|
||
const toIso = unixToIso(effectiveTo)
|
||
try {
|
||
// 先拿 chan,再根据"所有结构 [t0,t1] 的覆盖范围"扩大 /bars 请求范围
|
||
// 避免笔/段延伸到可视区外时,bar-index 插值斜率算错(笔/段形变)。
|
||
// 回放/回测走各自的 /{mode}/{id}/chan:chan 仅基于已 push 的 bar 计算,
|
||
// 最新一段未确认时 sure=false 显示虚线。普通模式走 /chan(全 csv 切片,秒回)。
|
||
const snap = await (() => {
|
||
const sid = streamState.sessionId
|
||
if (sid && streamState.mode === 'replay') return api.replayChan(sid, { from: fromIso, to: toIso })
|
||
if (sid && streamState.mode === 'backtest') return api.backtestChan(sid, { from: fromIso, to: toIso })
|
||
return api.chan({ exchange, symbol, freq, from: fromIso, to: toIso })
|
||
})()
|
||
|
||
let minT = range.from, maxT = range.to
|
||
const extend = (t0: number, t1: number) => {
|
||
if (t0 < minT) minT = t0
|
||
if (t1 > maxT) maxT = t1
|
||
}
|
||
for (const x of snap.bis) extend(x.t0, x.t1)
|
||
for (const x of snap.segs) extend(x.t0, x.t1)
|
||
for (const x of snap.zs) extend(x.t0, x.t1)
|
||
for (const x of snap.segzs) extend(x.t0, x.t1)
|
||
|
||
const barsResp = await api.bars({
|
||
exchange, symbol, freq,
|
||
from: unixToIso(minT), to: unixToIso(maxT),
|
||
})
|
||
// 累积合并:同 (exchange,symbol,freq) 多次 refresh 不再覆盖,让用户拖拽过的
|
||
// 历史区间 BSP 标签持续保留;切 symbol/freq 时按 key 整个替换。
|
||
commitChanLookup(buildChanLookup(snap, barsResp.bars), key)
|
||
lastCommitted.current = { key, from: range.from, to: range.to }
|
||
await ensureAndPokeChanStudy(chart)
|
||
} catch (e) {
|
||
console.error('chan study refresh failed', e)
|
||
}
|
||
}
|
||
|
||
// ---------- 回放 handlers ---------- //
|
||
function parseAtToUnix(at: string): number {
|
||
// at 可能是 'YYYY-MM-DD' 或 'YYYY-MM-DD HH:MM:SS',按北京时间解析
|
||
const cleaned = at.trim()
|
||
const padded = cleaned.includes(' ') ? cleaned : `${cleaned} 00:00:00`
|
||
const d = new Date(padded.replace(' ', 'T') + '+08:00')
|
||
return Math.floor(d.getTime() / 1000)
|
||
}
|
||
|
||
async function resolveCurrentSymbolFreq(): Promise<{ exchange: string; symbol: string; freq: Freq } | null> {
|
||
const chart = widgetRef.current?.activeChart?.()
|
||
if (!chart) return null
|
||
let exchange = propsRef.current.exchange
|
||
let symbol = propsRef.current.symbol
|
||
let freq = propsRef.current.freq
|
||
try {
|
||
const tk = String(chart.symbolExt?.()?.ticker ?? '')
|
||
if (tk.includes(':')) {
|
||
const [ex, sy] = tk.split(':')
|
||
exchange = ex
|
||
symbol = sy
|
||
}
|
||
} catch {}
|
||
try { freq = resolutionToFreq(String(chart.resolution())) } catch {}
|
||
return { exchange, symbol, freq }
|
||
}
|
||
|
||
// 给定 freq 估算「200 根 bar 大致是多少秒」,用来给 setVisibleRange 算左边界
|
||
function approxSecondsPerNBars(freq: Freq, n: number): number {
|
||
const sec: Record<string, number> = {
|
||
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
|
||
'1h': 3600, '2h': 7200, '4h': 14400, '1d': 86400,
|
||
}
|
||
return (sec[freq] ?? 60) * n
|
||
}
|
||
|
||
async function handleReplayStart() {
|
||
if (!replayDate.trim()) return
|
||
const w = widgetRef.current
|
||
const chart = w?.activeChart?.()
|
||
const ctx = await resolveCurrentSymbolFreq()
|
||
if (!w || !chart || !ctx) return
|
||
try {
|
||
const resp = await api.replayStart({
|
||
exchange: ctx.exchange, symbol: ctx.symbol, freq: ctx.freq, at: replayDate,
|
||
})
|
||
const cutoffUnix = parseAtToUnix(replayDate)
|
||
enterReplay(resp.session_id, cutoffUnix)
|
||
setReplayUiActive(true)
|
||
setReplayRemaining(resp.remaining)
|
||
// 同 symbol 强制 TV 重拉数据:先通知 TV 清缓存,再 resetData() 触发 getBars
|
||
// (getBars 里会读 streamState.cutoffTs,自动截断到回放起点之前)。
|
||
streamState.resetCache?.()
|
||
chart.resetData?.()
|
||
lastCommitted.current = null
|
||
// 滚动到 cutoff 附近:左侧 200 根历史作上下文,cutoff 留在右侧约 4/5 位置
|
||
const back = approxSecondsPerNBars(ctx.freq, 200)
|
||
const ahead = approxSecondsPerNBars(ctx.freq, 50)
|
||
try {
|
||
void chart.setVisibleRange?.({ from: cutoffUnix - back, to: cutoffUnix + ahead })
|
||
} catch (e) {
|
||
console.warn('setVisibleRange failed', e)
|
||
}
|
||
void refreshChanStudy(chart)
|
||
} catch (e) {
|
||
console.error('replay start failed', e)
|
||
}
|
||
}
|
||
|
||
async function handleReplayStep(n: number) {
|
||
const sid = streamState.sessionId
|
||
if (!sid) return
|
||
try {
|
||
const resp = await api.replayStep(sid, n)
|
||
advanceStream(resp.bars)
|
||
setReplayRemaining(resp.remaining)
|
||
const chart = widgetRef.current?.activeChart?.()
|
||
if (chart) {
|
||
lastCommitted.current = null
|
||
void refreshChanStudy(chart)
|
||
}
|
||
if (resp.remaining === 0) stopAutoPlay()
|
||
} catch (e) {
|
||
console.error('replay step failed', e)
|
||
}
|
||
}
|
||
|
||
function startAutoPlay() {
|
||
if (autoPlayTimer.current) return
|
||
setIsAutoPlaying(true)
|
||
autoPlayTimer.current = window.setInterval(() => {
|
||
void handleReplayStep(1)
|
||
}, 500)
|
||
}
|
||
|
||
function stopAutoPlay() {
|
||
if (autoPlayTimer.current) {
|
||
window.clearInterval(autoPlayTimer.current)
|
||
autoPlayTimer.current = null
|
||
}
|
||
setIsAutoPlaying(false)
|
||
}
|
||
|
||
async function handleReplayStop() {
|
||
stopAutoPlay()
|
||
const sid = streamState.sessionId
|
||
if (sid) { try { await api.replayStop(sid) } catch {} }
|
||
exitStream()
|
||
setReplayUiActive(false)
|
||
setReplayRemaining(0)
|
||
// 切回历史模式:同 symbol 强制重拉数据
|
||
const w = widgetRef.current
|
||
const chart = w?.activeChart?.()
|
||
if (chart) {
|
||
streamState.resetCache?.()
|
||
chart.resetData?.()
|
||
lastCommitted.current = null
|
||
void refreshChanStudy(chart)
|
||
}
|
||
}
|
||
|
||
// 卸载时清自动播放定时器
|
||
useEffect(() => () => {
|
||
if (autoPlayTimer.current) window.clearInterval(autoPlayTimer.current)
|
||
}, [])
|
||
|
||
// 关闭面板:如果正在回放就停回放,否则只是收起 UI
|
||
async function closeReplayPanel() {
|
||
if (replayUiActive) {
|
||
await handleReplayStop()
|
||
}
|
||
setReplayPanelOpen(false)
|
||
}
|
||
|
||
// 按主题计算样式:light 浅灰底深字,dark 深灰底浅字
|
||
const btn: React.CSSProperties = theme === 'light'
|
||
? { padding: '2px 8px', background: '#f0f3fa', color: '#131722',
|
||
border: '1px solid #e0e3eb', borderRadius: 3, cursor: 'pointer',
|
||
fontSize: 12, height: 22 }
|
||
: { padding: '2px 8px', background: '#2a2e39', color: '#d1d4dc',
|
||
border: '1px solid #363a45', borderRadius: 3, cursor: 'pointer',
|
||
fontSize: 12, height: 22 }
|
||
const panelBg = theme === 'light' ? '#ffffff' : '#1e222d'
|
||
const panelBorder = theme === 'light' ? '#e0e3eb' : '#363a45'
|
||
const inputBg = theme === 'light' ? '#ffffff' : '#131722'
|
||
const textColor = theme === 'light' ? '#131722' : '#d1d4dc'
|
||
|
||
// 顶栏回放按钮:无背景、无边框,跟 TV 自带的图标按钮风格一致;
|
||
// 激活时(面板展开 / 回放中)字色高亮成蓝色而不是改背景
|
||
const replayIconColor = replayPanelOpen || replayUiActive
|
||
? '#2962ff'
|
||
: (theme === 'light' ? '#131722' : '#d1d4dc')
|
||
const headerButton = (
|
||
<button
|
||
onClick={() => setReplayPanelOpen((v) => !v)}
|
||
onMouseEnter={() => setReplayHover(true)}
|
||
onMouseLeave={() => setReplayHover(false)}
|
||
style={{
|
||
padding: '6px 8px',
|
||
background: replayHover ? 'rgba(127,127,127,0.15)' : 'transparent',
|
||
border: 'none', borderRadius: 4,
|
||
cursor: 'pointer', fontSize: 14, lineHeight: 1, height: 34,
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
color: replayIconColor,
|
||
transition: 'background 120ms ease',
|
||
}}
|
||
title="回放"
|
||
>
|
||
<svg
|
||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" strokeWidth="2"
|
||
strokeLinecap="round" strokeLinejoin="round"
|
||
>
|
||
<polygon points="19 20 9 12 19 4 19 20" />
|
||
<line x1="5" y1="19" x2="5" y2="5" />
|
||
</svg>
|
||
回放
|
||
</button>
|
||
)
|
||
|
||
// 回测按钮:图标 + 文字,类似回放
|
||
const backtestIconColor = backtestPanelOpen
|
||
? '#2962ff'
|
||
: (theme === 'light' ? '#131722' : '#d1d4dc')
|
||
const backtestButton = (
|
||
<button
|
||
onClick={() => setBacktestPanelOpen((v) => !v)}
|
||
onMouseEnter={() => setBacktestHover(true)}
|
||
onMouseLeave={() => setBacktestHover(false)}
|
||
style={{
|
||
padding: '6px 8px',
|
||
background: backtestHover ? 'rgba(127,127,127,0.15)' : 'transparent',
|
||
border: 'none', borderRadius: 4,
|
||
cursor: 'pointer', fontSize: 14, lineHeight: 1, height: 34,
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
color: backtestIconColor,
|
||
transition: 'background 120ms ease',
|
||
}}
|
||
title="回测"
|
||
>
|
||
<svg
|
||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" strokeWidth="2"
|
||
strokeLinecap="round" strokeLinejoin="round"
|
||
>
|
||
<path d="M3 3v18h18" />
|
||
<path d="M7 14l4-4 4 4 5-5" />
|
||
</svg>
|
||
回测
|
||
</button>
|
||
)
|
||
|
||
// 回测面板对接图表的「动作」集合
|
||
const chartActions: ChartActions = {
|
||
resetData: () => {
|
||
try { widgetRef.current?.activeChart?.()?.resetData?.() } catch {}
|
||
},
|
||
refreshChan: () => {
|
||
const chart = widgetRef.current?.activeChart?.()
|
||
if (!chart) return
|
||
lastCommitted.current = null
|
||
void refreshChanStudy(chart)
|
||
},
|
||
appendFills: (fills: BacktestFill[]) => {
|
||
const chart = widgetRef.current?.activeChart?.()
|
||
if (!chart || !chart.createShape) return
|
||
for (const f of fills) {
|
||
if (f.reason && f.reason.startsWith('ROLLOVER')) continue
|
||
// 多空 + 开平 4 类组合:
|
||
// BUY OPEN → 红色上箭头放在 K 下方(做多入场)
|
||
// SELL CLOSE → 红色下箭头放在 K 上方(平多出场)
|
||
// SELL OPEN → 绿色下箭头放在 K 上方(做空入场)
|
||
// BUY CLOSE → 绿色上箭头放在 K 下方(平空出场)
|
||
const isLongSide = f.side === 'BUY' // 此 fill 的方向是 BUY 还是 SELL
|
||
const isOpen = f.action === 'OPEN'
|
||
// 开仓在 K 旁边(多在下 / 空在上);平仓也跟着开仓位置(避免重叠混乱)
|
||
const below = (isLongSide && isOpen) || (!isLongSide && !isOpen)
|
||
const shape = below ? 'arrow_up' : 'arrow_down'
|
||
// 颜色:入场红,出场绿(涨红跌绿的延伸:开仓属于「博取上涨」的红,平仓收益落袋为绿)
|
||
const color = isOpen ? '#d32f2f' : '#089981'
|
||
try {
|
||
const id = chart.createShape(
|
||
{ time: f.t, price: f.price },
|
||
{
|
||
shape,
|
||
text: '',
|
||
overrides: { color, arrowColor: color },
|
||
lock: true,
|
||
disableSelection: true,
|
||
disableSave: true,
|
||
disableUndo: true,
|
||
},
|
||
)
|
||
if (id && typeof (id as any).then === 'function') {
|
||
(id as Promise<any>).then((eid) => {
|
||
if (eid != null) backtestFillShapeIds.current.push(eid)
|
||
})
|
||
} else if (id != null) {
|
||
backtestFillShapeIds.current.push(id)
|
||
}
|
||
} catch {
|
||
// 单个箭头画失败不影响其他
|
||
}
|
||
}
|
||
},
|
||
clearFills: () => {
|
||
const chart = widgetRef.current?.activeChart?.()
|
||
if (!chart) return
|
||
for (const id of backtestFillShapeIds.current) {
|
||
try { chart.removeEntity?.(id) } catch {}
|
||
}
|
||
backtestFillShapeIds.current = []
|
||
},
|
||
}
|
||
|
||
// 主题切换按钮:light 显示月亮(点击切到 dark),dark 显示太阳。
|
||
// 无背景填充、无边框,跟 TV 顶栏自带的设置/全屏图标风格一致。
|
||
const themeButton = (
|
||
<button
|
||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||
onMouseEnter={() => setThemeHover(true)}
|
||
onMouseLeave={() => setThemeHover(false)}
|
||
style={{
|
||
padding: '6px 6px',
|
||
background: themeHover ? 'rgba(127,127,127,0.15)' : 'transparent',
|
||
border: 'none', borderRadius: 4,
|
||
cursor: 'pointer', fontSize: 18, lineHeight: 1, height: 34,
|
||
color: theme === 'light' ? '#131722' : '#d1d4dc',
|
||
transition: 'background 120ms ease',
|
||
}}
|
||
title={theme === 'light' ? '切换到深色主题' : '切换到浅色主题'}
|
||
>
|
||
{theme === 'light' ? '🌙' : '☀'}
|
||
</button>
|
||
)
|
||
|
||
const bottomPanel = (
|
||
<div style={{
|
||
position: 'absolute', left: '50%', transform: 'translateX(-50%)',
|
||
bottom: 48,
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '6px 10px',
|
||
background: panelBg, border: `1px solid ${panelBorder}`, borderRadius: 6,
|
||
color: textColor, fontSize: 13, boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
zIndex: 10,
|
||
}}>
|
||
{!replayUiActive ? (
|
||
<>
|
||
<span>📅</span>
|
||
<input
|
||
type="text"
|
||
value={replayDate}
|
||
onChange={(e) => setReplayDate(e.target.value)}
|
||
placeholder="2024-01-01 或 2024-01-01 09:00:00"
|
||
style={{
|
||
width: 220, padding: '3px 8px', background: inputBg,
|
||
color: textColor, border: `1px solid ${panelBorder}`, borderRadius: 3,
|
||
fontSize: 12, height: 24,
|
||
}}
|
||
/>
|
||
<button onClick={handleReplayStart} style={btn}>开始回放</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button onClick={() => handleReplayStep(1)} disabled={isAutoPlaying} style={btn}>⏭ 下一根</button>
|
||
<button onClick={() => handleReplayStep(5)} disabled={isAutoPlaying} style={btn}>下 5 根</button>
|
||
<button onClick={() => handleReplayStep(20)} disabled={isAutoPlaying} style={btn}>下 20 根</button>
|
||
{!isAutoPlaying ? (
|
||
<button onClick={startAutoPlay} style={btn}>▶ 自动</button>
|
||
) : (
|
||
<button onClick={stopAutoPlay} style={btn}>⏸ 暂停</button>
|
||
)}
|
||
<span style={{ opacity: 0.7, fontSize: 12, marginLeft: 4 }}>剩余 {replayRemaining}</span>
|
||
</>
|
||
)}
|
||
<button
|
||
onClick={closeReplayPanel}
|
||
style={{ ...btn, marginLeft: 8 }}
|
||
title={replayUiActive ? '停止回放并关闭' : '关闭'}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
{headerEl && createPortal(headerButton, headerEl)}
|
||
{backtestBtnEl && createPortal(backtestButton, backtestBtnEl)}
|
||
{themeBtnEl && createPortal(themeButton, themeBtnEl)}
|
||
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
|
||
<div style={{ position: 'relative', flex: 1, minWidth: 0, height: '100%' }}>
|
||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||
{replayPanelOpen && bottomPanel}
|
||
</div>
|
||
<BacktestPanel
|
||
open={backtestPanelOpen}
|
||
onClose={() => setBacktestPanelOpen(false)}
|
||
theme={theme}
|
||
exchange={props.exchange}
|
||
symbol={props.symbol}
|
||
freq={props.freq}
|
||
chartActions={chartActions}
|
||
/>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// 关键设计:chan study 创建一次后就一直保留,不再 remove+create。
|
||
//
|
||
// 之前用 remove+create 重建 study 的方式,用户在指标设置对话框里改的颜色/线宽全丢,
|
||
// 又试过 getStyleValues + applyOverrides 二次贴回,结果 TV 的 StudyPropertiesOverrider
|
||
// 不认自定义指标的 plot id(控制台报 'has no plot or input bi' 等),样式仍被重置。
|
||
//
|
||
// 现改用 setInputValues 触发 TV 重跑 main():chanIndicator 加了个隐藏的 epoch input,
|
||
// 每次 chanLookupHolder 更新后 bump epoch,TV 会按新 input 重新对所有 bar 调 main(),
|
||
// main() 读最新的 chanLookupHolder.current 拿到新值。study 对象本身不动,样式自然保留。
|
||
let chanEpoch = 0
|
||
|
||
async function ensureAndPokeChanStudy(chart: any) {
|
||
try {
|
||
const studies: any[] = chart.getAllStudies?.() ?? []
|
||
let existingId: any = null
|
||
for (const s of studies) {
|
||
if (s.name === CHAN_STUDY_NAME) {
|
||
existingId = s.id
|
||
break
|
||
}
|
||
}
|
||
chanEpoch += 1
|
||
if (existingId) {
|
||
try {
|
||
const api = chart.getStudyById?.(existingId)
|
||
api?.setInputValues?.([{ id: 'epoch', value: chanEpoch }])
|
||
} catch (err) {
|
||
console.warn('setInputValues Chan failed', err)
|
||
}
|
||
return
|
||
}
|
||
const id = await chart.createStudy(CHAN_STUDY_NAME, false, false, { epoch: chanEpoch })
|
||
if (!id) {
|
||
console.warn('createStudy Chan returned no id(指标未注册成功)')
|
||
return
|
||
}
|
||
// 把整个 chan study 提到 K 线上方,避免笔/段/中枢被 candle 遮住。
|
||
try {
|
||
chart.getStudyById?.(id)?.bringToFront?.()
|
||
} catch (err) {
|
||
console.warn('bringToFront Chan failed', err)
|
||
}
|
||
} catch (e) {
|
||
console.error('ensureAndPokeChanStudy 抛错', e)
|
||
}
|
||
}
|
||
|
||
function unixToIso(unix: number): string {
|
||
const d = new Date(unix * 1000)
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
return (
|
||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||
)
|
||
}
|