매수매도 시그널 표시 수정

This commit is contained in:
Macbook
2026-06-12 00:32:03 +09:00
parent 578cba3eb8
commit 423c60183e
4 changed files with 454 additions and 4 deletions
+33 -4
View File
@@ -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();
}
/**
+250
View File
@@ -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,
};
}