Compare commits
14 Commits
snapshot/2
...
a9ccc0b00e
| Author | SHA1 | Date | |
|---|---|---|---|
| a9ccc0b00e | |||
| 9420c89f52 | |||
| 545e1abfaa | |||
| 759173b5be | |||
| 194d596284 | |||
| 444f427420 | |||
| af267ad6c9 | |||
| f3c4a999c3 | |||
| 1c8a6900e8 | |||
| abaee44835 | |||
| f57366fad2 | |||
| b0c7806cb6 | |||
| a12c86f1f8 | |||
| 6107c4e0ef |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,7 +1,14 @@
|
|||||||
|
# Secrets (never commit)
|
||||||
|
tokens/*
|
||||||
|
!tokens/*.example.json
|
||||||
|
!tokens/*.example.yml
|
||||||
|
!tokens/*.example.yaml
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
tokens/*.json
|
|
||||||
tokens/*.yml
|
# Local scratch / build output
|
||||||
tokens/*.yaml
|
_tmp/
|
||||||
|
apps/visualizer/dist/
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -12,6 +12,21 @@ npm ci
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dev z backendem na VPS (staging)
|
||||||
|
|
||||||
|
Najprościej: trzymaj `VITE_API_URL=/api` i podepnij Vite proxy do VPS (żeby nie bawić się w CORS i nie wkładać tokena do przeglądarki):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/visualizer
|
||||||
|
API_PROXY_TARGET=https://trade.mpabi.pl \
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite proxy’uje wtedy: `/api/*`, `/whoami`, `/auth/*`, `/logout` do VPS. Dodatkowo w dev usuwa `Secure` z `Set-Cookie`, żeby sesja działała na `http://localhost:5173`.
|
||||||
|
|
||||||
|
Jeśli staging jest dodatkowo chroniony basic auth (np. Traefik `basicAuth`), ustaw:
|
||||||
|
`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`).
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
||||||
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
||||||
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
||||||
|
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false);
|
||||||
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
||||||
const [bottomTab, setBottomTab] = useLocalStorageState<
|
const [bottomTab, setBottomTab] = useLocalStorageState<
|
||||||
'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||||
@@ -119,7 +120,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
||||||
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
||||||
|
|
||||||
const { candles, indicators, loading, error, refresh } = useChartData({
|
const { candles, indicators, meta, loading, error, refresh } = useChartData({
|
||||||
symbol,
|
symbol,
|
||||||
source: source.trim() ? source : undefined,
|
source: source.trim() ? source : undefined,
|
||||||
tf,
|
tf,
|
||||||
@@ -216,6 +217,8 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
}, [latest?.close, latest?.oracle, changePct]);
|
}, [latest?.close, latest?.oracle, changePct]);
|
||||||
|
|
||||||
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
|
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
|
||||||
|
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
|
||||||
|
const bucketSeconds = meta?.bucketSeconds ?? 60;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -281,9 +284,13 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
candles={candles}
|
candles={candles}
|
||||||
indicators={indicators}
|
indicators={indicators}
|
||||||
timeframe={tf}
|
timeframe={tf}
|
||||||
|
bucketSeconds={bucketSeconds}
|
||||||
|
seriesKey={seriesKey}
|
||||||
onTimeframeChange={setTf}
|
onTimeframeChange={setTf}
|
||||||
showIndicators={showIndicators}
|
showIndicators={showIndicators}
|
||||||
onToggleIndicators={() => setShowIndicators((v) => !v)}
|
onToggleIndicators={() => setShowIndicators((v) => !v)}
|
||||||
|
showBuild={showBuild}
|
||||||
|
onToggleBuild={() => setShowBuild((v) => !v)}
|
||||||
seriesLabel={seriesLabel}
|
seriesLabel={seriesLabel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,21 @@ export function IconEye(props: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconLayers(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Layers'} {...props}>
|
||||||
|
<path
|
||||||
|
d="M3.0 6.2L9.0 3.2L15.0 6.2L9.0 9.2L3.0 6.2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M3.0 9.2L9.0 12.2L15.0 9.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.85" />
|
||||||
|
<path d="M3.0 12.2L9.0 15.2L15.0 12.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.65" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IconTrash(props: IconProps) {
|
export function IconTrash(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Svg title={props.title ?? 'Delete'} {...props}>
|
<Svg title={props.title ?? 'Delete'} {...props}>
|
||||||
|
|||||||
215
apps/visualizer/src/features/chart/ChartLayersPanel.tsx
Normal file
215
apps/visualizer/src/features/chart/ChartLayersPanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { OverlayLayer } from './ChartPanel.types';
|
||||||
|
import { IconEye, IconLock, IconTrash } from './ChartIcons';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
layers: OverlayLayer[];
|
||||||
|
onRequestClose: () => void;
|
||||||
|
|
||||||
|
onToggleLayerVisible: (layerId: string) => void;
|
||||||
|
onToggleLayerLocked: (layerId: string) => void;
|
||||||
|
onSetLayerOpacity: (layerId: string, opacity: number) => void;
|
||||||
|
|
||||||
|
fibPresent: boolean;
|
||||||
|
fibSelected: boolean;
|
||||||
|
fibVisible: boolean;
|
||||||
|
fibLocked: boolean;
|
||||||
|
fibOpacity: number;
|
||||||
|
onSelectFib: () => void;
|
||||||
|
onToggleFibVisible: () => void;
|
||||||
|
onToggleFibLocked: () => void;
|
||||||
|
onSetFibOpacity: (opacity: number) => void;
|
||||||
|
onDeleteFib: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 1;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function opacityToPct(opacity: number): number {
|
||||||
|
return Math.round(clamp01(opacity) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctToOpacity(pct: number): number {
|
||||||
|
if (!Number.isFinite(pct)) return 1;
|
||||||
|
return clamp01(pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({
|
||||||
|
title,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['layersBtn', active ? 'layersBtn--active' : null].filter(Boolean).join(' ')}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpacitySlider({
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number) => void;
|
||||||
|
}) {
|
||||||
|
const pct = opacityToPct(value);
|
||||||
|
return (
|
||||||
|
<div className="layersOpacity">
|
||||||
|
<input
|
||||||
|
className="layersOpacity__range"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={pct}
|
||||||
|
onChange={(e) => onChange(pctToOpacity(Number(e.target.value)))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="layersOpacity__pct">{pct}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChartLayersPanel({
|
||||||
|
open,
|
||||||
|
layers,
|
||||||
|
onRequestClose,
|
||||||
|
onToggleLayerVisible,
|
||||||
|
onToggleLayerLocked,
|
||||||
|
onSetLayerOpacity,
|
||||||
|
fibPresent,
|
||||||
|
fibSelected,
|
||||||
|
fibVisible,
|
||||||
|
fibLocked,
|
||||||
|
fibOpacity,
|
||||||
|
onSelectFib,
|
||||||
|
onToggleFibVisible,
|
||||||
|
onToggleFibLocked,
|
||||||
|
onSetFibOpacity,
|
||||||
|
onDeleteFib,
|
||||||
|
}: Props) {
|
||||||
|
const drawingsLayer = useMemo(() => layers.find((l) => l.id === 'drawings'), [layers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={['chartLayersBackdrop', open ? 'chartLayersBackdrop--open' : null].filter(Boolean).join(' ')}
|
||||||
|
onClick={open ? onRequestClose : undefined}
|
||||||
|
/>
|
||||||
|
<div className={['chartLayersPanel', open ? 'chartLayersPanel--open' : null].filter(Boolean).join(' ')}>
|
||||||
|
<div className="chartLayersPanel__head">
|
||||||
|
<div className="chartLayersPanel__title">Layers</div>
|
||||||
|
<button type="button" className="chartLayersPanel__close" onClick={onRequestClose} aria-label="Close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chartLayersTable">
|
||||||
|
<div className="chartLayersRow chartLayersRow--head">
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon" title="Visible">
|
||||||
|
<IconEye />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon" title="Lock">
|
||||||
|
<IconLock />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">Name</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">Opacity</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drawingsLayer ? (
|
||||||
|
<div className="chartLayersRow chartLayersRow--layer">
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton
|
||||||
|
title="Toggle visible"
|
||||||
|
active={drawingsLayer.visible}
|
||||||
|
onClick={() => onToggleLayerVisible(drawingsLayer.id)}
|
||||||
|
>
|
||||||
|
<IconEye />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton
|
||||||
|
title="Toggle lock"
|
||||||
|
active={drawingsLayer.locked}
|
||||||
|
onClick={() => onToggleLayerLocked(drawingsLayer.id)}
|
||||||
|
>
|
||||||
|
<IconLock />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">
|
||||||
|
<div className="layersName layersName--layer">
|
||||||
|
{drawingsLayer.name}
|
||||||
|
<span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">
|
||||||
|
<OpacitySlider value={drawingsLayer.opacity} onChange={(next) => onSetLayerOpacity(drawingsLayer.id, next)} />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{drawingsLayer && fibPresent ? (
|
||||||
|
<div
|
||||||
|
className={['chartLayersRow', 'chartLayersRow--object', fibSelected ? 'chartLayersRow--selected' : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={onSelectFib}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle visible" active={fibVisible} onClick={onToggleFibVisible}>
|
||||||
|
<IconEye />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle lock" active={fibLocked} onClick={onToggleFibLocked}>
|
||||||
|
<IconLock />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">
|
||||||
|
<div className="layersName layersName--object">Fib Retracement</div>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">
|
||||||
|
<OpacitySlider value={fibOpacity} onChange={onSetFibOpacity} />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions">
|
||||||
|
<IconButton title="Delete fib" onClick={onDeleteFib}>
|
||||||
|
<IconTrash />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,29 +1,58 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||||
import Card from '../../ui/Card';
|
import Card from '../../ui/Card';
|
||||||
|
import ChartLayersPanel from './ChartLayersPanel';
|
||||||
import ChartSideToolbar from './ChartSideToolbar';
|
import ChartSideToolbar from './ChartSideToolbar';
|
||||||
import ChartToolbar from './ChartToolbar';
|
import ChartToolbar from './ChartToolbar';
|
||||||
import TradingChart from './TradingChart';
|
import TradingChart from './TradingChart';
|
||||||
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
||||||
import type { IChartApi } from 'lightweight-charts';
|
import type { IChartApi } from 'lightweight-charts';
|
||||||
|
import type { OverlayLayer } from './ChartPanel.types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
candles: Candle[];
|
candles: Candle[];
|
||||||
indicators: ChartIndicators;
|
indicators: ChartIndicators;
|
||||||
timeframe: string;
|
timeframe: string;
|
||||||
|
bucketSeconds: number;
|
||||||
|
seriesKey: string;
|
||||||
onTimeframeChange: (tf: string) => void;
|
onTimeframeChange: (tf: string) => void;
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
onToggleIndicators: () => void;
|
onToggleIndicators: () => void;
|
||||||
|
showBuild: boolean;
|
||||||
|
onToggleBuild: () => void;
|
||||||
seriesLabel: string;
|
seriesLabel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FibDragMode = 'move' | 'edit-b';
|
||||||
|
|
||||||
|
type FibDrag = {
|
||||||
|
pointerId: number;
|
||||||
|
mode: FibDragMode;
|
||||||
|
startClientX: number;
|
||||||
|
startClientY: number;
|
||||||
|
start: FibAnchor;
|
||||||
|
origin: FibRetracement;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isEditableTarget(t: EventTarget | null): boolean {
|
||||||
|
if (!(t instanceof HTMLElement)) return false;
|
||||||
|
const tag = t.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||||
|
return t.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChartPanel({
|
export default function ChartPanel({
|
||||||
candles,
|
candles,
|
||||||
indicators,
|
indicators,
|
||||||
timeframe,
|
timeframe,
|
||||||
|
bucketSeconds,
|
||||||
|
seriesKey,
|
||||||
onTimeframeChange,
|
onTimeframeChange,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
onToggleIndicators,
|
onToggleIndicators,
|
||||||
|
showBuild,
|
||||||
|
onToggleBuild,
|
||||||
seriesLabel,
|
seriesLabel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
@@ -31,11 +60,27 @@ export default function ChartPanel({
|
|||||||
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||||
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||||
|
const [layers, setLayers] = useState<OverlayLayer[]>([
|
||||||
|
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
|
||||||
|
]);
|
||||||
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
|
const [fibVisible, setFibVisible] = useState(true);
|
||||||
|
const [fibLocked, setFibLocked] = useState(false);
|
||||||
|
const [fibOpacity, setFibOpacity] = useState(1);
|
||||||
|
const [selectedOverlayId, setSelectedOverlayId] = useState<string | null>(null);
|
||||||
|
const [priceAutoScale, setPriceAutoScale] = useState(true);
|
||||||
|
|
||||||
const chartApiRef = useRef<IChartApi | null>(null);
|
const chartApiRef = useRef<IChartApi | null>(null);
|
||||||
const activeToolRef = useRef(activeTool);
|
const activeToolRef = useRef(activeTool);
|
||||||
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
||||||
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
||||||
|
const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
const spaceDownRef = useRef<boolean>(false);
|
||||||
|
const dragRef = useRef<FibDrag | null>(null);
|
||||||
|
const selectPointerRef = useRef<number | null>(null);
|
||||||
|
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
|
||||||
|
const fibRef = useRef<FibRetracement | null>(fib);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFullscreen) return;
|
if (!isFullscreen) return;
|
||||||
@@ -67,16 +112,64 @@ export default function ChartPanel({
|
|||||||
fibStartRef.current = fibStart;
|
fibStartRef.current = fibStart;
|
||||||
}, [fibStart]);
|
}, [fibStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedOverlayIdRef.current = selectedOverlayId;
|
||||||
|
}, [selectedOverlayId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fibRef.current = fib;
|
||||||
|
}, [fib]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Escape') return;
|
if (isEditableTarget(e.target)) return;
|
||||||
if (activeToolRef.current !== 'fib-retracement') return;
|
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
spaceDownRef.current = true;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (dragRef.current) {
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
setFibDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeToolRef.current === 'fib-retracement') {
|
||||||
setFibStart(null);
|
setFibStart(null);
|
||||||
setFibDraft(null);
|
setFibDraft(null);
|
||||||
setActiveTool('cursor');
|
setActiveTool('cursor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedOverlayIdRef.current) setSelectedOverlayId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (selectedOverlayIdRef.current === 'fib') {
|
||||||
|
clearFib();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
spaceDownRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
window.addEventListener('keyup', onKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
window.removeEventListener('keyup', onKeyUp);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,6 +191,81 @@ export default function ChartPanel({
|
|||||||
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
|
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 1;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
|
||||||
|
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFib() {
|
||||||
|
setFib(null);
|
||||||
|
setFibStart(null);
|
||||||
|
setFibDraft(null);
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
setSelectedOverlayId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFibFromDrag(drag: FibDrag, pointer: FibAnchor): FibRetracement {
|
||||||
|
if (drag.mode === 'edit-b') return { a: drag.origin.a, b: pointer };
|
||||||
|
const deltaLogical = pointer.logical - drag.start.logical;
|
||||||
|
const deltaPrice = pointer.price - drag.start.price;
|
||||||
|
return {
|
||||||
|
a: { logical: drag.origin.a.logical + deltaLogical, price: drag.origin.a.price + deltaPrice },
|
||||||
|
b: { logical: drag.origin.b.logical + deltaLogical, price: drag.origin.b.price + deltaPrice },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFrame() {
|
||||||
|
if (rafRef.current != null) return;
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null;
|
||||||
|
|
||||||
|
const drag = dragRef.current;
|
||||||
|
const pendingDrag = pendingDragRef.current;
|
||||||
|
if (drag && pendingDrag) {
|
||||||
|
if (!drag.moved) {
|
||||||
|
const dx = pendingDrag.clientX - drag.startClientX;
|
||||||
|
const dy = pendingDrag.clientY - drag.startClientY;
|
||||||
|
if (dx * dx + dy * dy >= 16) drag.moved = true; // ~4px threshold
|
||||||
|
}
|
||||||
|
if (drag.moved) {
|
||||||
|
setFibDraft(computeFibFromDrag(drag, pendingDrag.anchor));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = pendingMoveRef.current;
|
||||||
|
if (!pointer) return;
|
||||||
|
if (activeToolRef.current !== 'fib-retracement') return;
|
||||||
|
|
||||||
|
const start2 = fibStartRef.current;
|
||||||
|
if (!start2) return;
|
||||||
|
setFibDraft({ a: start2, b: pointer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawingsLayer =
|
||||||
|
layers.find((l) => l.id === 'drawings') ?? { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 };
|
||||||
|
const fibEffectiveVisible = fibVisible && drawingsLayer.visible;
|
||||||
|
const fibEffectiveOpacity = fibOpacity * drawingsLayer.opacity;
|
||||||
|
const fibEffectiveLocked = fibLocked || drawingsLayer.locked;
|
||||||
|
const fibSelected = selectedOverlayId === 'fib';
|
||||||
|
const fibRenderable = fibEffectiveVisible ? (fibDraft ?? fib) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOverlayId !== 'fib') return;
|
||||||
|
if (!fib) {
|
||||||
|
setSelectedOverlayId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fibEffectiveVisible) setSelectedOverlayId(null);
|
||||||
|
}, [fib, fibEffectiveVisible, selectedOverlayId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
||||||
@@ -108,6 +276,10 @@ export default function ChartPanel({
|
|||||||
onTimeframeChange={onTimeframeChange}
|
onTimeframeChange={onTimeframeChange}
|
||||||
showIndicators={showIndicators}
|
showIndicators={showIndicators}
|
||||||
onToggleIndicators={onToggleIndicators}
|
onToggleIndicators={onToggleIndicators}
|
||||||
|
showBuild={showBuild}
|
||||||
|
onToggleBuild={onToggleBuild}
|
||||||
|
priceAutoScale={priceAutoScale}
|
||||||
|
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||||
seriesLabel={seriesLabel}
|
seriesLabel={seriesLabel}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
||||||
@@ -118,15 +290,13 @@ export default function ChartPanel({
|
|||||||
timeframe={timeframe}
|
timeframe={timeframe}
|
||||||
activeTool={activeTool}
|
activeTool={activeTool}
|
||||||
hasFib={fib != null || fibDraft != null}
|
hasFib={fib != null || fibDraft != null}
|
||||||
|
isLayersOpen={layersOpen}
|
||||||
onToolChange={setActiveTool}
|
onToolChange={setActiveTool}
|
||||||
|
onToggleLayers={() => setLayersOpen((v) => !v)}
|
||||||
onZoomIn={() => zoomTime(0.8)}
|
onZoomIn={() => zoomTime(0.8)}
|
||||||
onZoomOut={() => zoomTime(1.25)}
|
onZoomOut={() => zoomTime(1.25)}
|
||||||
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
|
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
|
||||||
onClearFib={() => {
|
onClearFib={clearFib}
|
||||||
setFib(null);
|
|
||||||
setFibStart(null);
|
|
||||||
setFibDraft(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="chartCard__chart">
|
<div className="chartCard__chart">
|
||||||
<TradingChart
|
<TradingChart
|
||||||
@@ -136,12 +306,18 @@ export default function ChartPanel({
|
|||||||
ema20={indicators.ema20}
|
ema20={indicators.ema20}
|
||||||
bb20={indicators.bb20}
|
bb20={indicators.bb20}
|
||||||
showIndicators={showIndicators}
|
showIndicators={showIndicators}
|
||||||
fib={fibDraft ?? fib}
|
showBuild={showBuild}
|
||||||
|
bucketSeconds={bucketSeconds}
|
||||||
|
seriesKey={seriesKey}
|
||||||
|
fib={fibRenderable}
|
||||||
|
fibOpacity={fibEffectiveOpacity}
|
||||||
|
fibSelected={fibSelected}
|
||||||
|
priceAutoScale={priceAutoScale}
|
||||||
onReady={({ chart }) => {
|
onReady={({ chart }) => {
|
||||||
chartApiRef.current = chart;
|
chartApiRef.current = chart;
|
||||||
}}
|
}}
|
||||||
onChartClick={(p) => {
|
onChartClick={(p) => {
|
||||||
if (activeTool !== 'fib-retracement') return;
|
if (activeTool === 'fib-retracement') {
|
||||||
if (!fibStartRef.current) {
|
if (!fibStartRef.current) {
|
||||||
fibStartRef.current = p;
|
fibStartRef.current = p;
|
||||||
setFibStart(p);
|
setFibStart(p);
|
||||||
@@ -153,21 +329,103 @@ export default function ChartPanel({
|
|||||||
fibStartRef.current = null;
|
fibStartRef.current = null;
|
||||||
setFibDraft(null);
|
setFibDraft(null);
|
||||||
setActiveTool('cursor');
|
setActiveTool('cursor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.target === 'chart') setSelectedOverlayId(null);
|
||||||
}}
|
}}
|
||||||
onChartCrosshairMove={(p) => {
|
onChartCrosshairMove={(p) => {
|
||||||
if (activeToolRef.current !== 'fib-retracement') return;
|
|
||||||
const start = fibStartRef.current;
|
|
||||||
if (!start) return;
|
|
||||||
pendingMoveRef.current = p;
|
pendingMoveRef.current = p;
|
||||||
if (rafRef.current != null) return;
|
scheduleFrame();
|
||||||
rafRef.current = window.requestAnimationFrame(() => {
|
|
||||||
rafRef.current = null;
|
|
||||||
const move = pendingMoveRef.current;
|
|
||||||
const start2 = fibStartRef.current;
|
|
||||||
if (!move || !start2) return;
|
|
||||||
setFibDraft({ a: start2, b: move });
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
|
onPointerEvent={({ type, logical, price, target, event }) => {
|
||||||
|
const pointer: FibAnchor = { logical, price };
|
||||||
|
|
||||||
|
if (type === 'pointerdown') {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
if (spaceDownRef.current) return;
|
||||||
|
if (activeToolRef.current !== 'cursor') return;
|
||||||
|
if (target !== 'fib') return;
|
||||||
|
if (!fibRef.current) return;
|
||||||
|
if (!fibEffectiveVisible) return;
|
||||||
|
|
||||||
|
if (selectedOverlayIdRef.current !== 'fib') {
|
||||||
|
setSelectedOverlayId('fib');
|
||||||
|
selectPointerRef.current = event.pointerId;
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fibEffectiveLocked) {
|
||||||
|
selectPointerRef.current = event.pointerId;
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
mode: event.ctrlKey ? 'edit-b' : 'move',
|
||||||
|
startClientX: event.clientX,
|
||||||
|
startClientY: event.clientY,
|
||||||
|
start: pointer,
|
||||||
|
origin: fibRef.current,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
|
||||||
|
setFibDraft(fibRef.current);
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (drag && drag.pointerId === event.pointerId) {
|
||||||
|
if (type === 'pointermove') {
|
||||||
|
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
|
||||||
|
scheduleFrame();
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
if (type === 'pointerup' || type === 'pointercancel') {
|
||||||
|
if (drag.moved) setFib(computeFibFromDrag(drag, pointer));
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
setFibDraft(null);
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectPointerRef.current != null && selectPointerRef.current === event.pointerId) {
|
||||||
|
if (type === 'pointermove') return { consume: true };
|
||||||
|
if (type === 'pointerup' || type === 'pointercancel') {
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChartLayersPanel
|
||||||
|
open={layersOpen}
|
||||||
|
layers={layers}
|
||||||
|
onRequestClose={() => setLayersOpen(false)}
|
||||||
|
onToggleLayerVisible={(layerId) => {
|
||||||
|
const layer = layers.find((l) => l.id === layerId);
|
||||||
|
if (!layer) return;
|
||||||
|
updateLayer(layerId, { visible: !layer.visible });
|
||||||
|
}}
|
||||||
|
onToggleLayerLocked={(layerId) => {
|
||||||
|
const layer = layers.find((l) => l.id === layerId);
|
||||||
|
if (!layer) return;
|
||||||
|
updateLayer(layerId, { locked: !layer.locked });
|
||||||
|
}}
|
||||||
|
onSetLayerOpacity={(layerId, opacity) => updateLayer(layerId, { opacity: clamp01(opacity) })}
|
||||||
|
fibPresent={fib != null}
|
||||||
|
fibSelected={fibSelected}
|
||||||
|
fibVisible={fibVisible}
|
||||||
|
fibLocked={fibLocked}
|
||||||
|
fibOpacity={fibOpacity}
|
||||||
|
onSelectFib={() => setSelectedOverlayId('fib')}
|
||||||
|
onToggleFibVisible={() => setFibVisible((v) => !v)}
|
||||||
|
onToggleFibLocked={() => setFibLocked((v) => !v)}
|
||||||
|
onSetFibOpacity={(opacity) => setFibOpacity(clamp01(opacity))}
|
||||||
|
onDeleteFib={clearFib}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
apps/visualizer/src/features/chart/ChartPanel.types.ts
Normal file
8
apps/visualizer/src/features/chart/ChartPanel.types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type OverlayLayer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
opacity: number; // 0..1
|
||||||
|
};
|
||||||
|
|
||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
IconBrush,
|
IconBrush,
|
||||||
IconCrosshair,
|
IconCrosshair,
|
||||||
IconCursor,
|
IconCursor,
|
||||||
IconEye,
|
|
||||||
IconFib,
|
IconFib,
|
||||||
|
IconLayers,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconRuler,
|
IconRuler,
|
||||||
@@ -24,7 +24,9 @@ type Props = {
|
|||||||
timeframe: string;
|
timeframe: string;
|
||||||
activeTool: ActiveTool;
|
activeTool: ActiveTool;
|
||||||
hasFib: boolean;
|
hasFib: boolean;
|
||||||
|
isLayersOpen: boolean;
|
||||||
onToolChange: (tool: ActiveTool) => void;
|
onToolChange: (tool: ActiveTool) => void;
|
||||||
|
onToggleLayers: () => void;
|
||||||
onZoomIn: () => void;
|
onZoomIn: () => void;
|
||||||
onZoomOut: () => void;
|
onZoomOut: () => void;
|
||||||
onResetView: () => void;
|
onResetView: () => void;
|
||||||
@@ -35,7 +37,9 @@ export default function ChartSideToolbar({
|
|||||||
timeframe,
|
timeframe,
|
||||||
activeTool,
|
activeTool,
|
||||||
hasFib,
|
hasFib,
|
||||||
|
isLayersOpen,
|
||||||
onToolChange,
|
onToolChange,
|
||||||
|
onToggleLayers,
|
||||||
onZoomIn,
|
onZoomIn,
|
||||||
onZoomOut,
|
onZoomOut,
|
||||||
onResetView,
|
onResetView,
|
||||||
@@ -195,9 +199,15 @@ export default function ChartSideToolbar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="chartToolBtn" title="Visibility" aria-label="Visibility" disabled>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['chartToolBtn', isLayersOpen ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||||
|
title="Layers"
|
||||||
|
aria-label="Layers"
|
||||||
|
onClick={onToggleLayers}
|
||||||
|
>
|
||||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
<IconEye />
|
<IconLayers />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ type Props = {
|
|||||||
onTimeframeChange: (tf: string) => void;
|
onTimeframeChange: (tf: string) => void;
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
onToggleIndicators: () => void;
|
onToggleIndicators: () => void;
|
||||||
|
showBuild: boolean;
|
||||||
|
onToggleBuild: () => void;
|
||||||
|
priceAutoScale: boolean;
|
||||||
|
onTogglePriceAutoScale: () => void;
|
||||||
seriesLabel: string;
|
seriesLabel: string;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
@@ -17,6 +21,10 @@ export default function ChartToolbar({
|
|||||||
onTimeframeChange,
|
onTimeframeChange,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
onToggleIndicators,
|
onToggleIndicators,
|
||||||
|
showBuild,
|
||||||
|
onToggleBuild,
|
||||||
|
priceAutoScale,
|
||||||
|
onTogglePriceAutoScale,
|
||||||
seriesLabel,
|
seriesLabel,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
@@ -41,6 +49,12 @@ export default function ChartToolbar({
|
|||||||
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
|
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
|
||||||
Indicators
|
Indicators
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant={showBuild ? 'primary' : 'ghost'} onClick={onToggleBuild} type="button">
|
||||||
|
Build
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button">
|
||||||
|
Auto Scale
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
|
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
|
||||||
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ type State = {
|
|||||||
fib: FibRetracement | null;
|
fib: FibRetracement | null;
|
||||||
series: ISeriesApi<'Candlestick', Time> | null;
|
series: ISeriesApi<'Candlestick', Time> | null;
|
||||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||||
|
selected: boolean;
|
||||||
|
opacity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||||
@@ -64,8 +66,10 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
draw(target: any) {
|
draw(target: any) {
|
||||||
const { fib, series, chart } = this._getState();
|
const { fib, series, chart, selected, opacity } = this._getState();
|
||||||
if (!fib || !series || !chart) return;
|
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 x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
||||||
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
||||||
@@ -78,6 +82,9 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
|||||||
const delta = p1 - p0;
|
const delta = p1 - p0;
|
||||||
|
|
||||||
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||||
|
context.save();
|
||||||
|
context.globalAlpha *= clampedOpacity;
|
||||||
|
try {
|
||||||
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
||||||
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * 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)));
|
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
|
||||||
@@ -132,7 +139,7 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
|||||||
const bx = Math.round(x2 * horizontalPixelRatio);
|
const bx = Math.round(x2 * horizontalPixelRatio);
|
||||||
const by = Math.round(y1 * verticalPixelRatio);
|
const by = Math.round(y1 * verticalPixelRatio);
|
||||||
|
|
||||||
context.strokeStyle = 'rgba(226,232,240,0.55)';
|
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.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.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
@@ -141,14 +148,23 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
|||||||
context.stroke();
|
context.stroke();
|
||||||
context.setLineDash([]);
|
context.setLineDash([]);
|
||||||
|
|
||||||
const r = Math.max(2, Math.round(3 * horizontalPixelRatio));
|
const r = Math.max(2, Math.round((selected ? 4 : 3) * horizontalPixelRatio));
|
||||||
context.fillStyle = 'rgba(147,197,253,0.95)';
|
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.beginPath();
|
||||||
context.arc(ax, ay, r, 0, Math.PI * 2);
|
context.arc(ax, ay, r, 0, Math.PI * 2);
|
||||||
context.fill();
|
context.fill();
|
||||||
|
if (selected) context.stroke();
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(bx, by, r, 0, Math.PI * 2);
|
context.arc(bx, by, r, 0, Math.PI * 2);
|
||||||
context.fill();
|
context.fill();
|
||||||
|
if (selected) context.stroke();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
context.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -170,11 +186,19 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
|||||||
private _param: SeriesAttachedParameter<Time> | null = null;
|
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||||
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||||
private _fib: FibRetracement | null = null;
|
private _fib: FibRetracement | null = null;
|
||||||
|
private _selected = false;
|
||||||
|
private _opacity = 1;
|
||||||
private readonly _paneView: FibPaneView;
|
private readonly _paneView: FibPaneView;
|
||||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._paneView = new FibPaneView(() => ({ fib: this._fib, series: this._series, chart: this._param?.chart ?? null }));
|
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];
|
this._paneViews = [this._paneView];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,4 +220,14 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
|||||||
this._fib = next;
|
this._fib = next;
|
||||||
this._param?.requestUpdate();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,18 +25,66 @@ type Props = {
|
|||||||
ema20?: SeriesPoint[];
|
ema20?: SeriesPoint[];
|
||||||
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
|
showBuild: boolean;
|
||||||
|
bucketSeconds: number;
|
||||||
|
seriesKey: string;
|
||||||
fib?: FibRetracement | null;
|
fib?: FibRetracement | null;
|
||||||
|
fibOpacity?: number;
|
||||||
|
fibSelected?: boolean;
|
||||||
|
priceAutoScale?: boolean;
|
||||||
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
|
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
|
||||||
onChartClick?: (p: FibAnchor) => void;
|
onChartClick?: (p: FibAnchor & { target: 'chart' | 'fib' }) => void;
|
||||||
onChartCrosshairMove?: (p: FibAnchor) => void;
|
onChartCrosshairMove?: (p: FibAnchor) => void;
|
||||||
|
onPointerEvent?: (p: {
|
||||||
|
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel';
|
||||||
|
logical: number;
|
||||||
|
price: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
target: 'chart' | 'fib';
|
||||||
|
event: PointerEvent;
|
||||||
|
}) => { consume?: boolean; capturePointer?: boolean } | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LinePoint = LineData | WhitespaceData;
|
type LinePoint = LineData | WhitespaceData;
|
||||||
|
type BuildSample = { t: number; v: number };
|
||||||
|
|
||||||
function toTime(t: number): UTCTimestamp {
|
function toTime(t: number): UTCTimestamp {
|
||||||
return t as UTCTimestamp;
|
return t as UTCTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBucketSeconds(bucketSeconds: number, candles: Candle[]): number {
|
||||||
|
if (Number.isFinite(bucketSeconds) && bucketSeconds > 0) return bucketSeconds;
|
||||||
|
if (candles.length >= 2) {
|
||||||
|
const last = candles[candles.length - 1]?.time;
|
||||||
|
const prev = candles[candles.length - 2]?.time;
|
||||||
|
const delta = typeof last === 'number' && typeof prev === 'number' ? last - prev : 0;
|
||||||
|
if (Number.isFinite(delta) && delta > 0) return delta;
|
||||||
|
}
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function samplePriceFromCandles(candles: Candle[]): number | null {
|
||||||
|
for (let i = candles.length - 1; i >= 0; i -= 1) {
|
||||||
|
const close = candles[i]?.close;
|
||||||
|
if (typeof close !== 'number') continue;
|
||||||
|
if (!Number.isFinite(close)) continue;
|
||||||
|
if (close === 0) continue;
|
||||||
|
return close;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceFormatForSample(price: number) {
|
||||||
|
const abs = Math.abs(price);
|
||||||
|
if (!Number.isFinite(abs) || abs === 0) return { type: 'price' as const, precision: 2, minMove: 0.01 };
|
||||||
|
if (abs >= 1000) return { type: 'price' as const, precision: 0, minMove: 1 };
|
||||||
|
if (abs >= 1) return { type: 'price' as const, precision: 2, minMove: 0.01 };
|
||||||
|
const exponent = Math.floor(Math.log10(abs)); // negative for abs < 1
|
||||||
|
const precision = Math.min(8, Math.max(4, -exponent + 3));
|
||||||
|
return { type: 'price' as const, precision, minMove: Math.pow(10, -precision) };
|
||||||
|
}
|
||||||
|
|
||||||
function toCandleData(candles: Candle[]): CandlestickData[] {
|
function toCandleData(candles: Candle[]): CandlestickData[] {
|
||||||
return candles.map((c) => ({
|
return candles.map((c) => ({
|
||||||
time: toTime(c.time),
|
time: toTime(c.time),
|
||||||
@@ -49,11 +97,10 @@ function toCandleData(candles: Candle[]): CandlestickData[] {
|
|||||||
|
|
||||||
function toVolumeData(candles: Candle[]): HistogramData[] {
|
function toVolumeData(candles: Candle[]): HistogramData[] {
|
||||||
return candles.map((c) => {
|
return candles.map((c) => {
|
||||||
const up = c.close >= c.open;
|
|
||||||
return {
|
return {
|
||||||
time: toTime(c.time),
|
time: toTime(c.time),
|
||||||
value: c.volume ?? 0,
|
value: c.volume ?? 0,
|
||||||
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)',
|
color: 'rgba(148,163,184,0.22)',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -70,20 +117,37 @@ export default function TradingChart({
|
|||||||
ema20,
|
ema20,
|
||||||
bb20,
|
bb20,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
|
showBuild,
|
||||||
|
bucketSeconds,
|
||||||
|
seriesKey,
|
||||||
fib,
|
fib,
|
||||||
|
fibOpacity = 1,
|
||||||
|
fibSelected = false,
|
||||||
|
priceAutoScale = true,
|
||||||
onReady,
|
onReady,
|
||||||
onChartClick,
|
onChartClick,
|
||||||
onChartCrosshairMove,
|
onChartCrosshairMove,
|
||||||
|
onPointerEvent,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
|
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
|
||||||
|
const fibRef = useRef<FibRetracement | null>(fib ?? null);
|
||||||
|
const fibOpacityRef = useRef<number>(fibOpacity);
|
||||||
|
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||||
|
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||||
const onReadyRef = useRef<Props['onReady']>(onReady);
|
const onReadyRef = useRef<Props['onReady']>(onReady);
|
||||||
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
||||||
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
||||||
|
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
|
||||||
|
const capturedOverlayPointerRef = useRef<number | null>(null);
|
||||||
|
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
|
||||||
|
const buildKeyRef = useRef<string | null>(null);
|
||||||
|
const lastBuildCandleStartRef = useRef<number | null>(null);
|
||||||
const seriesRef = useRef<{
|
const seriesRef = useRef<{
|
||||||
candles?: ISeriesApi<'Candlestick'>;
|
candles?: ISeriesApi<'Candlestick'>;
|
||||||
volume?: ISeriesApi<'Histogram'>;
|
volume?: ISeriesApi<'Histogram'>;
|
||||||
|
build?: ISeriesApi<'Line'>;
|
||||||
oracle?: ISeriesApi<'Line'>;
|
oracle?: ISeriesApi<'Line'>;
|
||||||
sma20?: ISeriesApi<'Line'>;
|
sma20?: ISeriesApi<'Line'>;
|
||||||
ema20?: ISeriesApi<'Line'>;
|
ema20?: ISeriesApi<'Line'>;
|
||||||
@@ -100,6 +164,10 @@ export default function TradingChart({
|
|||||||
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
|
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
|
||||||
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
|
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
|
||||||
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
|
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
|
||||||
|
const priceFormat = useMemo(() => {
|
||||||
|
const sample = samplePriceFromCandles(candles);
|
||||||
|
return sample == null ? { type: 'price' as const, precision: 2, minMove: 0.01 } : priceFormatForSample(sample);
|
||||||
|
}, [candles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onReadyRef.current = onReady;
|
onReadyRef.current = onReady;
|
||||||
@@ -113,6 +181,23 @@ export default function TradingChart({
|
|||||||
onChartCrosshairMoveRef.current = onChartCrosshairMove;
|
onChartCrosshairMoveRef.current = onChartCrosshairMove;
|
||||||
}, [onChartCrosshairMove]);
|
}, [onChartCrosshairMove]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPointerEventRef.current = onPointerEvent;
|
||||||
|
}, [onPointerEvent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fibRef.current = fib ?? null;
|
||||||
|
}, [fib]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fibOpacityRef.current = fibOpacity;
|
||||||
|
fibPrimitiveRef.current?.setOpacity(fibOpacity);
|
||||||
|
}, [fibOpacity]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
priceAutoScaleRef.current = priceAutoScale;
|
||||||
|
}, [priceAutoScale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
if (chartRef.current) return;
|
if (chartRef.current) return;
|
||||||
@@ -146,12 +231,14 @@ export default function TradingChart({
|
|||||||
borderVisible: false,
|
borderVisible: false,
|
||||||
wickUpColor: '#22c55e',
|
wickUpColor: '#22c55e',
|
||||||
wickDownColor: '#ef4444',
|
wickDownColor: '#ef4444',
|
||||||
|
priceFormat,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fibPrimitive = new FibRetracementPrimitive();
|
const fibPrimitive = new FibRetracementPrimitive();
|
||||||
candleSeries.attachPrimitive(fibPrimitive);
|
candleSeries.attachPrimitive(fibPrimitive);
|
||||||
fibPrimitiveRef.current = fibPrimitive;
|
fibPrimitiveRef.current = fibPrimitive;
|
||||||
fibPrimitive.setFib(fib ?? null);
|
fibPrimitive.setFib(fib ?? null);
|
||||||
|
fibPrimitive.setOpacity(fibOpacityRef.current);
|
||||||
|
|
||||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||||
priceFormat: { type: 'volume' },
|
priceFormat: { type: 'volume' },
|
||||||
@@ -159,28 +246,46 @@ export default function TradingChart({
|
|||||||
color: 'rgba(255,255,255,0.15)',
|
color: 'rgba(255,255,255,0.15)',
|
||||||
});
|
});
|
||||||
volumeSeries.priceScale().applyOptions({
|
volumeSeries.priceScale().applyOptions({
|
||||||
scaleMargins: { top: 0.82, bottom: 0 },
|
scaleMargins: { top: 0.88, bottom: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSeries = chart.addSeries(LineSeries, {
|
||||||
|
color: '#60a5fa',
|
||||||
|
lineWidth: 2,
|
||||||
|
priceFormat,
|
||||||
|
priceScaleId: 'build',
|
||||||
|
lastValueVisible: false,
|
||||||
|
priceLineVisible: false,
|
||||||
|
crosshairMarkerVisible: false,
|
||||||
|
});
|
||||||
|
buildSeries.priceScale().applyOptions({
|
||||||
|
scaleMargins: { top: 0.72, bottom: 0.12 },
|
||||||
|
visible: false,
|
||||||
|
borderVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const oracleSeries = chart.addSeries(LineSeries, {
|
const oracleSeries = chart.addSeries(LineSeries, {
|
||||||
color: 'rgba(251,146,60,0.9)',
|
color: 'rgba(251,146,60,0.9)',
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: LineStyle.Dotted,
|
lineStyle: LineStyle.Dotted,
|
||||||
|
priceFormat,
|
||||||
});
|
});
|
||||||
|
|
||||||
const smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1 });
|
const smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1, priceFormat });
|
||||||
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1 });
|
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1, priceFormat });
|
||||||
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1 });
|
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1, priceFormat });
|
||||||
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1 });
|
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1, priceFormat });
|
||||||
const bbMidSeries = chart.addSeries(LineSeries, {
|
const bbMidSeries = chart.addSeries(LineSeries, {
|
||||||
color: 'rgba(250,204,21,0.35)',
|
color: 'rgba(250,204,21,0.35)',
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: LineStyle.Dashed,
|
lineStyle: LineStyle.Dashed,
|
||||||
|
priceFormat,
|
||||||
});
|
});
|
||||||
|
|
||||||
seriesRef.current = {
|
seriesRef.current = {
|
||||||
candles: candleSeries,
|
candles: candleSeries,
|
||||||
volume: volumeSeries,
|
volume: volumeSeries,
|
||||||
|
build: buildSeries,
|
||||||
oracle: oracleSeries,
|
oracle: oracleSeries,
|
||||||
sma20: smaSeries,
|
sma20: smaSeries,
|
||||||
ema20: emaSeries,
|
ema20: emaSeries,
|
||||||
@@ -197,7 +302,35 @@ export default function TradingChart({
|
|||||||
if (logical == null) return;
|
if (logical == null) return;
|
||||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||||
if (price == null) return;
|
if (price == null) return;
|
||||||
onChartClickRef.current?.({ logical: Number(logical), price: Number(price) });
|
|
||||||
|
const currentFib = fibRef.current;
|
||||||
|
let target: 'chart' | 'fib' = 'chart';
|
||||||
|
if (currentFib) {
|
||||||
|
const x1 = chart.timeScale().logicalToCoordinate(currentFib.a.logical as any);
|
||||||
|
const x2 = chart.timeScale().logicalToCoordinate(currentFib.b.logical as any);
|
||||||
|
if (x1 != null && x2 != null) {
|
||||||
|
const tol = 6;
|
||||||
|
const left = Math.min(x1, x2) - tol;
|
||||||
|
const right = Math.max(x1, x2) + tol;
|
||||||
|
const p0 = currentFib.a.price;
|
||||||
|
const delta = currentFib.b.price - p0;
|
||||||
|
const yRatioMin = 0;
|
||||||
|
const yRatioMax = 4.236;
|
||||||
|
const pMin = Math.min(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
|
||||||
|
const pMax = Math.max(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
|
||||||
|
const y1 = candleSeries.priceToCoordinate(pMin);
|
||||||
|
const y2 = candleSeries.priceToCoordinate(pMax);
|
||||||
|
if (y1 != null && y2 != null) {
|
||||||
|
const top = Math.min(y1, y2) - tol;
|
||||||
|
const bottom = Math.max(y1, y2) + tol;
|
||||||
|
if (param.point.x >= left && param.point.x <= right && param.point.y >= top && param.point.y <= bottom) {
|
||||||
|
target = 'fib';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChartClickRef.current?.({ logical: Number(logical), price: Number(price), target });
|
||||||
};
|
};
|
||||||
chart.subscribeClick(onClick);
|
chart.subscribeClick(onClick);
|
||||||
|
|
||||||
@@ -211,6 +344,218 @@ export default function TradingChart({
|
|||||||
};
|
};
|
||||||
chart.subscribeCrosshairMove(onCrosshairMove);
|
chart.subscribeCrosshairMove(onCrosshairMove);
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const toAnchorFromPoint = (x: number, y: number): FibAnchor | null => {
|
||||||
|
const logical = chart.timeScale().coordinateToLogical(x);
|
||||||
|
if (logical == null) return null;
|
||||||
|
const price = candleSeries.coordinateToPrice(y);
|
||||||
|
if (price == null) return null;
|
||||||
|
return { logical: Number(logical), price: Number(price) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOverFib = (x: number, y: number, currentFib: FibRetracement): boolean => {
|
||||||
|
const x1 = chart.timeScale().logicalToCoordinate(currentFib.a.logical as any);
|
||||||
|
const x2 = chart.timeScale().logicalToCoordinate(currentFib.b.logical as any);
|
||||||
|
if (x1 == null || x2 == null) return false;
|
||||||
|
|
||||||
|
const tol = 6;
|
||||||
|
const left = Math.min(x1, x2) - tol;
|
||||||
|
const right = Math.max(x1, x2) + tol;
|
||||||
|
|
||||||
|
const p0 = currentFib.a.price;
|
||||||
|
const delta = currentFib.b.price - p0;
|
||||||
|
const yRatioMin = 0;
|
||||||
|
const yRatioMax = 4.236;
|
||||||
|
const pMin = Math.min(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
|
||||||
|
const pMax = Math.max(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
|
||||||
|
const y1 = candleSeries.priceToCoordinate(pMin);
|
||||||
|
const y2 = candleSeries.priceToCoordinate(pMax);
|
||||||
|
if (y1 == null || y2 == null) return false;
|
||||||
|
|
||||||
|
const top = Math.min(y1, y2) - tol;
|
||||||
|
const bottom = Math.max(y1, y2) + tol;
|
||||||
|
return x >= left && x <= right && y >= top && y <= bottom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOverlayPointer = (e: PointerEvent) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const anchor = toAnchorFromPoint(x, y);
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
const currentFib = fibRef.current;
|
||||||
|
const target: 'chart' | 'fib' = currentFib && isOverFib(x, y, currentFib) ? 'fib' : 'chart';
|
||||||
|
const type =
|
||||||
|
e.type === 'pointerdown'
|
||||||
|
? ('pointerdown' as const)
|
||||||
|
: e.type === 'pointermove'
|
||||||
|
? ('pointermove' as const)
|
||||||
|
: e.type === 'pointerup'
|
||||||
|
? ('pointerup' as const)
|
||||||
|
: ('pointercancel' as const);
|
||||||
|
|
||||||
|
const decision = onPointerEventRef.current?.({
|
||||||
|
type,
|
||||||
|
logical: anchor.logical,
|
||||||
|
price: anchor.price,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
target,
|
||||||
|
event: e,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision?.capturePointer) {
|
||||||
|
capturedOverlayPointerRef.current = e.pointerId;
|
||||||
|
try {
|
||||||
|
containerRef.current.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision?.consume) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'pointerup' || type === 'pointercancel') {
|
||||||
|
if (capturedOverlayPointerRef.current === e.pointerId) {
|
||||||
|
capturedOverlayPointerRef.current = null;
|
||||||
|
try {
|
||||||
|
containerRef.current.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
container?.addEventListener('pointerdown', onOverlayPointer, { capture: true });
|
||||||
|
container?.addEventListener('pointermove', onOverlayPointer, { capture: true });
|
||||||
|
container?.addEventListener('pointerup', onOverlayPointer, { capture: true });
|
||||||
|
container?.addEventListener('pointercancel', onOverlayPointer, { capture: true });
|
||||||
|
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (priceAutoScaleRef.current) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const pivotPrice = candleSeries.coordinateToPrice(y);
|
||||||
|
if (pivotPrice == null) return;
|
||||||
|
|
||||||
|
const ps = candleSeries.priceScale();
|
||||||
|
const range = ps.getVisibleRange();
|
||||||
|
let low: number;
|
||||||
|
let high: number;
|
||||||
|
if (range) {
|
||||||
|
low = Math.min(range.from, range.to);
|
||||||
|
high = Math.max(range.from, range.to);
|
||||||
|
} else {
|
||||||
|
const top = candleSeries.coordinateToPrice(0);
|
||||||
|
const bottom = candleSeries.coordinateToPrice(rect.height);
|
||||||
|
if (top == null || bottom == null) return;
|
||||||
|
low = Math.min(top, bottom);
|
||||||
|
high = Math.max(top, bottom);
|
||||||
|
}
|
||||||
|
const span = high - low;
|
||||||
|
if (!(span > 0)) return;
|
||||||
|
|
||||||
|
const zoomFactor = Math.exp(e.deltaY * 0.002);
|
||||||
|
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return;
|
||||||
|
const t = Math.min(1, Math.max(0, (pivotPrice - low) / span));
|
||||||
|
const minSpan = span * 1e-6;
|
||||||
|
const maxSpan = span * 1e6;
|
||||||
|
const nextSpan = Math.min(maxSpan, Math.max(minSpan, span * zoomFactor));
|
||||||
|
const nextLow = pivotPrice - t * nextSpan;
|
||||||
|
const nextHigh = nextLow + nextSpan;
|
||||||
|
|
||||||
|
ps.setAutoScale(false);
|
||||||
|
ps.setVisibleRange({ from: nextLow, to: nextHigh });
|
||||||
|
};
|
||||||
|
container?.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||||
|
|
||||||
|
let pan: { pointerId: number; startPrice: number; startRange: { from: number; to: number } } | null = null;
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
if (priceAutoScaleRef.current) return;
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const startPrice = candleSeries.coordinateToPrice(y);
|
||||||
|
if (startPrice == null) return;
|
||||||
|
|
||||||
|
const ps = candleSeries.priceScale();
|
||||||
|
const range = ps.getVisibleRange();
|
||||||
|
let from: number;
|
||||||
|
let to: number;
|
||||||
|
if (range) {
|
||||||
|
from = range.from;
|
||||||
|
to = range.to;
|
||||||
|
} else {
|
||||||
|
const top = candleSeries.coordinateToPrice(0);
|
||||||
|
const bottom = candleSeries.coordinateToPrice(rect.height);
|
||||||
|
if (top == null || bottom == null) return;
|
||||||
|
from = Math.min(top, bottom);
|
||||||
|
to = Math.max(top, bottom);
|
||||||
|
}
|
||||||
|
if (!(to > from)) return;
|
||||||
|
|
||||||
|
pan = { pointerId: e.pointerId, startPrice, startRange: { from, to } };
|
||||||
|
try {
|
||||||
|
containerRef.current.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (!pan) return;
|
||||||
|
if (pan.pointerId !== e.pointerId) return;
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
if (!e.ctrlKey || priceAutoScaleRef.current) {
|
||||||
|
pan = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const currentPrice = candleSeries.coordinateToPrice(y);
|
||||||
|
if (currentPrice == null) return;
|
||||||
|
const delta = pan.startPrice - currentPrice;
|
||||||
|
|
||||||
|
const ps = candleSeries.priceScale();
|
||||||
|
ps.setAutoScale(false);
|
||||||
|
ps.setVisibleRange({ from: pan.startRange.from + delta, to: pan.startRange.to + delta });
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPan = (e: PointerEvent) => {
|
||||||
|
if (!pan) return;
|
||||||
|
if (pan.pointerId !== e.pointerId) return;
|
||||||
|
pan = null;
|
||||||
|
try {
|
||||||
|
containerRef.current?.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container?.addEventListener('pointerdown', onPointerDown, { capture: true });
|
||||||
|
container?.addEventListener('pointermove', onPointerMove, { capture: true });
|
||||||
|
container?.addEventListener('pointerup', stopPan, { capture: true });
|
||||||
|
container?.addEventListener('pointercancel', stopPan, { capture: true });
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||||
@@ -221,6 +566,15 @@ export default function TradingChart({
|
|||||||
return () => {
|
return () => {
|
||||||
chart.unsubscribeClick(onClick);
|
chart.unsubscribeClick(onClick);
|
||||||
chart.unsubscribeCrosshairMove(onCrosshairMove);
|
chart.unsubscribeCrosshairMove(onCrosshairMove);
|
||||||
|
container?.removeEventListener('pointerdown', onOverlayPointer, { capture: true });
|
||||||
|
container?.removeEventListener('pointermove', onOverlayPointer, { capture: true });
|
||||||
|
container?.removeEventListener('pointerup', onOverlayPointer, { capture: true });
|
||||||
|
container?.removeEventListener('pointercancel', onOverlayPointer, { capture: true });
|
||||||
|
container?.removeEventListener('wheel', onWheel, { capture: true });
|
||||||
|
container?.removeEventListener('pointerdown', onPointerDown, { capture: true });
|
||||||
|
container?.removeEventListener('pointermove', onPointerMove, { capture: true });
|
||||||
|
container?.removeEventListener('pointerup', stopPan, { capture: true });
|
||||||
|
container?.removeEventListener('pointercancel', stopPan, { capture: true });
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
if (fibPrimitiveRef.current) {
|
if (fibPrimitiveRef.current) {
|
||||||
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
||||||
@@ -234,7 +588,7 @@ export default function TradingChart({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = seriesRef.current;
|
const s = seriesRef.current;
|
||||||
if (!s.candles || !s.volume) return;
|
if (!s.candles || !s.volume || !s.build) return;
|
||||||
s.candles.setData(candleData);
|
s.candles.setData(candleData);
|
||||||
s.volume.setData(volumeData);
|
s.volume.setData(volumeData);
|
||||||
s.oracle?.setData(oracleData);
|
s.oracle?.setData(oracleData);
|
||||||
@@ -244,16 +598,203 @@ export default function TradingChart({
|
|||||||
s.bbLower?.setData(bbLower);
|
s.bbLower?.setData(bbLower);
|
||||||
s.bbMid?.setData(bbMid);
|
s.bbMid?.setData(bbMid);
|
||||||
|
|
||||||
|
s.build.applyOptions({ visible: showBuild });
|
||||||
|
if (!showBuild) {
|
||||||
|
buildSamplesRef.current.clear();
|
||||||
|
buildKeyRef.current = seriesKey;
|
||||||
|
lastBuildCandleStartRef.current = null;
|
||||||
|
s.build.setData([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildKeyRef.current !== seriesKey) {
|
||||||
|
buildSamplesRef.current.clear();
|
||||||
|
buildKeyRef.current = seriesKey;
|
||||||
|
lastBuildCandleStartRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showBuild) {
|
||||||
s.sma20?.applyOptions({ visible: showIndicators });
|
s.sma20?.applyOptions({ visible: showIndicators });
|
||||||
s.ema20?.applyOptions({ visible: showIndicators });
|
s.ema20?.applyOptions({ visible: showIndicators });
|
||||||
s.bbUpper?.applyOptions({ visible: showIndicators });
|
s.bbUpper?.applyOptions({ visible: showIndicators });
|
||||||
s.bbLower?.applyOptions({ visible: showIndicators });
|
s.bbLower?.applyOptions({ visible: showIndicators });
|
||||||
s.bbMid?.applyOptions({ visible: showIndicators });
|
s.bbMid?.applyOptions({ visible: showIndicators });
|
||||||
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
||||||
|
const eps = 1e-3;
|
||||||
|
const maxPointsPerCandle = 600;
|
||||||
|
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
|
||||||
|
const map = buildSamplesRef.current;
|
||||||
|
const visibleStarts = new Set(candles.map((c) => c.time));
|
||||||
|
for (const start of Array.from(map.keys())) {
|
||||||
|
if (!visibleStarts.has(start)) map.delete(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = candles[candles.length - 1];
|
||||||
|
if (last) {
|
||||||
|
const prevStart = lastBuildCandleStartRef.current;
|
||||||
|
if (prevStart != null && prevStart !== last.time) {
|
||||||
|
const prevCandle = candles.find((c) => c.time === prevStart);
|
||||||
|
if (prevCandle) {
|
||||||
|
const endT = prevStart + bs - eps;
|
||||||
|
const finalDelta = prevCandle.close - prevCandle.open;
|
||||||
|
const list = map.get(prevStart) ?? [];
|
||||||
|
const lastT = list.length ? list[list.length - 1]!.t : -Infinity;
|
||||||
|
if (endT > lastT + eps) {
|
||||||
|
list.push({ t: endT, v: finalDelta });
|
||||||
|
} else if (list.length) {
|
||||||
|
list[list.length - 1] = { t: lastT, v: finalDelta };
|
||||||
|
}
|
||||||
|
map.set(prevStart, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = last.time;
|
||||||
|
const endT = start + bs - eps;
|
||||||
|
const delta = last.close - last.open;
|
||||||
|
const nowT = Date.now() / 1000;
|
||||||
|
const tClamped = Math.min(endT, Math.max(start + eps, nowT));
|
||||||
|
const list = map.get(start) ?? [];
|
||||||
|
if (list.length) {
|
||||||
|
const lastPt = list[list.length - 1]!;
|
||||||
|
if (tClamped - lastPt.t < minStep) {
|
||||||
|
list[list.length - 1] = { t: tClamped, v: delta };
|
||||||
|
} else {
|
||||||
|
const t = Math.min(endT, Math.max(lastPt.t + eps, tClamped));
|
||||||
|
if (t > lastPt.t) list.push({ t, v: delta });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list.push({ t: tClamped, v: delta });
|
||||||
|
}
|
||||||
|
map.set(start, list);
|
||||||
|
lastBuildCandleStartRef.current = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildRaw: LinePoint[] = [];
|
||||||
|
for (const c of candles) {
|
||||||
|
const list = map.get(c.time);
|
||||||
|
if (!list?.length) continue;
|
||||||
|
|
||||||
|
const startT = c.time + eps;
|
||||||
|
const endT = c.time + bs - eps;
|
||||||
|
if (!(endT > startT)) continue;
|
||||||
|
|
||||||
|
buildRaw.push({ time: toTime(c.time) } as WhitespaceData);
|
||||||
|
buildRaw.push({ time: toTime(startT), value: 0 } as LineData);
|
||||||
|
|
||||||
|
let lastT = startT;
|
||||||
|
for (const p of list) {
|
||||||
|
let t = p.t;
|
||||||
|
if (t <= lastT + eps) t = lastT + eps;
|
||||||
|
if (t >= endT) break;
|
||||||
|
buildRaw.push({ time: toTime(t), value: p.v } as LineData);
|
||||||
|
lastT = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDelta = c.close - c.open;
|
||||||
|
if (endT > lastT + eps) {
|
||||||
|
buildRaw.push({ time: toTime(endT), value: finalDelta } as LineData);
|
||||||
|
} else if (buildRaw.length) {
|
||||||
|
const prev = buildRaw[buildRaw.length - 1];
|
||||||
|
if ('value' in prev) {
|
||||||
|
buildRaw[buildRaw.length - 1] = { ...prev, value: finalDelta } as LineData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildColored: LinePoint[] = [];
|
||||||
|
let lastLineIdx: number | null = null;
|
||||||
|
let lastLineVal: number | null = null;
|
||||||
|
for (const p of buildRaw) {
|
||||||
|
if (!('value' in p)) {
|
||||||
|
buildColored.push(p);
|
||||||
|
lastLineIdx = null;
|
||||||
|
lastLineVal = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastLineIdx != null && lastLineVal != null) {
|
||||||
|
const delta = p.value - lastLineVal;
|
||||||
|
const color = delta > 0 ? '#22c55e' : delta < 0 ? '#ef4444' : '#60a5fa';
|
||||||
|
const prev = buildColored[lastLineIdx] as LineData;
|
||||||
|
buildColored[lastLineIdx] = { ...prev, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildColored.push({ time: p.time, value: p.value } as LineData);
|
||||||
|
lastLineIdx = buildColored.length - 1;
|
||||||
|
lastLineVal = p.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.build.setData(buildColored);
|
||||||
|
|
||||||
|
s.sma20?.applyOptions({ visible: showIndicators });
|
||||||
|
s.ema20?.applyOptions({ visible: showIndicators });
|
||||||
|
s.bbUpper?.applyOptions({ visible: showIndicators });
|
||||||
|
s.bbLower?.applyOptions({ visible: showIndicators });
|
||||||
|
s.bbMid?.applyOptions({ visible: showIndicators });
|
||||||
|
}, [
|
||||||
|
candleData,
|
||||||
|
volumeData,
|
||||||
|
oracleData,
|
||||||
|
smaData,
|
||||||
|
emaData,
|
||||||
|
bbUpper,
|
||||||
|
bbLower,
|
||||||
|
bbMid,
|
||||||
|
showIndicators,
|
||||||
|
showBuild,
|
||||||
|
candles,
|
||||||
|
bucketSeconds,
|
||||||
|
seriesKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const s = seriesRef.current;
|
||||||
|
if (!s.candles) return;
|
||||||
|
s.candles.applyOptions({ priceFormat });
|
||||||
|
s.build?.applyOptions({ priceFormat });
|
||||||
|
s.oracle?.applyOptions({ priceFormat });
|
||||||
|
s.sma20?.applyOptions({ priceFormat });
|
||||||
|
s.ema20?.applyOptions({ priceFormat });
|
||||||
|
s.bbUpper?.applyOptions({ priceFormat });
|
||||||
|
s.bbLower?.applyOptions({ priceFormat });
|
||||||
|
s.bbMid?.applyOptions({ priceFormat });
|
||||||
|
}, [priceFormat]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fibPrimitiveRef.current?.setFib(fib ?? null);
|
fibPrimitiveRef.current?.setFib(fib ?? null);
|
||||||
}, [fib]);
|
}, [fib]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fibPrimitiveRef.current?.setSelected(Boolean(fibSelected));
|
||||||
|
}, [fibSelected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const candlesSeries = seriesRef.current.candles;
|
||||||
|
if (!candlesSeries) return;
|
||||||
|
const ps = candlesSeries.priceScale();
|
||||||
|
const prev = prevPriceAutoScaleRef.current;
|
||||||
|
prevPriceAutoScaleRef.current = priceAutoScale;
|
||||||
|
|
||||||
|
if (priceAutoScale) {
|
||||||
|
ps.setAutoScale(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.setAutoScale(false);
|
||||||
|
if (!prev) return;
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const top = candlesSeries.coordinateToPrice(0);
|
||||||
|
const bottom = candlesSeries.coordinateToPrice(rect.height);
|
||||||
|
if (top == null || bottom == null) return;
|
||||||
|
const from = Math.min(top, bottom);
|
||||||
|
const to = Math.max(top, bottom);
|
||||||
|
if (!(to > from)) return;
|
||||||
|
ps.setVisibleRange({ from, to });
|
||||||
|
}, [priceAutoScale]);
|
||||||
|
|
||||||
return <div className="tradingChart" ref={containerRef} />;
|
return <div className="tradingChart" ref={containerRef} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Params = {
|
|||||||
type Result = {
|
type Result = {
|
||||||
candles: Candle[];
|
candles: Candle[];
|
||||||
indicators: ChartIndicators;
|
indicators: ChartIndicators;
|
||||||
|
meta: { tf: string; bucketSeconds: number } | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
@@ -22,6 +23,7 @@ type Result = {
|
|||||||
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
|
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
|
||||||
const [candles, setCandles] = useState<Candle[]>([]);
|
const [candles, setCandles] = useState<Candle[]>([]);
|
||||||
const [indicators, setIndicators] = useState<ChartIndicators>({});
|
const [indicators, setIndicators] = useState<ChartIndicators>({});
|
||||||
|
const [meta, setMeta] = useState<{ tf: string; bucketSeconds: number } | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inFlight = useRef(false);
|
const inFlight = useRef(false);
|
||||||
@@ -34,6 +36,7 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
|
|||||||
const res = await fetchChart({ symbol, source, tf, limit });
|
const res = await fetchChart({ symbol, source, tf, limit });
|
||||||
setCandles(res.candles);
|
setCandles(res.candles);
|
||||||
setIndicators(res.indicators);
|
setIndicators(res.indicators);
|
||||||
|
setMeta(res.meta);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message || e));
|
setError(String(e?.message || e));
|
||||||
@@ -50,8 +53,7 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
|
|||||||
useInterval(() => void fetchOnce(), pollMs);
|
useInterval(() => void fetchOnce(), pollMs);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({ candles, indicators, loading, error, refresh: fetchOnce }),
|
() => ({ candles, indicators, meta, loading, error, refresh: fetchOnce }),
|
||||||
[candles, indicators, loading, error, fetchOnce]
|
[candles, indicators, meta, loading, error, fetchOnce]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,15 @@ type Props = {
|
|||||||
active?: NavId;
|
active?: NavId;
|
||||||
onSelect?: (id: NavId) => void;
|
onSelect?: (id: NavId) => void;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
rightEndSlot?: ReactNode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TopNav({ active = 'trade', onSelect, rightSlot, rightEndSlot }: Props) {
|
export default function TopNav({ active = 'trade', onSelect, rightSlot }: Props) {
|
||||||
return (
|
return (
|
||||||
<header className="topNav">
|
<header className="topNav">
|
||||||
<div className="topNav__left">
|
<div className="topNav__left">
|
||||||
<div className="topNav__brand" aria-label="Drift">
|
<div className="topNav__brand" aria-label="Trade">
|
||||||
<div className="topNav__brandMark" aria-hidden="true" />
|
<div className="topNav__brandMark" aria-hidden="true" />
|
||||||
<div className="topNav__brandName">Drift</div>
|
<div className="topNav__brandName">Trade</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="topNav__menu" aria-label="Primary">
|
<nav className="topNav__menu" aria-label="Primary">
|
||||||
{navItems.map((it) => (
|
{navItems.map((it) => (
|
||||||
@@ -56,7 +55,6 @@ export default function TopNav({ active = 'trade', onSelect, rightSlot, rightEnd
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{rightEndSlot}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -689,6 +689,216 @@ a:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chartLayersBackdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 60;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersBackdrop--open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
background: rgba(17, 19, 28, 0.92);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 70;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 140ms ease, transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel--open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel__head {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel__title {
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel__close {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(230, 233, 239, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel__close:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(230, 233, 239, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersTable {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px 34px minmax(0, 1fr) 150px 40px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(17, 19, 28, 0.92);
|
||||||
|
color: rgba(230, 233, 239, 0.60);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--layer {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--object {
|
||||||
|
cursor: pointer;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--object:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--selected {
|
||||||
|
background: rgba(168, 85, 247, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell--icon {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell--name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell--opacity {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell--actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersBtn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
background: rgba(0, 0, 0, 0.10);
|
||||||
|
color: rgba(230, 233, 239, 0.88);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersBtn:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersBtn--active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersBtn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersName {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersName--layer {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersName--object {
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(230, 233, 239, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersName__meta {
|
||||||
|
opacity: 0.65;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersOpacity {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 44px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersOpacity__range {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersOpacity__pct {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(230, 233, 239, 0.70);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.chartCard__chart {
|
.chartCard__chart {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import react from '@vitejs/plugin-react';
|
|||||||
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = path.resolve(DIR, '../..');
|
const ROOT = path.resolve(DIR, '../..');
|
||||||
|
|
||||||
|
type BasicAuth = { username: string; password: string };
|
||||||
|
|
||||||
|
function stripTrailingSlashes(p: string): string {
|
||||||
|
const out = p.replace(/\/+$/, '');
|
||||||
|
return out || '/';
|
||||||
|
}
|
||||||
|
|
||||||
function readApiReadToken(): string | undefined {
|
function readApiReadToken(): string | undefined {
|
||||||
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
||||||
const p = path.join(ROOT, 'tokens', 'read.json');
|
const p = path.join(ROOT, 'tokens', 'read.json');
|
||||||
@@ -20,24 +27,132 @@ function readApiReadToken(): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const idx = raw.indexOf(':');
|
||||||
|
if (idx <= 0) return undefined;
|
||||||
|
const username = raw.slice(0, idx).trim();
|
||||||
|
const password = raw.slice(idx + 1);
|
||||||
|
if (!username || !password) return undefined;
|
||||||
|
return { username, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProxyBasicAuth(): BasicAuth | undefined {
|
||||||
|
const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH);
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
|
const fileRaw = String(process.env.API_PROXY_BASIC_AUTH_FILE || '').trim();
|
||||||
|
if (!fileRaw) return undefined;
|
||||||
|
|
||||||
|
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
|
||||||
|
if (!fs.existsSync(p)) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(p, 'utf8');
|
||||||
|
const json = JSON.parse(raw) as { username?: string; password?: string };
|
||||||
|
const username = typeof json?.username === 'string' ? json.username.trim() : '';
|
||||||
|
const password = typeof json?.password === 'string' ? json.password : '';
|
||||||
|
if (!username || !password) return undefined;
|
||||||
|
return { username, password };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const apiReadToken = readApiReadToken();
|
const apiReadToken = readApiReadToken();
|
||||||
|
const proxyBasicAuth = readProxyBasicAuth();
|
||||||
|
const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:8787';
|
||||||
|
|
||||||
|
function parseUrl(v: string): URL | undefined {
|
||||||
|
try {
|
||||||
|
return new URL(v);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
|
||||||
|
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
||||||
|
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
||||||
|
|
||||||
|
function inferUiProxyTarget(apiTarget: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const u = new URL(apiTarget);
|
||||||
|
const p = stripTrailingSlashes(u.pathname || '/');
|
||||||
|
if (!p.endsWith('/api')) return undefined;
|
||||||
|
const basePath = p.slice(0, -'/api'.length) || '/';
|
||||||
|
u.pathname = basePath;
|
||||||
|
u.search = '';
|
||||||
|
u.hash = '';
|
||||||
|
const out = u.toString();
|
||||||
|
return out.endsWith('/') ? out.slice(0, -1) : out;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiProxyTarget =
|
||||||
|
process.env.FRONTEND_PROXY_TARGET ||
|
||||||
|
process.env.UI_PROXY_TARGET ||
|
||||||
|
process.env.AUTH_PROXY_TARGET ||
|
||||||
|
inferUiProxyTarget(apiProxyTarget) ||
|
||||||
|
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
|
||||||
|
|
||||||
|
function applyProxyBasicAuth(proxyReq: any) {
|
||||||
|
if (!proxyBasicAuth) return false;
|
||||||
|
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||||
|
proxyReq.setHeader('Authorization', `Basic ${b64}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
||||||
|
const v = proxyRes?.headers?.['set-cookie'];
|
||||||
|
if (!v) return;
|
||||||
|
const rewrite = (cookie: string) => {
|
||||||
|
let out = cookie.replace(/;\s*secure\b/gi, '');
|
||||||
|
out = out.replace(/;\s*domain=[^;]+/gi, '');
|
||||||
|
out = out.replace(/;\s*samesite=none\b/gi, '; SameSite=Lax');
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy: Record<string, any> = {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
|
||||||
|
configure: (p: any) => {
|
||||||
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
if (applyProxyBasicAuth(proxyReq)) return;
|
||||||
|
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (uiProxyTarget) {
|
||||||
|
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
||||||
|
proxy[prefix] = {
|
||||||
|
target: uiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
configure: (p: any) => {
|
||||||
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
p.on('proxyRes', (proxyRes: any) => {
|
||||||
|
rewriteSetCookieForLocalDevHttp(proxyRes);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy,
|
||||||
'/api': {
|
|
||||||
target: process.env.API_PROXY_TARGET || 'http://localhost:8787',
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
|
||||||
configure: (proxy) => {
|
|
||||||
proxy.on('proxyReq', (proxyReq) => {
|
|
||||||
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
99
doc/workflow.md
Normal file
99
doc/workflow.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Workflow pracy w projekcie `trade` (dev → staging na VPS) + snapshot/rollback
|
||||||
|
|
||||||
|
Ten plik jest “source of truth” dla sposobu pracy nad zmianami w `trade`.
|
||||||
|
Cel: **zero ręcznych zmian na VPS**, każdy deploy jest **snapshoot’em**, do którego można wrócić.
|
||||||
|
|
||||||
|
## Dla agenta / po restarcie sesji
|
||||||
|
|
||||||
|
1) Przeczytaj ten plik: `doc/workflow.md`.
|
||||||
|
2) Kontekst funkcjonalny: `README.md`, `info.md`.
|
||||||
|
3) Kontekst stacka: `doc/workflow-api-ingest.md` oraz `devops/*/README.md`.
|
||||||
|
4) Stan VPS/k3s + GitOps: `doc/migration.md` i log zmian: `doc/steps.md`.
|
||||||
|
|
||||||
|
## Zasady (must-have)
|
||||||
|
|
||||||
|
- **Nie edytujemy “na żywo” VPS** (żadnych ręcznych poprawek w kontenerach/plikach na serwerze) → tylko Git + CI + Argo.
|
||||||
|
- **Sekrety nie trafiają do gita**: `tokens/*.json` są gitignored; w dokumentacji/logach redagujemy hasła/tokeny.
|
||||||
|
- **Brak `latest`**: obrazy w deployu są przypięte do `sha-<shortsha>` albo digesta.
|
||||||
|
- **Każda zmiana = snapshot**: “wersja” to commit w repo deploy + przypięte obrazy.
|
||||||
|
|
||||||
|
## Domyślne środowisko pracy (ważne)
|
||||||
|
|
||||||
|
- **Frontend**: domyślnie pracujemy lokalnie (Vite) i łączymy się z backendem na VPS (staging) przez proxy. Deploy frontendu na VPS robimy tylko jeśli jest to wyraźnie powiedziane (“wdrażam na VPS”).
|
||||||
|
- **Backend (trade-api + ingestor)**: zmiany backendu weryfikujemy/wdrażamy na VPS (staging), bo tam działa ingestor i tam są dane. Nie traktujemy lokalnego uruchomienia backendu jako domyślnego (tylko na wyraźną prośbę do debugowania).
|
||||||
|
|
||||||
|
## Standardowy flow zmian (polecany)
|
||||||
|
|
||||||
|
1) Zmiana w kodzie lokalnie.
|
||||||
|
- Nie musisz odpalać lokalnego Dockera; na start rób szybkie walidacje (build/typecheck).
|
||||||
|
2) Commit + push (najlepiej przez PR).
|
||||||
|
3) CI:
|
||||||
|
- build → push obrazów (tag `sha-*` lub digest),
|
||||||
|
- aktualizacja `trade-deploy` (bump obrazu/rewizji).
|
||||||
|
4) Argo CD (auto-sync na staging) wdraża nowy snapshot w `trade-staging`.
|
||||||
|
5) Test na VPS:
|
||||||
|
- API: `/healthz`, `/v1/ticks`, `/v1/chart`
|
||||||
|
- UI: `trade.mpabi.pl`
|
||||||
|
- Ingest: logi `trade-ingestor` + napływ ticków do tabeli.
|
||||||
|
|
||||||
|
## Snapshoty i rollback (playbook)
|
||||||
|
|
||||||
|
### Rollback szybki (preferowany)
|
||||||
|
|
||||||
|
- Cofnij snapshot w repo deploy:
|
||||||
|
- `git revert` commita, który podbił obrazy, **albo**
|
||||||
|
- w Argo cofnij do poprzedniej rewizji (ten sam efekt).
|
||||||
|
|
||||||
|
Efekt: Argo wraca do poprzedniego “dobrego” zestawu obrazów i konfiguracji.
|
||||||
|
|
||||||
|
### Rollback bezpieczny dla “dużych” zmian (schema/ingest)
|
||||||
|
|
||||||
|
Jeśli zmiana dotyka danych/ingestu, rób ją jako nową wersję vN:
|
||||||
|
- nowa tabela: `drift_ticks_vN`
|
||||||
|
- nowa funkcja: `get_drift_candles_vN`
|
||||||
|
- osobny deploy API/UI (inne porty/sufiksy), a ingest przełączany “cutover”.
|
||||||
|
|
||||||
|
W razie problemów: robisz “cut back” vN → v1 (stare dane zostają nietknięte).
|
||||||
|
|
||||||
|
## Lokalne uruchomienie (opcjonalne, do debugowania)
|
||||||
|
|
||||||
|
Dokładna instrukcja: `doc/workflow-api-ingest.md`.
|
||||||
|
|
||||||
|
Skrót:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
docker compose -f devops/db/docker-compose.yml up -d
|
||||||
|
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init
|
||||||
|
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap
|
||||||
|
docker compose -f devops/app/docker-compose.yml up -d --build api
|
||||||
|
npm run token:api -- --scopes write --out tokens/alg.json
|
||||||
|
npm run token:api -- --scopes read --out tokens/read.json
|
||||||
|
docker compose -f devops/app/docker-compose.yml up -d --build frontend
|
||||||
|
docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend dev (Vite) z backendem na VPS (staging)
|
||||||
|
|
||||||
|
Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.mpabi.pl`.
|
||||||
|
|
||||||
|
- Vite trzyma `VITE_API_URL=/api` (default) i proxy’uje `/api/*` do VPS.
|
||||||
|
- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxy’ujemy też `/whoami`, `/auth/*`, `/logout`.
|
||||||
|
- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`.
|
||||||
|
- Na VPS `trade-frontend` proxy’uje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki).
|
||||||
|
|
||||||
|
Przykład:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/visualizer
|
||||||
|
API_PROXY_TARGET=https://trade.mpabi.pl \
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeśli staging ma dodatkowy basic auth (np. Traefik `basicAuth`), dodaj:
|
||||||
|
`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`).
|
||||||
|
|
||||||
|
## Definicja “done” dla zmiany
|
||||||
|
|
||||||
|
- Jest snapshot (commit w deploy) i można wrócić jednym ruchem.
|
||||||
|
- Staging działa (API/ingest/UI) i ma podstawowe smoke-checki.
|
||||||
|
- Sekrety nie zostały dodane do repo ani do logów/komentarzy.
|
||||||
Reference in New Issue
Block a user