234 lines
7.6 KiB
TypeScript
234 lines
7.6 KiB
TypeScript
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();
|
|
}
|
|
}
|