매매 시그널 디테일 팝업 기능 적용
This commit is contained in:
@@ -344,6 +344,9 @@ export class ChartManager {
|
||||
/** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */
|
||||
private _paneAreaRatio: { candle: number; aux: number } | null = null;
|
||||
|
||||
/** 보조지표 전용 패널 — 캔들 pane 최소화·선형 지표만 표시 (시그널 상세 등) */
|
||||
private _auxIndicatorOnlyLayout = false;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
theme: Theme,
|
||||
@@ -502,14 +505,22 @@ export class ChartManager {
|
||||
this.volumeSeries.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
} else if (panesInit[0]) {
|
||||
} else if (panesInit[0] && !this._auxIndicatorOnlyLayout) {
|
||||
panesInit[0].setStretchFactor(1);
|
||||
}
|
||||
|
||||
this._reapplyAllPatternMarkers();
|
||||
|
||||
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
||||
this._applyDefaultVisibleRange(200, 0);
|
||||
if (this._auxIndicatorOnlyLayout && bars.length <= 21) {
|
||||
try { this.chart.timeScale().fitContent(); } catch { /* ok */ }
|
||||
} else {
|
||||
this._applyDefaultVisibleRange(200, 0);
|
||||
}
|
||||
|
||||
if (this._auxIndicatorOnlyLayout) {
|
||||
this.syncChartOverlayVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4692,6 +4703,24 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보조지표 전용 레이아웃 — 캔들·오버레이 숨김, 하단 pane 선형 지표만 표시.
|
||||
*/
|
||||
applyAuxIndicatorOnlyLayout(enabled: boolean): void {
|
||||
this._auxIndicatorOnlyLayout = enabled;
|
||||
if (enabled) {
|
||||
this.setChartOverlayVisibility({
|
||||
ma: false,
|
||||
bollinger: false,
|
||||
ichimoku: false,
|
||||
candle: false,
|
||||
});
|
||||
} else {
|
||||
this.setChartOverlayVisibility({ ...DEFAULT_CHART_OVERLAY_VISIBILITY });
|
||||
}
|
||||
this.resetPaneHeights();
|
||||
}
|
||||
|
||||
private _volumeFrac(H: number): number {
|
||||
if (!this._volumePaneEnabled || !this._volumeVisible || this._candleOnlyLayout) return 0;
|
||||
return Math.min(Math.max(60 / H, 0.04), 0.12);
|
||||
@@ -4808,6 +4837,51 @@ export class ChartManager {
|
||||
return H;
|
||||
}
|
||||
|
||||
/** 보조지표 전용 — pane 0(캔들) 최소화, 보조 pane 균등 확장 */
|
||||
private _resetPaneHeightsAuxIndicatorOnly(availableHeight?: number): number {
|
||||
const panes = this.chart.panes();
|
||||
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
||||
|
||||
const ORPHAN_STRETCH = 0.0001;
|
||||
const IND_EQUAL_WEIGHT = 1;
|
||||
const H = (availableHeight && availableHeight > 0)
|
||||
? availableHeight
|
||||
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
||||
? this._lastLayoutAvailableHeight
|
||||
: this.container.clientHeight);
|
||||
const activeIndPanes = this._activeIndicatorPaneIndices();
|
||||
const volumeShown = this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout;
|
||||
|
||||
try {
|
||||
this.volumeSeries?.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
||||
} catch { /* ok */ }
|
||||
|
||||
for (let i = 0; i < panes.length; i++) {
|
||||
const paneIndex = panes[i].paneIndex();
|
||||
if (paneIndex === 0) {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
} else if (paneIndex === 1) {
|
||||
if (volumeShown) {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
} else if (activeIndPanes.has(1)) {
|
||||
panes[i].setStretchFactor(IND_EQUAL_WEIGHT);
|
||||
} else {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
}
|
||||
} else if (activeIndPanes.has(paneIndex)) {
|
||||
panes[i].setStretchFactor(IND_EQUAL_WEIGHT);
|
||||
} else {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncChartOverlayVisibility();
|
||||
this._restoreSubPaneIndicatorVisibility();
|
||||
this._scheduleIndicatorLastUpdate();
|
||||
|
||||
return H;
|
||||
}
|
||||
|
||||
resetPaneHeights(availableHeight?: number): number {
|
||||
const panes = this.chart.panes();
|
||||
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
||||
@@ -4820,6 +4894,10 @@ export class ChartManager {
|
||||
return this._resetPaneHeightsCandleFullscreen(availableHeight);
|
||||
}
|
||||
|
||||
if (this._auxIndicatorOnlyLayout) {
|
||||
return this._resetPaneHeightsAuxIndicatorOnly(availableHeight);
|
||||
}
|
||||
|
||||
const H = (availableHeight && availableHeight > 0)
|
||||
? availableHeight
|
||||
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
||||
|
||||
@@ -51,3 +51,57 @@ export async function loadAnalysisCandles(
|
||||
|
||||
return data.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/** 시그널 발생 봉 기준 좌우 N개 캔들 (기본 ±5) */
|
||||
export const SIGNAL_SNAPSHOT_CONTEXT_BARS = 5;
|
||||
|
||||
/** 화면에 표시할 봉 수 (±context) */
|
||||
export const SIGNAL_SNAPSHOT_VISIBLE_BARS = SIGNAL_SNAPSHOT_CONTEXT_BARS * 2 + 1;
|
||||
|
||||
/** CCI·RSI 등 보조지표 계산용 선행 워밍업 봉 (표시 범위와 별도) */
|
||||
const SIGNAL_SNAPSHOT_WARMUP_BARS = 50;
|
||||
|
||||
function findNearestBarIndex(bars: OHLCVBar[], candleTimeSec: number): number {
|
||||
if (bars.length === 0) return -1;
|
||||
let bestIdx = 0;
|
||||
let bestDist = Math.abs(bars[0].time - candleTimeSec);
|
||||
for (let i = 1; i < bars.length; i++) {
|
||||
const dist = Math.abs(bars[i].time - candleTimeSec);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
export async function loadSignalSnapshotCandles(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
candleTimeSec: number,
|
||||
contextBars = SIGNAL_SNAPSHOT_CONTEXT_BARS,
|
||||
): Promise<OHLCVBar[]> {
|
||||
const barSec = timeframeToBarSeconds(timeframe);
|
||||
const normalizedCandle = normalizeEpochSec(candleTimeSec);
|
||||
const wantVisible = contextBars * 2 + 1;
|
||||
const toTimeSec = normalizedCandle + barSec * (contextBars + 2);
|
||||
const fetchCount = Math.max(wantVisible + SIGNAL_SNAPSHOT_WARMUP_BARS + 10, 60);
|
||||
const raw = await loadAnalysisCandles(market, timeframe, toTimeSec, fetchCount);
|
||||
if (raw.length === 0) return raw;
|
||||
|
||||
let idx = raw.findIndex(b => b.time === normalizedCandle);
|
||||
if (idx < 0) idx = findNearestBarIndex(raw, normalizedCandle);
|
||||
if (idx < 0) return raw.slice(-wantVisible);
|
||||
|
||||
const start = Math.max(0, idx - contextBars - SIGNAL_SNAPSHOT_WARMUP_BARS);
|
||||
const end = Math.min(raw.length, idx + contextBars + 1);
|
||||
return raw.slice(start, end);
|
||||
}
|
||||
|
||||
function timeframeToBarSeconds(timeframe: string): number {
|
||||
const map: Record<string, number> = {
|
||||
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
|
||||
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
||||
};
|
||||
return map[timeframe] ?? 180;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ import type {
|
||||
|
||||
export type LiveTimelineEventKind = 'SIGNAL' | 'ORDER_PENDING' | 'FILL';
|
||||
|
||||
export interface LiveTimelineSignalMeta {
|
||||
signalId: number;
|
||||
candleTime: number;
|
||||
candleType: string;
|
||||
executionType: string;
|
||||
strategyId?: number | null;
|
||||
}
|
||||
|
||||
export interface LiveTimelineFilter {
|
||||
symbol?: string;
|
||||
strategyId?: number | null;
|
||||
@@ -25,6 +33,8 @@ export interface LiveTimelineEvent {
|
||||
strategyName?: string | null;
|
||||
sourceLabel?: string;
|
||||
detail?: string;
|
||||
/** kind === 'SIGNAL' 일 때만 채워짐 */
|
||||
signalMeta?: LiveTimelineSignalMeta;
|
||||
}
|
||||
|
||||
export const LIVE_TIMELINE_KIND_LABEL: Record<LiveTimelineEventKind, string> = {
|
||||
@@ -79,6 +89,13 @@ export function buildLiveTradeTimeline(
|
||||
price: s.price,
|
||||
strategyName: s.strategyName,
|
||||
detail: [s.candleType, s.executionType].filter(Boolean).join(' · ') || undefined,
|
||||
signalMeta: {
|
||||
signalId: s.id,
|
||||
candleTime: s.candleTime > 1e12 ? Math.floor(s.candleTime / 1000) : s.candleTime,
|
||||
candleType: s.candleType,
|
||||
executionType: s.executionType,
|
||||
strategyId: s.strategyId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,3 +247,27 @@ export function formatTimelineTime(ts: number): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
||||
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 타임라인 SIGNAL 이벤트 → 상세 팝업용 메타 */
|
||||
export interface TimelineSignalDetailSource {
|
||||
side: 'BUY' | 'SELL';
|
||||
price: number;
|
||||
symbol: string;
|
||||
strategyName?: string | null;
|
||||
ts: number;
|
||||
signalMeta: LiveTimelineSignalMeta;
|
||||
}
|
||||
|
||||
export function toTimelineSignalDetail(
|
||||
ev: LiveTimelineEvent,
|
||||
): TimelineSignalDetailSource | null {
|
||||
if (ev.kind !== 'SIGNAL' || !ev.signalMeta) return null;
|
||||
return {
|
||||
side: ev.side,
|
||||
price: ev.price,
|
||||
symbol: ev.symbol,
|
||||
strategyName: ev.strategyName,
|
||||
ts: ev.ts,
|
||||
signalMeta: ev.signalMeta,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,11 +145,15 @@ function walkIndicatorRefs(
|
||||
node.children?.forEach(ch => walkIndicatorRefs(ch, out, seen));
|
||||
}
|
||||
|
||||
function collectIndicatorRefs(strategy: StrategyDto): IndicatorRef[] {
|
||||
function collectIndicatorRefs(strategy: StrategyDto, side?: 'BUY' | 'SELL'): IndicatorRef[] {
|
||||
const out: IndicatorRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
|
||||
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
|
||||
if (!side || side === 'BUY') {
|
||||
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
|
||||
}
|
||||
if (!side || side === 'SELL') {
|
||||
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -240,10 +244,11 @@ export function buildStrategyChartIndicators(
|
||||
getParams: GetParams,
|
||||
getVisual: GetVisual,
|
||||
workspaceIndicators?: IndicatorConfig[],
|
||||
side?: 'BUY' | 'SELL',
|
||||
): IndicatorConfig[] {
|
||||
if (!strategy) return [];
|
||||
|
||||
const refs = collectIndicatorRefs(strategy);
|
||||
const refs = collectIndicatorRefs(strategy, side);
|
||||
const byType = new Map<string, IndicatorRef>();
|
||||
|
||||
for (const ref of refs) {
|
||||
|
||||
@@ -54,6 +54,67 @@ export function buildNotificationSignalMarkers(
|
||||
return [{ time, type: marker.signalType, price: marker.price }];
|
||||
}
|
||||
|
||||
/** 스냅샷 차트 — 시그널 발생 봉이 시간축 중앙에 오도록 visible logical range 설정 */
|
||||
export function centerChartOnSignalBar(
|
||||
mgr: ChartManager,
|
||||
candleTimeSec: number,
|
||||
visibleBars?: number,
|
||||
): void {
|
||||
const bars = mgr.getRawBars();
|
||||
if (bars.length < 2) {
|
||||
mgr.fitContent();
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeEpochSec(candleTimeSec);
|
||||
const barTime = resolveSignalMarkerBarTime(mgr, normalized);
|
||||
if (barTime == null) {
|
||||
mgr.fitContent();
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = bars.findIndex(b => b.time === barTime);
|
||||
if (idx < 0) {
|
||||
let bestIdx = 0;
|
||||
let bestDist = Math.abs(bars[0].time - barTime);
|
||||
for (let i = 1; i < bars.length; i++) {
|
||||
const dist = Math.abs(bars[i].time - barTime);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
idx = bestIdx;
|
||||
}
|
||||
|
||||
const totalBars = bars.length;
|
||||
const span = visibleBars != null && visibleBars > 0
|
||||
? visibleBars
|
||||
: totalBars;
|
||||
|
||||
if (visibleBars == null && totalBars <= 21) {
|
||||
mgr.fitContent();
|
||||
return;
|
||||
}
|
||||
|
||||
const halfSpan = span / 2;
|
||||
let from = idx - halfSpan + 0.5;
|
||||
let to = idx + halfSpan - 0.5;
|
||||
|
||||
if (from < 0) {
|
||||
to -= from;
|
||||
from = 0;
|
||||
}
|
||||
if (to > totalBars - 1) {
|
||||
from -= to - (totalBars - 1);
|
||||
to = totalBars - 1;
|
||||
}
|
||||
from = Math.max(0, from);
|
||||
to = Math.min(totalBars - 1, to);
|
||||
|
||||
mgr.applyVisibleLogicalRange(from, to);
|
||||
}
|
||||
|
||||
export function applyNotificationSignalMarkers(
|
||||
mgr: ChartManager,
|
||||
marker: TradeSignalChartMarker | undefined,
|
||||
|
||||
Reference in New Issue
Block a user