251 lines
6.6 KiB
TypeScript
251 lines
6.6 KiB
TypeScript
/** 캔들 차트 매수·매도 시그널 라벨 (화살표와 분리 렌더) */
|
|
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,
|
|
};
|
|
}
|