chore: initial trade-visualizer import

This commit is contained in:
u1
2026-01-31 01:14:32 +01:00
commit 37210d9681
48 changed files with 9303 additions and 0 deletions

View 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();
}
}