chore: initial trade-visualizer import
This commit is contained in:
233
src/features/chart/FibRetracementPrimitive.ts
Normal file
233
src/features/chart/FibRetracementPrimitive.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type {
|
||||
IPrimitivePaneRenderer,
|
||||
IPrimitivePaneView,
|
||||
ISeriesApi,
|
||||
ISeriesPrimitive,
|
||||
SeriesAttachedParameter,
|
||||
Time,
|
||||
} from 'lightweight-charts';
|
||||
|
||||
export type FibAnchor = {
|
||||
logical: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export type FibRetracement = {
|
||||
a: FibAnchor;
|
||||
b: FibAnchor;
|
||||
};
|
||||
|
||||
type FibLevel = {
|
||||
ratio: number;
|
||||
line: string;
|
||||
fill: string;
|
||||
};
|
||||
|
||||
const LEVELS: FibLevel[] = [
|
||||
{ ratio: 4.236, line: 'rgba(255, 45, 85, 0.95)', fill: 'rgba(255, 45, 85, 0.16)' },
|
||||
{ ratio: 3.618, line: 'rgba(192, 132, 252, 0.95)', fill: 'rgba(192, 132, 252, 0.14)' },
|
||||
{ ratio: 2.618, line: 'rgba(239, 68, 68, 0.92)', fill: 'rgba(239, 68, 68, 0.14)' },
|
||||
{ ratio: 1.618, line: 'rgba(59, 130, 246, 0.92)', fill: 'rgba(59, 130, 246, 0.14)' },
|
||||
{ ratio: 1.0, line: 'rgba(148, 163, 184, 0.92)', fill: 'rgba(59, 130, 246, 0.10)' },
|
||||
{ ratio: 0.786, line: 'rgba(96, 165, 250, 0.92)', fill: 'rgba(96, 165, 250, 0.10)' },
|
||||
{ ratio: 0.618, line: 'rgba(6, 182, 212, 0.92)', fill: 'rgba(6, 182, 212, 0.10)' },
|
||||
{ ratio: 0.5, line: 'rgba(34, 197, 94, 0.92)', fill: 'rgba(34, 197, 94, 0.09)' },
|
||||
{ ratio: 0.382, line: 'rgba(245, 158, 11, 0.92)', fill: 'rgba(245, 158, 11, 0.10)' },
|
||||
{ ratio: 0.236, line: 'rgba(249, 115, 22, 0.92)', fill: 'rgba(249, 115, 22, 0.10)' },
|
||||
{ ratio: 0.0, line: 'rgba(163, 163, 163, 0.85)', fill: 'rgba(163, 163, 163, 0.06)' },
|
||||
];
|
||||
|
||||
function formatRatio(r: number): string {
|
||||
if (Number.isInteger(r)) return String(r);
|
||||
const s = r.toFixed(3);
|
||||
return s.replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function formatPrice(p: number): string {
|
||||
if (!Number.isFinite(p)) return '—';
|
||||
if (Math.abs(p) >= 1000) return p.toFixed(0);
|
||||
if (Math.abs(p) >= 1) return p.toFixed(2);
|
||||
return p.toPrecision(4);
|
||||
}
|
||||
|
||||
type State = {
|
||||
fib: FibRetracement | null;
|
||||
series: ISeriesApi<'Candlestick', Time> | null;
|
||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||
selected: boolean;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
private readonly _getState: () => State;
|
||||
|
||||
constructor(getState: () => State) {
|
||||
this._getState = getState;
|
||||
}
|
||||
|
||||
draw(target: any) {
|
||||
const { fib, series, chart, selected, opacity } = this._getState();
|
||||
if (!fib || !series || !chart) return;
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
if (clampedOpacity <= 0) return;
|
||||
|
||||
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
||||
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
||||
if (x1 == null || x2 == null) return;
|
||||
|
||||
const xLeftMedia = Math.min(x1, x2);
|
||||
const xRightMedia = Math.max(x1, x2);
|
||||
const p0 = fib.a.price;
|
||||
const p1 = fib.b.price;
|
||||
const delta = p1 - p0;
|
||||
|
||||
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||
context.save();
|
||||
context.globalAlpha *= clampedOpacity;
|
||||
try {
|
||||
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
||||
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
|
||||
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
|
||||
const w = xEnd - xStart;
|
||||
|
||||
const points = LEVELS.map((l) => {
|
||||
const price = p0 + delta * l.ratio;
|
||||
const y = series.priceToCoordinate(price);
|
||||
return { ...l, price, y };
|
||||
}).filter((p) => p.y != null) as Array<FibLevel & { price: number; y: number }>;
|
||||
|
||||
if (!points.length) return;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const ya = Math.round(a.y * verticalPixelRatio);
|
||||
const yb = Math.round(b.y * verticalPixelRatio);
|
||||
const top = Math.min(ya, yb);
|
||||
const h = Math.abs(yb - ya);
|
||||
if (h <= 0) continue;
|
||||
context.fillStyle = a.fill;
|
||||
context.fillRect(xStart, top, w, h);
|
||||
}
|
||||
|
||||
const lineW = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
context.lineWidth = lineW;
|
||||
|
||||
const labelX = Math.round(Math.max(6, xLeftMedia - 8) * horizontalPixelRatio);
|
||||
context.font = `${Math.max(10, Math.round(11 * verticalPixelRatio))}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif`;
|
||||
context.textAlign = 'right';
|
||||
context.textBaseline = 'middle';
|
||||
|
||||
for (const pt of points) {
|
||||
const y = Math.round(pt.y * verticalPixelRatio);
|
||||
context.strokeStyle = pt.line;
|
||||
context.beginPath();
|
||||
context.moveTo(xStart, y);
|
||||
context.lineTo(xEnd, y);
|
||||
context.stroke();
|
||||
|
||||
const label = `${formatRatio(pt.ratio)} (${formatPrice(pt.price)})`;
|
||||
context.fillStyle = pt.line;
|
||||
context.fillText(label, labelX, y);
|
||||
}
|
||||
|
||||
const y0 = series.priceToCoordinate(p0);
|
||||
const y1 = series.priceToCoordinate(p1);
|
||||
if (y0 != null && y1 != null) {
|
||||
const ax = Math.round(x1 * horizontalPixelRatio);
|
||||
const ay = Math.round(y0 * verticalPixelRatio);
|
||||
const bx = Math.round(x2 * horizontalPixelRatio);
|
||||
const by = Math.round(y1 * verticalPixelRatio);
|
||||
|
||||
context.strokeStyle = selected ? 'rgba(250,204,21,0.65)' : 'rgba(226,232,240,0.55)';
|
||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
context.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
const r = Math.max(2, Math.round((selected ? 4 : 3) * horizontalPixelRatio));
|
||||
context.fillStyle = selected ? 'rgba(250,204,21,0.95)' : 'rgba(147,197,253,0.95)';
|
||||
if (selected) {
|
||||
context.strokeStyle = 'rgba(15,23,42,0.85)';
|
||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
}
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (selected) context.stroke();
|
||||
context.beginPath();
|
||||
context.arc(bx, by, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (selected) context.stroke();
|
||||
}
|
||||
} finally {
|
||||
context.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class FibPaneView implements IPrimitivePaneView {
|
||||
private readonly _renderer: FibPaneRenderer;
|
||||
|
||||
constructor(getState: () => State) {
|
||||
this._renderer = new FibPaneRenderer(getState);
|
||||
}
|
||||
|
||||
renderer() {
|
||||
return this._renderer;
|
||||
}
|
||||
}
|
||||
|
||||
export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||
private _fib: FibRetracement | null = null;
|
||||
private _selected = false;
|
||||
private _opacity = 1;
|
||||
private readonly _paneView: FibPaneView;
|
||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||
|
||||
constructor() {
|
||||
this._paneView = new FibPaneView(() => ({
|
||||
fib: this._fib,
|
||||
series: this._series,
|
||||
chart: this._param?.chart ?? null,
|
||||
selected: this._selected,
|
||||
opacity: this._opacity,
|
||||
}));
|
||||
this._paneViews = [this._paneView];
|
||||
}
|
||||
|
||||
attached(param: SeriesAttachedParameter<Time>) {
|
||||
this._param = param;
|
||||
this._series = param.series as ISeriesApi<'Candlestick', Time>;
|
||||
}
|
||||
|
||||
detached() {
|
||||
this._param = null;
|
||||
this._series = null;
|
||||
}
|
||||
|
||||
paneViews() {
|
||||
return this._paneViews;
|
||||
}
|
||||
|
||||
setFib(next: FibRetracement | null) {
|
||||
this._fib = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
|
||||
setSelected(next: boolean) {
|
||||
this._selected = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
|
||||
setOpacity(next: number) {
|
||||
this._opacity = Number.isFinite(next) ? next : 1;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user