매수매도 시그널 표시 수정
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import {
|
||||
layoutStackedSignalLabels,
|
||||
resolveSignalLabelAnchors,
|
||||
type DrawableSignalLabel,
|
||||
type TradeSignalLabelItem,
|
||||
} from '../utils/tradeSignalLabels';
|
||||
|
||||
interface Props {
|
||||
manager: ChartManager;
|
||||
}
|
||||
|
||||
const ARROW_OFFSET = 16;
|
||||
const FONT = '600 11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
|
||||
function resolveArrowY(
|
||||
mgr: ChartManager,
|
||||
item: TradeSignalLabelItem,
|
||||
mainTopY: number,
|
||||
): number | null {
|
||||
const bar = mgr.getBarAtTime(item.time);
|
||||
let paneY: number | null;
|
||||
if (bar) {
|
||||
paneY = item.isBuy ? mgr.priceToY(bar.low) : mgr.priceToY(bar.high);
|
||||
} else {
|
||||
paneY = mgr.priceToY(item.price);
|
||||
}
|
||||
if (paneY == null) return null;
|
||||
const chartY = paneY + mainTopY;
|
||||
return item.isBuy ? chartY + ARROW_OFFSET : chartY - ARROW_OFFSET;
|
||||
}
|
||||
|
||||
function drawLabel(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
textY: number,
|
||||
text: string,
|
||||
color: string,
|
||||
baseline: 'top' | 'bottom',
|
||||
): void {
|
||||
ctx.font = FONT;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = baseline;
|
||||
ctx.fillText(text, x, textY);
|
||||
}
|
||||
|
||||
function renderTradeSignalLabels(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
mgr: ChartManager,
|
||||
cssW: number,
|
||||
labels: TradeSignalLabelItem[],
|
||||
): void {
|
||||
const main = mgr.getPaneLayouts().find(l => l.paneIndex === 0);
|
||||
if (!main || labels.length === 0) return;
|
||||
|
||||
const { buyBaseBottomY, sellBaseTopY } = resolveSignalLabelAnchors(mgr, main);
|
||||
|
||||
const drawable: DrawableSignalLabel[] = [];
|
||||
for (const item of labels) {
|
||||
const x = mgr.timeToX(item.time);
|
||||
if (x == null || x < -40 || x > cssW + 40) continue;
|
||||
const arrowY = resolveArrowY(mgr, item, main.topY);
|
||||
if (arrowY == null) continue;
|
||||
drawable.push({ item, x, arrowY });
|
||||
}
|
||||
if (drawable.length === 0) return;
|
||||
|
||||
const buys = drawable.filter(d => d.item.isBuy);
|
||||
const sells = drawable.filter(d => !d.item.isBuy);
|
||||
|
||||
const buyPlaced = layoutStackedSignalLabels(
|
||||
ctx, buys, mgr, buyBaseBottomY, 'bottom', FONT,
|
||||
);
|
||||
const sellPlaced = layoutStackedSignalLabels(
|
||||
ctx, sells, mgr, sellBaseTopY, 'top', FONT,
|
||||
);
|
||||
|
||||
ctx.setLineDash([3, 4]);
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (const p of [...buyPlaced, ...sellPlaced]) {
|
||||
ctx.strokeStyle = p.item.color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.arrowY);
|
||||
ctx.lineTo(p.x, p.textY);
|
||||
ctx.stroke();
|
||||
|
||||
drawLabel(ctx, p.x, p.textY, p.item.text, p.item.color, p.baseline);
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = manager.getContainer();
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const cssW = container.clientWidth;
|
||||
const cssH = container.clientHeight;
|
||||
if (cssW <= 0 || cssH <= 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(cssW * dpr);
|
||||
canvas.height = Math.round(cssH * dpr);
|
||||
canvas.style.width = `${cssW}px`;
|
||||
canvas.style.height = `${cssH}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
renderTradeSignalLabels(ctx, manager, cssW, manager.getTradeSignalLabels());
|
||||
}, [manager]);
|
||||
|
||||
useEffect(() => {
|
||||
redraw();
|
||||
let layoutRaf = 0;
|
||||
const scheduleRedraw = () => {
|
||||
cancelAnimationFrame(layoutRaf);
|
||||
layoutRaf = requestAnimationFrame(redraw);
|
||||
};
|
||||
|
||||
const unsubs = [
|
||||
manager.subscribePaneLayout(scheduleRedraw),
|
||||
manager.subscribeViewport(scheduleRedraw),
|
||||
manager.subscribeTradeSignalLabels(scheduleRedraw),
|
||||
];
|
||||
|
||||
const container = manager.getContainer();
|
||||
const ro = new ResizeObserver(scheduleRedraw);
|
||||
if (container) ro.observe(container);
|
||||
|
||||
const raf1 = requestAnimationFrame(redraw);
|
||||
const raf2 = requestAnimationFrame(() => requestAnimationFrame(redraw));
|
||||
|
||||
return () => {
|
||||
unsubs.forEach(u => u());
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(layoutRaf);
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
};
|
||||
}, [manager, redraw]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="trade-signal-label-overlay"
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeSignalLabelOverlay;
|
||||
@@ -64,6 +64,7 @@ import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKe
|
||||
import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
|
||||
import TradeSignalLabelOverlay from './TradeSignalLabelOverlay';
|
||||
import { centerChartOnSignalBar } from '../utils/tradeSignalChartMarkers';
|
||||
|
||||
interface TradingChartProps {
|
||||
@@ -1861,6 +1862,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
options={paneSeparatorOptions}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && chartPaintReady && (
|
||||
<TradeSignalLabelOverlay manager={chartMgr} />
|
||||
)}
|
||||
{chartMgr && (
|
||||
<DrawingCanvas
|
||||
manager={chartMgr}
|
||||
|
||||
@@ -28,6 +28,11 @@ import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../
|
||||
import { formatChartAxisPrice, formatIndicatorAxisPrice } from './dataGenerator';
|
||||
import { formatUpbitKrwPrice } from './safeFormat';
|
||||
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
|
||||
import {
|
||||
toTradeSignalLabelItem,
|
||||
type TradeSignalLabelItem,
|
||||
type TradeSignalMarkerWithPrice,
|
||||
} from './tradeSignalLabels';
|
||||
import {
|
||||
DEFAULT_CHART_TIME_FORMAT,
|
||||
formatLwcTimeWithPattern,
|
||||
@@ -266,9 +271,10 @@ export class ChartManager {
|
||||
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
|
||||
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
|
||||
/** 백테스팅 매수/매도 시그널 마커 */
|
||||
private backtestMarkers: MarkerData[] = [];
|
||||
private backtestMarkers: TradeSignalMarkerWithPrice[] = [];
|
||||
/** 실시간 전략 체크 마커 */
|
||||
private liveStrategyMarkers: MarkerData[] = [];
|
||||
private liveStrategyMarkers: TradeSignalMarkerWithPrice[] = [];
|
||||
private _tradeSignalLabelListeners = new Set<() => void>();
|
||||
private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();
|
||||
/** 캔들 pane 오버레이 그룹 표시 (config.hidden 과 별도 — in-place 토글용) */
|
||||
private _chartOverlayVisibility: ChartOverlayVisibility = {
|
||||
@@ -1762,14 +1768,14 @@ export class ChartManager {
|
||||
position: m.position,
|
||||
shape: m.shape,
|
||||
color: m.color,
|
||||
text: m.text,
|
||||
text: '',
|
||||
}));
|
||||
const liveAll = this.liveStrategyMarkers.map(m => ({
|
||||
time: m.time as Time,
|
||||
position: m.position,
|
||||
shape: m.shape,
|
||||
color: m.color,
|
||||
text: m.text,
|
||||
text: '',
|
||||
}));
|
||||
const all = [...patternAll, ...backtestAll, ...liveAll];
|
||||
if (this.mainMarkersPlugin) {
|
||||
@@ -1815,17 +1821,20 @@ export class ChartManager {
|
||||
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
|
||||
color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR),
|
||||
text,
|
||||
price: s.price,
|
||||
};
|
||||
});
|
||||
// 시리즈 교체·detach 이후 stale 플러그인 방지 — 백테스트 마커는 항상 재생성
|
||||
this._disposeMainMarkersPlugin();
|
||||
this._reapplyAllPatternMarkers();
|
||||
this._notifyTradeSignalLabels();
|
||||
}
|
||||
|
||||
/** 백테스팅 마커 제거 */
|
||||
clearBacktestMarkers(): void {
|
||||
this.backtestMarkers = [];
|
||||
this._reapplyAllPatternMarkers();
|
||||
this._notifyTradeSignalLabels();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1848,15 +1857,35 @@ export class ChartManager {
|
||||
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
|
||||
color: buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR,
|
||||
text,
|
||||
price: m.price,
|
||||
};
|
||||
});
|
||||
this._reapplyAllPatternMarkers();
|
||||
this._notifyTradeSignalLabels();
|
||||
}
|
||||
|
||||
/** 실시간 전략 마커 전체 제거 */
|
||||
clearLiveStrategyMarkers(): void {
|
||||
this.liveStrategyMarkers = [];
|
||||
this._reapplyAllPatternMarkers();
|
||||
this._notifyTradeSignalLabels();
|
||||
}
|
||||
|
||||
/** 매수·매도 시그널 라벨 (캔버스 오버레이용) */
|
||||
getTradeSignalLabels(): TradeSignalLabelItem[] {
|
||||
return [
|
||||
...this.backtestMarkers.map(toTradeSignalLabelItem),
|
||||
...this.liveStrategyMarkers.map(toTradeSignalLabelItem),
|
||||
];
|
||||
}
|
||||
|
||||
subscribeTradeSignalLabels(cb: () => void): () => void {
|
||||
this._tradeSignalLabelListeners.add(cb);
|
||||
return () => { this._tradeSignalLabelListeners.delete(cb); };
|
||||
}
|
||||
|
||||
private _notifyTradeSignalLabels(): void {
|
||||
for (const cb of this._tradeSignalLabelListeners) cb();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/** 캔들 차트 매수·매도 시그널 라벨 (화살표와 분리 렌더) */
|
||||
export interface TradeSignalLabelItem {
|
||||
time: number;
|
||||
text: string;
|
||||
color: string;
|
||||
isBuy: boolean;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface TradeSignalMarkerWithPrice {
|
||||
time: number;
|
||||
position: 'aboveBar' | 'belowBar';
|
||||
shape: 'arrowUp' | 'arrowDown' | 'circle' | 'square';
|
||||
color: string;
|
||||
text: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface SignalLabelAnchors {
|
||||
/** 매수 라벨 baseline (bottom) — 시간축 바로 위 */
|
||||
buyBaseBottomY: number;
|
||||
/** 매도 라벨 baseline (top) — 캔들 pane 상단 */
|
||||
sellBaseTopY: number;
|
||||
}
|
||||
|
||||
export interface DrawableSignalLabel {
|
||||
item: TradeSignalLabelItem;
|
||||
x: number;
|
||||
arrowY: number;
|
||||
}
|
||||
|
||||
export interface PlacedSignalLabel {
|
||||
item: TradeSignalLabelItem;
|
||||
x: number;
|
||||
arrowY: number;
|
||||
textY: number;
|
||||
baseline: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
const CHART_BOTTOM_TIME_SCALE_H = 26;
|
||||
const GAP_ABOVE_TIME_AXIS = 4;
|
||||
const TOP_INSET = 10;
|
||||
export const SIGNAL_LABEL_LINE_HEIGHT = 15;
|
||||
const X_CLUSTER_PX = 12;
|
||||
const LABEL_COLLISION_PAD = 4;
|
||||
|
||||
interface BarTimeResolver {
|
||||
getBarAtTime(time: number): { time: number } | undefined;
|
||||
getRawBars(): Array<{ time: number }>;
|
||||
timeToX(time: number): number | null;
|
||||
}
|
||||
|
||||
export function toTradeSignalLabelItem(m: TradeSignalMarkerWithPrice): TradeSignalLabelItem {
|
||||
return {
|
||||
time: m.time,
|
||||
text: m.text,
|
||||
color: m.color,
|
||||
isBuy: m.position === 'belowBar',
|
||||
price: m.price,
|
||||
};
|
||||
}
|
||||
|
||||
/** 시그널 time → 차트 bar time (정확 일치 또는 최근접 봉) */
|
||||
export function resolveSignalBarTime(mgr: BarTimeResolver, timeSec: number): number {
|
||||
const exact = mgr.getBarAtTime(timeSec);
|
||||
if (exact) return exact.time;
|
||||
|
||||
const bars = mgr.getRawBars();
|
||||
if (bars.length === 0) return timeSec;
|
||||
|
||||
let best = bars[0].time;
|
||||
let bestDist = Math.abs(best - timeSec);
|
||||
for (let i = 1; i < bars.length; i++) {
|
||||
const t = bars[i].time;
|
||||
const dist = Math.abs(t - timeSec);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
|
||||
const interval = bars.length >= 2
|
||||
? Math.abs(bars[bars.length - 1].time - bars[bars.length - 2].time)
|
||||
: 60;
|
||||
const maxSnap = Math.max(interval * 2, 60);
|
||||
return bestDist <= maxSnap ? best : timeSec;
|
||||
}
|
||||
|
||||
/** 같은 캔들·같은 X 클러스터로 묶기 */
|
||||
export function clusterSignalLabels(
|
||||
items: DrawableSignalLabel[],
|
||||
mgr: BarTimeResolver,
|
||||
clusterPx = X_CLUSTER_PX,
|
||||
): DrawableSignalLabel[][] {
|
||||
if (items.length === 0) return [];
|
||||
|
||||
const enriched = items.map(d => ({
|
||||
d,
|
||||
barTime: resolveSignalBarTime(mgr, d.item.time),
|
||||
}));
|
||||
|
||||
const byBar = new Map<number, DrawableSignalLabel[]>();
|
||||
for (const { d, barTime } of enriched) {
|
||||
const list = byBar.get(barTime) ?? [];
|
||||
list.push(d);
|
||||
byBar.set(barTime, list);
|
||||
}
|
||||
|
||||
const groups = [...byBar.values()].map(group => {
|
||||
const barTime = resolveSignalBarTime(mgr, group[0].item.time);
|
||||
const snappedX = mgr.timeToX(barTime) ?? group[0].x;
|
||||
return group.map(d => ({ ...d, x: snappedX }));
|
||||
});
|
||||
|
||||
groups.sort((a, b) => a[0].x - b[0].x);
|
||||
|
||||
const merged: DrawableSignalLabel[][] = [];
|
||||
for (const g of groups) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && Math.abs(g[0].x - last[0].x) <= clusterPx) {
|
||||
last.push(...g);
|
||||
} else {
|
||||
merged.push([...g]);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
interface LabelRect {
|
||||
x: number;
|
||||
textY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
baseline: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
function labelRect(
|
||||
x: number,
|
||||
textY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
baseline: 'top' | 'bottom',
|
||||
): { left: number; right: number; top: number; bottom: number } {
|
||||
const halfW = width / 2;
|
||||
const top = baseline === 'top' ? textY : textY - height;
|
||||
const bottom = baseline === 'top' ? textY + height : textY;
|
||||
return { left: x - halfW, right: x + halfW, top, bottom };
|
||||
}
|
||||
|
||||
function rectsOverlap(a: LabelRect, b: LabelRect): boolean {
|
||||
const ra = labelRect(a.x, a.textY, a.width, a.height, a.baseline);
|
||||
const rb = labelRect(b.x, b.textY, b.width, b.height, b.baseline);
|
||||
const pad = LABEL_COLLISION_PAD;
|
||||
return ra.left < rb.right + pad
|
||||
&& ra.right + pad > rb.left
|
||||
&& ra.top < rb.bottom + pad
|
||||
&& ra.bottom + pad > rb.top;
|
||||
}
|
||||
|
||||
function compareSignalOrder(a: TradeSignalLabelItem, b: TradeSignalLabelItem): number {
|
||||
if (a.time !== b.time) return a.time - b.time;
|
||||
if (a.price !== b.price) return a.price - b.price;
|
||||
return a.text.localeCompare(b.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 캔들·겹치는 X 에 여러 시그널 — 세로 줄(lane) 분리 + 가로 겹침 시 다음 줄.
|
||||
*/
|
||||
export function layoutStackedSignalLabels(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
items: DrawableSignalLabel[],
|
||||
mgr: BarTimeResolver,
|
||||
baseY: number,
|
||||
baseline: 'top' | 'bottom',
|
||||
font: string,
|
||||
): PlacedSignalLabel[] {
|
||||
if (items.length === 0) return [];
|
||||
|
||||
ctx.font = font;
|
||||
const placed: PlacedSignalLabel[] = [];
|
||||
const occupied: LabelRect[] = [];
|
||||
const lineHeight = SIGNAL_LABEL_LINE_HEIGHT;
|
||||
|
||||
const clusters = clusterSignalLabels(items, mgr);
|
||||
|
||||
for (const cluster of clusters) {
|
||||
const anchorX = cluster[0].x;
|
||||
const sorted = [...cluster].sort((a, b) => compareSignalOrder(a.item, b.item));
|
||||
|
||||
for (const d of sorted) {
|
||||
const width = ctx.measureText(d.item.text).width;
|
||||
let lane = 0;
|
||||
|
||||
while (true) {
|
||||
const textY = baseline === 'top'
|
||||
? baseY + lane * lineHeight
|
||||
: baseY - lane * lineHeight;
|
||||
|
||||
const candidate: LabelRect = {
|
||||
x: anchorX,
|
||||
textY,
|
||||
width,
|
||||
height: lineHeight,
|
||||
baseline,
|
||||
};
|
||||
|
||||
if (!occupied.some(o => rectsOverlap(o, candidate))) {
|
||||
occupied.push(candidate);
|
||||
placed.push({
|
||||
item: d.item,
|
||||
x: anchorX,
|
||||
arrowY: d.arrowY,
|
||||
textY,
|
||||
baseline,
|
||||
});
|
||||
break;
|
||||
}
|
||||
lane += 1;
|
||||
if (lane > 24) {
|
||||
placed.push({
|
||||
item: d.item,
|
||||
x: anchorX,
|
||||
arrowY: d.arrowY,
|
||||
textY,
|
||||
baseline,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return placed;
|
||||
}
|
||||
|
||||
/** 매수(하단·시간축 위) / 매도(상단) 라벨 기준 Y */
|
||||
export function resolveSignalLabelAnchors(
|
||||
mgr: { getCandlePaneTimeAxisBand(): { topY: number; height: number; plotWidth: number } | null },
|
||||
main: { topY: number; height: number },
|
||||
): SignalLabelAnchors {
|
||||
const band = mgr.getCandlePaneTimeAxisBand();
|
||||
const buyBaseBottomY = band
|
||||
? band.topY - GAP_ABOVE_TIME_AXIS
|
||||
: main.topY + main.height - CHART_BOTTOM_TIME_SCALE_H - GAP_ABOVE_TIME_AXIS;
|
||||
|
||||
return {
|
||||
buyBaseBottomY,
|
||||
sellBaseTopY: main.topY + TOP_INSET,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user