매매 시그널 디테일 팝업 기능 적용

This commit is contained in:
Macbook
2026-06-10 21:13:53 +09:00
parent bf5554d375
commit 0ff1992b64
19 changed files with 1507 additions and 58 deletions
+80 -2
View File
@@ -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
+54
View File
@@ -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;
}
+41
View File
@@ -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,