diff --git a/.gitignore b/.gitignore index f81008f..d1705e6 100644 --- a/.gitignore +++ b/.gitignore @@ -230,3 +230,5 @@ testdata/*-output.json # Contribot contribot.*.json transcripts/ + +.playwright-cli \ No newline at end of file diff --git a/.interface-design/system.md b/.interface-design/system.md new file mode 100644 index 0000000..7e7ce4d --- /dev/null +++ b/.interface-design/system.md @@ -0,0 +1,129 @@ +# Design System + +## File Structure + +``` +out/css/tokens.css — CSS custom properties (single source of truth for all tokens) +out/css/styles.css — component styles (@import ./tokens.css; zero hardcoded values) +out/index.html — +``` + +Dependency graph: `index.html → styles.css → tokens.css` + +## Tokens (`out/css/tokens.css`) + +### Surfaces +| Token | Value | Usage | +|---|---|---| +| `--surface-base` | `#0f172a` | Deepest bg: body gradient start, table `` | +| `--surface-card` | `#1a2035` | Section bg: `.trades-section`, `.info` | +| `--surface-raised` | `#1e293b` | Card bg: `#container`, `.chart-container` | +| `--surface-raised-alpha` | `rgba(30,41,59,0.85)` | Fullscreen toggle button bg | +| `--surface-hover` | `rgba(255,255,255,0.06)` | Row hover — visible on any dark surface | + +### Borders +| Token | Value | Usage | +|---|---|---| +| `--border-subtle` | `#2d3748` | Inner dividers, row borders, section internals | +| `--border-default` | `#334155` | Outer borders: container, chart, buttons | + +### Text +| Token | Value | Usage | +|---|---|---| +| `--text-primary` | `#cbd5e1` | Body text, card text | +| `--text-secondary` | `#94a3b8` | ``, summary, button default label | +| `--text-muted` | `#6b7280` | Timestamp, `.no-trades` placeholder | + +### Accent +| Token | Value | Usage | +|---|---|---| +| `--color-accent` | `#5eead4` | Section headings, hover state, focus ring | +| `--color-accent-alt` | `#2dd4bf` | h1 gradient end | +| `--color-accent-subtle` | `rgba(94,234,212,0.25)` | Pane resize handle hover fill | + +### Actions +| Token | Value | Usage | +|---|---|---| +| `--color-action` | `#2563eb` | `.refresh-btn` bg | +| `--color-action-hover` | `#1d4ed8` | `.refresh-btn:hover` bg | + +### Semantic +| Token | Value | Usage | +|---|---|---| +| `--color-long` | `#10b981` | Long direction, positive P/L | +| `--color-short` | `#ef4444` | Short direction, negative P/L | +| `--color-open` | `#3b82f6` | Open / in-progress trades | + +### Trade Table +| Token | Value | Usage | +|---|---|---| +| `--stripe-tint` | `rgba(255,255,255,0.03)` | Alternating trade-pair row tint | + +### Typography +| Token | Value | Usage | +|---|---|---| +| `--font-ui` | `'Segoe UI', system-ui, sans-serif` | All text | + +### Border Radius +| Token | Value | Usage | +|---|---|---| +| `--radius-md` | `6px` | Most elements | +| `--radius-lg` | `8px` | Outer `#container` only | + +### Shadows +| Token | Value | Usage | +|---|---|---| +| `--shadow-container` | `0 4px 6px -1px rgba(0,0,0,0.2)` | `#container` only | +| `--shadow-text` | `0 2px 4px rgba(0,0,0,0.3)` | `h1` text-shadow | + +### Transitions +| Token | Value | Usage | +|---|---|---| +| `--transition-duration` | `0.2s` | All interactive elements | + +## Spacing Scale +Rem-based: `0.5rem`, `0.75rem`, `1rem`, `1.25rem`, `1.5rem`, `2rem`, `2.5rem` +Pixel exceptions: `8px` (absolute inset positioning only) + +## Font Scale +| Size | Usage | +|---|---| +| `2.5rem` | `h1` page title | +| `1.5rem` | `h2` section title | +| `1rem` | Body, action button | +| `0.875rem` | Table, timestamp, secondary labels, compact buttons | + +Font weight `600`: profit values, table headers. + +## Component Patterns + +### Filled button (`.refresh-btn`) +- `background-color: var(--color-action)` → hover: `var(--color-action-hover)` +- `padding: 0.75rem 1.5rem`, `border-radius: var(--radius-md)`, `font-size: 1rem` +- `:focus-visible`: `outline: 2px solid var(--color-accent); outline-offset: 2px` +- `:active`: `transform: scale(0.97)` + +### Ghost/outline button (`.sort-toggle-btn`, `.fullscreen-toggle-btn`) +- `background: transparent`, `border: 1px solid var(--border-default)`, `color: var(--text-secondary)` +- Hover: `border-color: var(--color-accent); color: var(--color-accent)` +- `:focus-visible`: same accent outline ring as filled button +- `:active`: `transform: scale(0.97)` + +### Section card (`.trades-section`, `.info`) +- `background-color: var(--surface-card)`, `border: 1px solid var(--border-subtle)`, `border-radius: var(--radius-md)`, `padding: 1rem` + +### Section header (`.trades-header`) +- `display: flex`, `justify-content: space-between`, `align-items: center` +- `margin-bottom: 1rem`, `padding-bottom: 0.5rem`, `border-bottom: 1px solid var(--border-subtle)` +- Title: `color: var(--color-accent)` + +## Chart-specific (JS, not CSS) +Chart colors are LightweightCharts API options passed in `index.html` inline ` - - - - - -
-

Financial Chart

- -
- Symbol: Loading...
- Timeframe: Loading...
- Strategy: Loading... -
- - - -
-
-
- -
- -
-
-

Trade History

-
No trades
-
-
- - - - - - - - - - - - - - - - - -
#DateDirectionEntryExitSizeProfit/Loss
No trades to display
-
-
-
- - - - diff --git a/out/index.html b/out/index.html index d7673c4..58a75f4 100644 --- a/out/index.html +++ b/out/index.html @@ -4,184 +4,9 @@ quant5-lab/runner + - diff --git a/out/js/ChartApplication.js b/out/js/ChartApplication.js index e5b4011..3a80c89 100644 --- a/out/js/ChartApplication.js +++ b/out/js/ChartApplication.js @@ -1,275 +1,267 @@ -import { ConfigLoader } from './ConfigLoader.js'; -import { PaneAssigner } from './PaneAssigner.js'; -import { PaneManager } from './PaneManager.js'; -import { SeriesRouter } from './SeriesRouter.js'; -import { ChartManager } from './ChartManager.js'; -import { TradeDataFormatter } from './TradeTable.js'; +import { ConfigLoader } from './ConfigLoader.js'; +import { PaneAssigner } from './PaneAssigner.js'; +import { PaneManager } from './PaneManager.js'; +import { SeriesRouter } from './SeriesRouter.js'; +import { ChartManager } from './ChartManager.js'; +import { TradeDataFormatter, + formatCurrency } from './TradeTable.js'; import { TradeRowspanTransformer } from './TradeRowspanTransformer.js'; -import { TradeRowspanRenderer } from './TradeRowspanRenderer.js'; -import { TimeIndexBuilder } from './TimeIndexBuilder.js'; -import { PlotOffsetTransformer } from './PlotOffsetTransformer.js'; -import { SeriesDataMapper } from './SeriesDataMapper.js'; -import { LineStyleConverter } from './LineStyleConverter.js'; +import { TradeRowspanRenderer } from './TradeRowspanRenderer.js'; +import { TradeMarkerBuilder } from './TradeMarkerBuilder.js'; +import { TimeIndexBuilder } from './TimeIndexBuilder.js'; +import { PlotOffsetTransformer } from './PlotOffsetTransformer.js'; +import { SeriesDataMapper } from './SeriesDataMapper.js'; +import { LineStyleConverter } from './LineStyleConverter.js'; +import { TradeSortToggle } from './TradeSortToggle.js'; +import { FullscreenToggle } from './FullscreenToggle.js'; +import { SortDirection } from './SortDirection.js'; +import { PaneResizeController } from './PaneResizeController.js'; +import { WindowResizeHandler } from './WindowResizeHandler.js'; export class ChartApplication { constructor(chartOptions) { - this.chartOptions = chartOptions; - this.paneManager = null; - this.seriesMap = {}; - this.timeIndexBuilder = new TimeIndexBuilder(); + this.chartOptions = chartOptions; + this.paneManager = null; + this.seriesMap = {}; + this.timeIndexBuilder = new TimeIndexBuilder(); this.plotOffsetTransformer = new PlotOffsetTransformer(this.timeIndexBuilder); - this.seriesDataMapper = new SeriesDataMapper(); - } + this.seriesDataMapper = new SeriesDataMapper(); + this.tradeMarkerBuilder = new TradeMarkerBuilder(); - async initialize() { - const data = await ConfigLoader.loadChartData(); - const configOverride = await ConfigLoader.loadStrategyConfig( - data.metadata?.strategy || 'strategy' + this.sortToggle = new TradeSortToggle( + (direction) => this._rerenderTrades(direction), + SortDirection.DESC, ); - const paneAssigner = new PaneAssigner(data.candlestick); - const indicatorsWithPanes = paneAssigner.assignAllPanes( - data.indicators, - configOverride + this.fullscreenToggle = new FullscreenToggle( + document.querySelector('.chart-container'), + () => this._onResize(), ); - // Merge config style/color overrides into indicators - if (configOverride) { - Object.entries(indicatorsWithPanes).forEach(([key, indicator]) => { - const override = configOverride[key]; - if (override && typeof override === 'object') { - if (override.style) indicator.style = { ...indicator.style, ...override }; - } - }); - } + this.resizeController = new PaneResizeController(); + this.windowResizeHandler = new WindowResizeHandler(() => this._onResize()); + } + + async initialize() { + const data = await ConfigLoader.loadChartData(); + + const indicatorsWithPanes = new PaneAssigner(data.candlestick).assignAllPanes(data.indicators); this.updateMetadataDisplay(data.metadata); - const paneConfig = this.buildPaneConfig(indicatorsWithPanes, data.ui?.panes); - + const paneConfig = this._buildPaneConfig(indicatorsWithPanes, data.ui?.panes); this.paneManager = new PaneManager(this.chartOptions); - this.createCharts(paneConfig); + this._createCharts(paneConfig); + this.resizeController.mount(this.paneManager.getAllPanes()); - const seriesRouter = new SeriesRouter(this.paneManager, this.seriesMap); - this.routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter, configOverride); + const seriesRouter = new SeriesRouter(this.paneManager, this.seriesMap); + const candlestickData = this._routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter); - this.loadTrades(data.strategy, data.candlestick); + this._loadTradeMarkers(data.strategy, candlestickData); + this._loadTrades(data.strategy, data.candlestick); this.updateTimestamp(data.metadata); - - this.setupEventListeners(); this.paneManager.synchronizeTimeScales(); setTimeout(() => { ChartManager.fitContent(this.paneManager.getAllCharts()); + ChartManager.handleResize( + this.paneManager.getAllCharts(), + this.paneManager.getAllContainers(), + ); }, 50); } - buildPaneConfig(indicatorsWithPanes, uiPanes) { - const config = { - main: { height: 400, fixed: true }, - }; + async refresh() { + this.resizeController.destroy(); + this.paneManager.getAllCharts().forEach(chart => chart.remove()); + this.paneManager.dynamicPanes.forEach(({ container }) => container.remove()); + this.paneManager.mainPane.container.innerHTML = ''; - const uniquePanes = new Set(); - Object.values(indicatorsWithPanes).forEach((indicator) => { - const pane = indicator.pane; - if (pane && pane !== 'main') { - uniquePanes.add(pane); - } - }); + this.seriesMap = {}; + this.paneManager = null; - uniquePanes.forEach((paneName) => { + await this.initialize(); + } + + updateMetadataDisplay(metadata) { + if (!metadata) return; + document.getElementById('chart-title').textContent = metadata.title || 'Financial Chart'; + document.getElementById('symbol-display').textContent = metadata.symbol || 'Unknown'; + document.getElementById('timeframe-display').textContent = metadata.timeframe || 'Unknown'; + document.getElementById('strategy-display').textContent = metadata.strategy || 'Unknown'; + } + + updateTimestamp(metadata) { + if (!metadata?.timestamp) return; + document.getElementById('timestamp').textContent = + 'Last updated: ' + new Date(metadata.timestamp).toLocaleString(); + } + + _buildPaneConfig(indicatorsWithPanes, uiPanes) { + const config = { main: { height: 400, fixed: true } }; + + const dynamicPanes = new Set( + Object.values(indicatorsWithPanes) + .map(ind => ind.pane) + .filter(pane => pane && pane !== 'main') + ); + + dynamicPanes.forEach(paneName => { config[paneName] = uiPanes?.[paneName] || { height: 200, fixed: false }; }); - /* Backward compatibility: ensure 'indicator' pane exists if no dynamic panes */ - if (Object.keys(config).length === 1) { - config.indicator = { height: 200, fixed: false }; - } - return config; } - createCharts(paneConfig) { + _createCharts(paneConfig) { const mainContainer = document.getElementById('main-chart'); this.paneManager.createMainPane(mainContainer, paneConfig.main); - Object.entries(paneConfig).forEach(([paneName, config]) => { + this.fullscreenToggle.mount(); + + for (const [paneName, config] of Object.entries(paneConfig)) { if (paneName !== 'main') { this.paneManager.createDynamicPane(paneName, config); } - }); + } } - routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter, configOverride) { + _routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter) { const mainChart = this.paneManager.mainPane.chart; this.seriesMap.candlestick = ChartManager.addCandlestickSeries(mainChart, { - upColor: '#26a69a', - downColor: '#ef5350', + upColor: '#26a69a', + downColor: '#ef5350', borderVisible: false, - wickUpColor: '#26a69a', + wickUpColor: '#26a69a', wickDownColor: '#ef5350', }); const candlestickData = data.candlestick .sort((a, b) => a.time - b.time) - .map((c) => ({ - time: c.time, - open: c.open, - high: c.high, - low: c.low, - close: c.close, - })); + .map(({ time, open, high, low, close }) => ({ time, open, high, low, close })); this.seriesMap.candlestick.setData(candlestickData); - Object.entries(indicatorsWithPanes).forEach(([key, indicator]) => { - const styleType = configOverride?.[key]?.style || 'line'; - const color = indicator.style?.color || configOverride?.[key]?.color || '#2196F3'; - const lineStyleValue = configOverride?.[key]?.lineStyle; - + for (const [key, indicator] of Object.entries(indicatorsWithPanes)) { + const color = indicator.style?.color || '#2196F3'; const seriesConfig = { - color: color, - lineWidth: indicator.style?.lineWidth || 2, - title: indicator.title || key, - chart: indicator.pane || 'main', - style: styleType, - priceLineVisible: false, - lastValueVisible: true, + color, + lineWidth: indicator.style?.lineWidth || 2, + title: indicator.title || key, + chart: indicator.pane || 'main', + style: indicator.style?.plotStyle || 'line', + priceLineVisible: false, + lastValueVisible: true, crosshairMarkerVisible: true, - lineStyle: LineStyleConverter.toNumeric(lineStyleValue), + lineStyle: LineStyleConverter.toNumeric(indicator.style?.lineStyle), }; const series = seriesRouter.routeSeries(key, seriesConfig, ChartManager); - if (!series) { console.error(`Failed to create series for '${key}'`); - return; + continue; } - const offset = indicator.offset || 0; - const offsetAdjustedData = this.plotOffsetTransformer.transform( - indicator.data, - offset, - candlestickData - ); + const offset = indicator.offset || 0; + const shifted = this.plotOffsetTransformer.transform(indicator.data, offset, candlestickData); + const colored = this.seriesDataMapper.applyColorToData(shifted, color); + const processed = window.adaptLineSeriesData(colored); - const dataWithColor = this.seriesDataMapper.applyColorToData(offsetAdjustedData, color); - const processedData = window.adaptLineSeriesData(dataWithColor); - - if (processedData.length > 0) { - series.setData(processedData); - - // Auto-zoom to Buy/Sell Potential signals if they exist - if ((key === 'Buy Potential' || key === 'Sell Potential') && processedData.length > 0) { - const validPoints = processedData.filter(p => !isNaN(p.value) && p.value !== null); - if (validPoints.length > 0) { - const firstTime = validPoints[0].time; - const lastTime = validPoints[validPoints.length - 1].time; - const mainChart = this.paneManager.mainPane.chart; - - // Zoom to show signals with context (±50 bars = 50 hours for 1h timeframe) - const contextBars = 50; - const barInterval = 3600; // 1 hour - mainChart.timeScale().setVisibleRange({ - from: firstTime - (contextBars * barInterval), - to: lastTime + (contextBars * barInterval) - }); - } - } + if (processed.length > 0) { + series.setData(processed); + this._applySignalZoom(key, processed); } + } + + return candlestickData; + } + + _applySignalZoom(key, processedData) { + if (key !== 'Buy Potential' && key !== 'Sell Potential') return; + const valid = processedData.filter(p => p.value != null && !isNaN(p.value)); + if (!valid.length) return; + const contextBars = 50; + const barInterval = 3600; + this.paneManager.mainPane.chart.timeScale().setVisibleRange({ + from: valid[0].time - contextBars * barInterval, + to: valid[valid.length - 1].time + contextBars * barInterval, }); } - loadTrades(strategy, candlestickData) { + _loadTradeMarkers(strategy, candlestickData) { + const markers = this.tradeMarkerBuilder.build(strategy, candlestickData); + if (markers.length > 0) { + this.seriesMap.candlestick.setMarkers(markers); + } + } + + _loadTrades(strategy, candlestickData) { if (!strategy) return; - const allTrades = [ + const openTrades = strategy.openTrades || []; + const allTrades = [ ...(strategy.trades || []), - ...(strategy.openTrades || []).map((t) => ({ ...t, status: 'open' })), + ...openTrades.map(t => ({ ...t, status: 'open' })), ]; - // Sort trades: latest first (by entryTime descending) allTrades.sort((a, b) => (b.entryTime || 0) - (a.entryTime || 0)); - const tbody = document.getElementById('trades-tbody'); + this._tradeState = { + allTrades, + openTrades, + netProfit: strategy.netProfit || 0, + candlestickData, + }; + + const headerEl = document.querySelector('.trades-header'); + this.sortToggle.mount(headerEl, allTrades.length > 0); + + this._rerenderTrades(this.sortToggle.direction); + } + + _rerenderTrades(sortDirection) { + if (!this._tradeState) return; + const { allTrades, openTrades, netProfit, candlestickData } = this._tradeState; + + const tbody = document.getElementById('trades-tbody'); const summary = document.getElementById('trades-summary'); if (allTrades.length === 0) { - tbody.innerHTML = - 'No trades to display'; + tbody.innerHTML = 'No trades to display'; summary.textContent = 'No trades'; return; } - const currentPrice = candlestickData?.length > 0 - ? candlestickData[candlestickData.length - 1].close + const sorted = sortDirection === SortDirection.ASC + ? [...allTrades].reverse() + : allTrades; + + const currentPrice = candlestickData?.length > 0 + ? candlestickData[candlestickData.length - 1].close : null; const formatter = new TradeDataFormatter(candlestickData); - const transformer = new TradeRowspanTransformer(formatter); - const renderer = new TradeRowspanRenderer(); - - const tradeRows = transformer.transformTrades(allTrades, currentPrice); - tbody.innerHTML = renderer.renderRows(tradeRows); + const rows = new TradeRowspanTransformer(formatter).transformTrades(sorted, currentPrice, sortDirection); + tbody.innerHTML = new TradeRowspanRenderer().renderRows(rows); - const realizedProfit = strategy.netProfit || 0; const unrealizedProfit = currentPrice - ? (strategy.openTrades || []).reduce((sum, trade) => { - const multiplier = trade.direction === 'long' ? 1 : -1; - return sum + (currentPrice - trade.entryPrice) * trade.size * multiplier; + ? openTrades.reduce((sum, t) => { + const multiplier = t.direction === 'long' ? 1 : -1; + return sum + (currentPrice - t.entryPrice) * t.size * multiplier; }, 0) : 0; - const totalProfit = realizedProfit + unrealizedProfit; - - const profitClass = - totalProfit >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'; - summary.innerHTML = `${allTrades.length} trades | Net P/L: $${totalProfit.toFixed( - 2 - )}`; + const totalProfit = netProfit + unrealizedProfit; + const profitClass = totalProfit >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'; + summary.innerHTML = + `${allTrades.length} trades | Net P/L: ${formatCurrency(totalProfit)}`; } - updateMetadataDisplay(metadata) { - if (!metadata) return; - - document.getElementById('chart-title').textContent = - metadata.title || 'Financial Chart'; - document.getElementById('symbol-display').textContent = - metadata.symbol || 'Unknown'; - document.getElementById('timeframe-display').textContent = - metadata.timeframe || 'Unknown'; - document.getElementById('strategy-display').textContent = - metadata.strategy || 'Unknown'; - } - - updateTimestamp(metadata) { - if (!metadata?.timestamp) return; - - document.getElementById('timestamp').textContent = - 'Last updated: ' + new Date(metadata.timestamp).toLocaleString(); - } - - setupEventListeners() { - window.addEventListener('resize', () => { - const containers = this.paneManager.getAllContainers(); - const charts = this.paneManager.getAllCharts(); - ChartManager.handleResize(charts, containers); - }); - } - - async refresh() { - // Clear all charts and containers - const charts = this.paneManager.getAllCharts(); - charts.forEach(chart => chart.remove()); - - const containers = this.paneManager.getAllContainers(); - containers.forEach((container) => { - container.innerHTML = ''; - }); - - this.seriesMap = {}; - this.paneManager = null; - - await this.initialize(); + _onResize() { + if (!this.paneManager) return; + ChartManager.handleResize( + this.paneManager.getAllCharts(), + this.paneManager.getAllContainers(), + ); } } diff --git a/out/js/ChartManager.js b/out/js/ChartManager.js index 89c5b79..982513e 100644 --- a/out/js/ChartManager.js +++ b/out/js/ChartManager.js @@ -1,4 +1,3 @@ -/* Chart creation and series management (SRP) */ export class ChartManager { static createChart(container, config, chartOptions) { return LightweightCharts.createChart(container, { @@ -25,7 +24,8 @@ export class ChartManager { } static handleResize(charts, containers) { - const width = containers[0].clientWidth; - charts.forEach((chart) => chart.applyOptions({ width })); + charts.forEach((chart, i) => { + chart.resize(containers[i].clientWidth, containers[i].clientHeight); + }); } } diff --git a/out/js/ConfigLoader.js b/out/js/ConfigLoader.js index 93bc593..2eb5b59 100644 --- a/out/js/ConfigLoader.js +++ b/out/js/ConfigLoader.js @@ -1,23 +1,6 @@ -/* Config file loader for optional explicit pane overrides (SRP) */ export class ConfigLoader { - static async loadStrategyConfig(strategyName) { - try { - const configUrl = `${strategyName}.config`; - const response = await fetch(configUrl + '?' + Date.now()); - - if (!response.ok) { - return null; - } - - const config = await response.json(); - return config.indicators || null; - } catch (error) { - return null; - } - } - static async loadChartData(url = 'chart-data.json') { const response = await fetch(url + '?' + Date.now()); - return await response.json(); + return response.json(); } } diff --git a/out/js/FullscreenToggle.js b/out/js/FullscreenToggle.js new file mode 100644 index 0000000..7a349dc --- /dev/null +++ b/out/js/FullscreenToggle.js @@ -0,0 +1,92 @@ +/** + * Prefers the native Fullscreen API; falls back to a `chart-fullscreen` CSS class + * for browsers that do not support the API on non-video elements (e.g. iPhone Safari). + * + * `onResize` is called on every viewport change so the chart library can reflow. + * + * `mount()` is idempotent — safe to call on every render cycle; attaches exactly + * one button and one pair of `fullscreenchange` listeners for the lifetime of the instance. + */ +export class FullscreenToggle { + #container; + #button; + #onResize; + #usingApiFullscreen = false; + #mounted = false; + #boundHandler = () => this.#handleFullscreenChange(); + + constructor(container, onResize) { + this.#container = container; + this.#onResize = onResize; + this.#button = this.#createButton(); + } + + mount() { + if (this.#mounted) return; + this.#mounted = true; + this.#container.appendChild(this.#button); + document.addEventListener('fullscreenchange', this.#boundHandler); + document.addEventListener('webkitfullscreenchange', this.#boundHandler); + } + + #createButton() { + const btn = document.createElement('button'); + btn.className = 'fullscreen-toggle-btn'; + btn.textContent = '⤢'; + btn.title = 'Toggle fullscreen'; + btn.addEventListener('click', () => this.#toggle()); + return btn; + } + + #supportsFullscreenApi() { + return typeof this.#container.requestFullscreen === 'function' + || typeof this.#container.webkitRequestFullscreen === 'function'; + } + + #isFullscreen() { + return !!(document.fullscreenElement || document.webkitFullscreenElement) + || this.#container.classList.contains('chart-fullscreen'); + } + + #toggle() { + if (this.#isFullscreen()) { + this.#exit(); + } else { + this.#enter(); + } + } + + #enter() { + if (this.#supportsFullscreenApi()) { + this.#usingApiFullscreen = true; + (this.#container.requestFullscreen || this.#container.webkitRequestFullscreen) + .call(this.#container); + } else { + this.#container.classList.add('chart-fullscreen'); + this.#syncButton(); + this.#onResize(); + } + } + + #exit() { + if (this.#usingApiFullscreen) { + this.#usingApiFullscreen = false; + (document.exitFullscreen || document.webkitExitFullscreen).call(document); + } else { + this.#container.classList.remove('chart-fullscreen'); + this.#syncButton(); + this.#onResize(); + } + } + + #handleFullscreenChange() { + this.#syncButton(); + this.#onResize(); + } + + #syncButton() { + const active = this.#isFullscreen(); + this.#button.textContent = active ? '✕' : '⤢'; + this.#button.title = active ? 'Exit fullscreen' : 'Toggle fullscreen'; + } +} diff --git a/out/js/PaneAssigner.js b/out/js/PaneAssigner.js index 54ddaec..85ebc65 100644 --- a/out/js/PaneAssigner.js +++ b/out/js/PaneAssigner.js @@ -1,94 +1,50 @@ -/* Pane assignment logic based on value range analysis (SRP) */ export class PaneAssigner { constructor(candlestickData) { - this.candlestickRange = this.calculateCandlestickRange(candlestickData); + this.candlestickRange = this._candlestickRange(candlestickData); } - calculateCandlestickRange(candlestickData) { - if (!candlestickData || candlestickData.length === 0) { - return { min: 0, max: 0 }; + _candlestickRange(candlestickData) { + if (!candlestickData?.length) return { min: 0, max: 0 }; + let min = Infinity, max = -Infinity; + for (const { low, high } of candlestickData) { + if (low < min) min = low; + if (high > max) max = high; } - - let min = Infinity; - let max = -Infinity; - - candlestickData.forEach((candle) => { - if (candle.low < min) min = candle.low; - if (candle.high > max) max = candle.high; - }); - return { min, max }; } - calculateIndicatorRange(indicatorData) { - if (!indicatorData || indicatorData.length === 0) { - return { min: 0, max: 0 }; - } - - let min = Infinity; - let max = -Infinity; - let validCount = 0; - - indicatorData.forEach((point) => { - if (point.value !== null && point.value !== undefined && !isNaN(point.value) && point.value !== 0) { - if (point.value < min) min = point.value; - if (point.value > max) max = point.value; - validCount++; + _indicatorRange(indicatorData) { + if (!indicatorData?.length) return { min: 0, max: 0 }; + let min = Infinity, max = -Infinity, count = 0; + for (const { value } of indicatorData) { + if (value != null && !isNaN(value) && value !== 0) { + if (value < min) min = value; + if (value > max) max = value; + count++; } - }); - - if (validCount === 0) { - return { min: 0, max: 0 }; } - - return { min, max }; + return count === 0 ? { min: 0, max: 0 } : { min, max }; } - rangesOverlap(range1, range2, overlapThreshold = 0.3) { - const range1Span = range1.max - range1.min; - const range2Span = range2.max - range2.min; - - if (range1Span === 0 || range2Span === 0) return false; - - const overlapMin = Math.max(range1.min, range2.min); - const overlapMax = Math.min(range1.max, range2.max); - const overlapSpan = Math.max(0, overlapMax - overlapMin); - - const overlapRatio = overlapSpan / Math.min(range1Span, range2Span); - - return overlapRatio >= overlapThreshold; + _rangesOverlap(r1, r2, threshold = 0.3) { + const span1 = r1.max - r1.min; + const span2 = r2.max - r2.min; + if (span1 === 0 || span2 === 0) return false; + const overlapSpan = Math.max(0, Math.min(r1.max, r2.max) - Math.max(r1.min, r2.min)); + return overlapSpan / Math.min(span1, span2) >= threshold; } - assignPane(indicatorKey, indicator, configOverride = null) { - if (configOverride && configOverride[indicatorKey]) { - const override = configOverride[indicatorKey]; - // Handle both string ("indicator") and object ({pane: "indicator", ...}) - return typeof override === 'string' ? override : (override.pane || 'indicator'); - } - - if (indicator.pane && indicator.pane !== '') { - return indicator.pane; - } - - const indicatorRange = this.calculateIndicatorRange(indicator.data); - - if (this.rangesOverlap(this.candlestickRange, indicatorRange)) { - return 'main'; - } - - return 'indicator'; + _assignPane(indicator) { + if (indicator.pane) return indicator.pane; + const range = this._indicatorRange(indicator.data); + return this._rangesOverlap(this.candlestickRange, range) ? 'main' : 'indicator'; } - assignAllPanes(indicators, configOverride = null) { + assignAllPanes(indicators) { const result = {}; - - Object.entries(indicators).forEach(([key, indicator]) => { - result[key] = { - ...indicator, - pane: this.assignPane(key, indicator, configOverride), - }; - }); - + for (const [key, indicator] of Object.entries(indicators)) { + result[key] = { ...indicator, pane: this._assignPane(indicator) }; + } return result; } } diff --git a/out/js/PaneManager.js b/out/js/PaneManager.js index 8163fa2..7e103d3 100644 --- a/out/js/PaneManager.js +++ b/out/js/PaneManager.js @@ -1,4 +1,3 @@ -/* Multi-pane chart manager with time-scale synchronization (SRP) */ export class PaneManager { constructor(chartOptions) { this.chartOptions = chartOptions; @@ -7,6 +6,7 @@ export class PaneManager { } createMainPane(container, config) { + container.style.height = `${config.height}px`; this.mainPane = { container, chart: LightweightCharts.createChart(container, { @@ -22,7 +22,8 @@ export class PaneManager { const containerDiv = document.createElement('div'); containerDiv.id = `${paneName}-chart`; containerDiv.style.position = 'relative'; - containerDiv.style.zIndex = '1'; + containerDiv.style.zIndex = '1'; + containerDiv.style.height = `${config.height}px`; const chartContainerDiv = document.querySelector('.chart-container'); chartContainerDiv.appendChild(containerDiv); @@ -53,6 +54,12 @@ export class PaneManager { return containers; } + getAllPanes() { + const panes = [this.mainPane]; + this.dynamicPanes.forEach(pane => panes.push(pane)); + return panes; + } + synchronizeTimeScales() { const charts = this.getAllCharts(); let isUpdating = false; diff --git a/out/js/PaneResizeCalculator.js b/out/js/PaneResizeCalculator.js new file mode 100644 index 0000000..bd2ff9b --- /dev/null +++ b/out/js/PaneResizeCalculator.js @@ -0,0 +1,10 @@ +export const MIN_PANE_HEIGHT = 80; + +export class PaneResizeCalculator { + static calculate(aboveHeight, belowHeight, delta) { + const lo = MIN_PANE_HEIGHT - aboveHeight; + const hi = belowHeight - MIN_PANE_HEIGHT; + const clamped = Math.max(lo, Math.min(hi, delta)); + return { above: aboveHeight + clamped, below: belowHeight - clamped }; + } +} diff --git a/out/js/PaneResizeController.js b/out/js/PaneResizeController.js new file mode 100644 index 0000000..8da9ef8 --- /dev/null +++ b/out/js/PaneResizeController.js @@ -0,0 +1,25 @@ +import { PaneResizeHandle } from './PaneResizeHandle.js'; + +/** + * mount() — inserts N-1 handles between N panes; safe to call after destroy(). + * destroy() — removes all handles and their document listeners; safe to call + * before new panes are created (e.g. on refresh). + * + * Pane shape: { container: HTMLElement, chart: IChartApi } + */ +export class PaneResizeController { + #handles = []; + + mount(panes) { + for (let i = 0; i < panes.length - 1; i++) { + const handle = new PaneResizeHandle(panes[i], panes[i + 1]); + panes[i + 1].container.insertAdjacentElement('beforebegin', handle.element); + this.#handles.push(handle); + } + } + + destroy() { + this.#handles.forEach(h => h.destroy()); + this.#handles = []; + } +} diff --git a/out/js/PaneResizeHandle.js b/out/js/PaneResizeHandle.js new file mode 100644 index 0000000..28ae80a --- /dev/null +++ b/out/js/PaneResizeHandle.js @@ -0,0 +1,70 @@ +import { PaneResizeCalculator } from './PaneResizeCalculator.js'; + +/** + * Call destroy() when the associated panes are torn down to remove the element + * and deregister all document-level listeners. + * + * Pane shape: { container: HTMLElement, chart: IChartApi } + */ +export class PaneResizeHandle { + #above; + #below; + #element; + #drag = null; + + #onMouseMove = (e) => this.#move(e.clientY); + #onTouchMove = (e) => { if (this.#drag) { e.preventDefault(); this.#move(e.touches[0].clientY); } }; + #onDragEnd = () => { this.#drag = null; }; + + constructor(above, below) { + this.#above = above; + this.#below = below; + this.#element = this.#buildElement(); + + document.addEventListener('mousemove', this.#onMouseMove); + document.addEventListener('touchmove', this.#onTouchMove, { passive: false }); + document.addEventListener('mouseup', this.#onDragEnd); + document.addEventListener('touchend', this.#onDragEnd); + } + + get element() { return this.#element; } + + destroy() { + this.#element.remove(); + document.removeEventListener('mousemove', this.#onMouseMove); + document.removeEventListener('touchmove', this.#onTouchMove); + document.removeEventListener('mouseup', this.#onDragEnd); + document.removeEventListener('touchend', this.#onDragEnd); + } + + #buildElement() { + const el = document.createElement('div'); + el.className = 'pane-resize-handle'; + el.addEventListener('mousedown', (e) => this.#startDrag(e.clientY)); + el.addEventListener('touchstart', (e) => { e.preventDefault(); this.#startDrag(e.touches[0].clientY); }, { passive: false }); + return el; + } + + #startDrag(y) { + this.#drag = { + startY: y, + aboveH: this.#above.container.clientHeight, + belowH: this.#below.container.clientHeight, + }; + } + + #move(y) { + if (!this.#drag) return; + const delta = y - this.#drag.startY; + const { above, below } = PaneResizeCalculator.calculate( + this.#drag.aboveH, this.#drag.belowH, delta, + ); + this.#applyHeight(this.#above, above); + this.#applyHeight(this.#below, below); + } + + #applyHeight({ container, chart }, height) { + container.style.height = `${height}px`; + chart.resize(container.clientWidth, height); + } +} diff --git a/out/js/SortDirection.js b/out/js/SortDirection.js new file mode 100644 index 0000000..4fa7f45 --- /dev/null +++ b/out/js/SortDirection.js @@ -0,0 +1,4 @@ +export const SortDirection = Object.freeze({ + DESC: 'desc', + ASC: 'asc', +}); diff --git a/out/js/TradeMarkerBuilder.js b/out/js/TradeMarkerBuilder.js new file mode 100644 index 0000000..4bfd076 --- /dev/null +++ b/out/js/TradeMarkerBuilder.js @@ -0,0 +1,62 @@ +const DIRECTION_LONG_COLOR = '#26a69a'; +const DIRECTION_SHORT_COLOR = '#ef5350'; +const OUTCOME_PROFIT_COLOR = '#26a69a'; +const OUTCOME_LOSS_COLOR = '#ef5350'; + +export class TradeMarkerBuilder { + build(strategy, candlestickData) { + if (!strategy || !candlestickData?.length) return []; + + const markers = []; + + for (const trade of strategy.trades || []) { + const entryTime = this._resolveTime(trade.entryBar, trade.entryTime, candlestickData); + const exitTime = this._resolveTime(trade.exitBar, trade.exitTime, candlestickData); + if (entryTime !== null) markers.push(this._entryMarker(trade, entryTime)); + if (exitTime !== null) markers.push(this._exitMarker(trade, exitTime)); + } + + for (const trade of strategy.openTrades || []) { + const entryTime = this._resolveTime(trade.entryBar, trade.entryTime, candlestickData); + if (entryTime !== null) markers.push(this._entryMarker(trade, entryTime)); + } + + markers.sort((a, b) => a.time - b.time); + return markers; + } + + _resolveTime(barIndex, unixTime, candlestickData) { + if (barIndex >= 0 && barIndex < candlestickData.length) { + return candlestickData[barIndex].time; + } + return unixTime || null; + } + + _entryMarker(trade, time) { + const isLong = trade.direction === 'long'; + return { + time, + position: isLong ? 'belowBar' : 'aboveBar', + color: this._entryColor(trade), + shape: isLong ? 'arrowUp' : 'arrowDown', + }; + } + + _exitMarker(trade, time) { + const isLong = trade.direction === 'long'; + return { + time, + position: isLong ? 'aboveBar' : 'belowBar', + color: this._exitColor(trade), + shape: 'circle', + }; + } + + _entryColor(trade) { + return trade.direction === 'long' ? DIRECTION_LONG_COLOR : DIRECTION_SHORT_COLOR; + } + + _exitColor(trade) { + return trade.profit >= 0 ? OUTCOME_PROFIT_COLOR : OUTCOME_LOSS_COLOR; + } +} diff --git a/out/js/TradeRowData.js b/out/js/TradeRowData.js index 6cf778f..b6e7bcb 100644 --- a/out/js/TradeRowData.js +++ b/out/js/TradeRowData.js @@ -1,28 +1,23 @@ /** - * TradeRowData - Domain model for rowspan table rows - * - * SRP: Represents a single visual row (Entry or Exit) in the rowspan table - * Each trade produces TWO rows: one Entry row + one Exit row + * `isPrimary` marks the first row of the pair; only the primary row carries + * Direction[rowspan=2] and Size[rowspan=2] cells. + * P/L is always on the exit row regardless of pair position. */ export class TradeRowData { constructor(config) { this.tradeNumber = config.tradeNumber; - this.rowType = config.rowType; // 'entry' | 'exit' - this.dateTime = config.dateTime; - this.signal = config.signal; - this.price = config.price; - this.size = config.size; - this.profitLoss = config.profitLoss; - this.direction = config.direction; - this.isOpen = config.isOpen; - this.profitRaw = config.profitRaw; + this.rowType = config.rowType; + this.isPrimary = config.isPrimary; + this.dateTime = config.dateTime; + this.signal = config.signal; + this.price = config.price; + this.size = config.size; + this.profitLoss = config.profitLoss; + this.direction = config.direction; + this.isOpen = config.isOpen; + this.profitRaw = config.profitRaw; } - isEntryRow() { - return this.rowType === 'entry'; - } - - isExitRow() { - return this.rowType === 'exit'; - } + isEntryRow() { return this.rowType === 'entry'; } + isExitRow() { return this.rowType === 'exit'; } } diff --git a/out/js/TradeRowspanRenderer.js b/out/js/TradeRowspanRenderer.js index 0555cce..4fda2c9 100644 --- a/out/js/TradeRowspanRenderer.js +++ b/out/js/TradeRowspanRenderer.js @@ -1,51 +1,69 @@ /** - * TradeRowspanRenderer - Generates HTML for rowspan table structure - * - * SRP: Single responsibility - HTML generation for rowspan cells - * KISS: Simple row generation logic - * - * Rowspan Structure: - * Row 1 (Entry): Type[rowspan=2] | Entry Label | Entry Date | Entry Signal | Entry Price | Size[rowspan=2] | empty - * Row 2 (Exit): | Exit Label | Exit Date | Exit Signal | Exit Price | | P/L[rowspan=2] + * TradeRowspanRenderer — stateless HTML generator for the rowspan trade table. + * + * Layout contract (7 columns: Type | Entry/Exit | DateTime | Signal | Price | Size | P/L): + * + * Primary row (isPrimary=true): + * Type[rowspan=2] | label | DateTime | Signal | Price | Size[rowspan=2] | P/L (exit only) + * + * Secondary row (isPrimary=false): + * label | DateTime | Signal | Price | P/L (exit only) + * + * P/L is always and only on exit rows, regardless of pair position. + * `.trade-open` class is applied to the and the P/L of open exit rows. */ export class TradeRowspanRenderer { - constructor() {} + #directionClass(row) { + return row.direction === 'long' ? 'trade-long' : 'trade-short'; + } + + #profitClass(row) { + if (row.isOpen) return 'trade-open'; + return row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'; + } + + #label(row) { + return row.isEntryRow() ? 'Entry' : 'Exit'; + } + + #plCell(row) { + if (!row.isExitRow()) return ''; + return `${row.profitLoss}`; + } + + #primaryRowHtml(row) { + return [ + `${row.direction.toUpperCase()}`, + `${this.#label(row)}`, + `${row.dateTime}`, + `${row.signal}`, + `${row.price}`, + `${row.size}`, + this.#plCell(row), + ].join(''); + } + + #secondaryRowHtml(row) { + return [ + `${this.#label(row)}`, + `${row.dateTime}`, + `${row.signal}`, + `${row.price}`, + this.#plCell(row), + ].join(''); + } + + #trClass(row) { + return row.isExitRow() && row.isOpen ? ' class="trade-open"' : ''; + } - /** - * Render single TradeRowData as HTML row with rowspan cells - */ renderRow(row) { - const directionClass = row.direction === 'long' ? 'trade-long' : 'trade-short'; - const profitClass = row.isOpen - ? (row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative') - : (row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative'); - - let html = ''; - - if (row.isEntryRow()) { - // Entry row: Type (rowspan=2), Entry label, date, signal, price, Size (rowspan=2) - html += `${row.direction.toUpperCase()}`; - html += `Entry`; - html += `${row.dateTime}`; - html += `${row.signal}`; - html += `${row.price}`; - html += `${row.size}`; - } else { - // Exit row: Exit label, date, signal, price, P/L - html += `Exit`; - html += `${row.dateTime}`; - html += `${row.signal}`; - html += `${row.price}`; - html += `${row.profitLoss}`; - } - - html += ''; - return html; - } - - /** - * Render array of TradeRowData as complete HTML - */ + const cells = row.isPrimary + ? this.#primaryRowHtml(row) + : this.#secondaryRowHtml(row); + return `${cells}`; + } + renderRows(rows) { return rows.map(row => this.renderRow(row)).join('\n'); } diff --git a/out/js/TradeRowspanTransformer.js b/out/js/TradeRowspanTransformer.js index 8b0dc39..59cd487 100644 --- a/out/js/TradeRowspanTransformer.js +++ b/out/js/TradeRowspanTransformer.js @@ -1,72 +1,58 @@ -import { TradeRowData } from './TradeRowData.js'; +import { TradeRowData } from './TradeRowData.js'; +import { SortDirection } from './SortDirection.js'; -/** - * TradeRowspanTransformer - Transforms Trade objects into Entry/Exit row pairs - * - * SRP: Single responsibility - convert domain Trade to presentation TradeRowData pairs - * DRY: Reuses TradeDataFormatter for date/price formatting - * KISS: Simple transformation logic, no business logic - */ export class TradeRowspanTransformer { constructor(formatter) { this.formatter = formatter; } /** - * Transform single trade into [entryRow, exitRow] pair + * `isPrimary` on the first row drives rowspan-cell placement in the renderer. + * `size` is populated on both rows so the renderer always finds it on the primary. */ - transformTrade(trade, tradeNumber, currentPrice) { - const isOpen = trade.status === 'open'; + transformTrade(trade, tradeNumber, currentPrice, sortDirection = SortDirection.DESC) { + const isOpen = trade.status === 'open'; const unrealizedProfit = this.formatter.calculateUnrealizedProfit(trade, currentPrice); - - const exitPrice = isOpen - ? (currentPrice !== null && currentPrice !== undefined ? currentPrice : trade.entryPrice) - : (trade.exitPrice !== null && trade.exitPrice !== undefined ? trade.exitPrice : 0); + const exitPrice = isOpen ? (currentPrice ?? trade.entryPrice) : (trade.exitPrice ?? 0); + const profitValue = isOpen ? unrealizedProfit : trade.profit; + const sizeFormatted = trade.size.toFixed(2); - const profitValue = isOpen ? unrealizedProfit : trade.profit; - const formattedProfit = this.formatter.formatProfit(profitValue); - - // Entry row const entryRow = new TradeRowData({ - tradeNumber: tradeNumber, - rowType: 'entry', - dateTime: this.formatter.getTradeDate(trade, true), - signal: trade.entryComment || trade.EntryComment || '', - price: this.formatter.formatPrice(trade.entryPrice), - size: trade.size.toFixed(2), + tradeNumber, + rowType: 'entry', + isPrimary: sortDirection === SortDirection.ASC, + dateTime: this.formatter.getTradeDate(trade, true), + signal: trade.entryComment || trade.entryId || '', + price: this.formatter.formatPrice(trade.entryPrice), + size: sizeFormatted, profitLoss: '', - direction: trade.direction, - isOpen: false, - profitRaw: 0, + direction: trade.direction, + isOpen: false, + profitRaw: 0, }); - // Exit row const exitRow = new TradeRowData({ - tradeNumber: tradeNumber, - rowType: 'exit', - dateTime: isOpen ? 'Open' : this.formatter.getTradeDate(trade, false), - signal: isOpen ? '' : (trade.exitComment || trade.ExitComment || ''), - price: this.formatter.formatPrice(exitPrice), - size: '', - profitLoss: formattedProfit, - direction: trade.direction, - isOpen: isOpen, - profitRaw: profitValue, + tradeNumber, + rowType: 'exit', + isPrimary: sortDirection === SortDirection.DESC, + dateTime: isOpen ? 'Open' : this.formatter.getTradeDate(trade, false), + signal: isOpen ? '' : (trade.exitComment || trade.exitId || ''), + price: this.formatter.formatPrice(exitPrice), + size: sizeFormatted, + profitLoss: this.formatter.formatProfit(profitValue), + direction: trade.direction, + isOpen, + profitRaw: profitValue, }); - return [entryRow, exitRow]; + return sortDirection === SortDirection.ASC + ? [entryRow, exitRow] + : [exitRow, entryRow]; } - /** - * Transform array of trades into flat array of TradeRowData - * [trade1, trade2] → [trade1_entry, trade1_exit, trade2_entry, trade2_exit] - */ - transformTrades(trades, currentPrice) { - const rows = []; - trades.forEach((trade, index) => { - const [entryRow, exitRow] = this.transformTrade(trade, index + 1, currentPrice); - rows.push(entryRow, exitRow); - }); - return rows; + transformTrades(trades, currentPrice, sortDirection = SortDirection.DESC) { + return trades.flatMap((trade, index) => + this.transformTrade(trade, index + 1, currentPrice, sortDirection) + ); } } diff --git a/out/js/TradeSortToggle.js b/out/js/TradeSortToggle.js new file mode 100644 index 0000000..03b217b --- /dev/null +++ b/out/js/TradeSortToggle.js @@ -0,0 +1,41 @@ +import { SortDirection } from './SortDirection.js'; + +export class TradeSortToggle { + #direction; + #onChange; + #button = null; + + constructor(onChange, initialDirection = SortDirection.DESC) { + this.#direction = initialDirection; + this.#onChange = onChange; + } + + get direction() { + return this.#direction; + } + + mount(headerEl, hasAnyTrades) { + if (!this.#button) { + this.#button = document.createElement('button'); + this.#button.className = 'sort-toggle-btn'; + this.#button.addEventListener('click', () => this.#toggle()); + headerEl.appendChild(this.#button); + } + this.#button.style.display = hasAnyTrades ? '' : 'none'; + this.#syncLabel(); + } + + #toggle() { + this.#direction = this.#direction === SortDirection.DESC + ? SortDirection.ASC + : SortDirection.DESC; + this.#syncLabel(); + this.#onChange(this.#direction); + } + + #syncLabel() { + if (!this.#button) return; + const arrow = this.#direction === SortDirection.DESC ? '▼' : '▲'; + this.#button.textContent = `${arrow} ${this.#direction === SortDirection.DESC ? 'Newest first' : 'Oldest first'}`; + } +} diff --git a/out/js/TradeTable.js b/out/js/TradeTable.js index 6033304..da41985 100644 --- a/out/js/TradeTable.js +++ b/out/js/TradeTable.js @@ -1,12 +1,21 @@ -/* Trade data formatting (SRP, DRY) */ +export function formatCurrency(value) { + if (value == null || !isFinite(value)) return '$0.00'; + if (value === 0) return '$0.00'; + const abs = value < 0 ? -value : value; + const formatted = abs.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return value > 0 ? '+$' + formatted : '-$' + formatted; +} + export class TradeDataFormatter { constructor(candlestickData) { this.candlestickData = candlestickData || []; } formatDate(timestamp) { - const date = new Date(timestamp); - return date.toLocaleString('en-US', { + return new Date(timestamp).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', @@ -16,32 +25,30 @@ export class TradeDataFormatter { } formatPrice(price) { - if (price === null || price === undefined) return '$0.00'; - return `$${price.toFixed(2)}`; + if (price == null) return '$0.00'; + return '$' + price.toFixed(2); } formatProfit(profit) { - const formatted = `$${Math.abs(profit).toFixed(2)}`; - return profit >= 0 ? `+${formatted}` : `-${formatted}`; + return formatCurrency(profit); } getTradeDate(trade, isEntry = true) { const timeField = isEntry ? 'entryTime' : 'exitTime'; const barField = isEntry ? 'entryBar' : 'exitBar'; - + if (trade[timeField]) { return this.formatDate(trade[timeField] * 1000); } - + const barIndex = trade[barField]; if (barIndex !== undefined && barIndex >= 0 && barIndex < this.candlestickData.length) { const bar = this.candlestickData[barIndex]; - if (bar && bar.time !== undefined) { - const timestamp = bar.time * 1000; - return this.formatDate(timestamp); + if (bar?.time !== undefined) { + return this.formatDate(bar.time * 1000); } } - + return 'N/A'; } @@ -50,69 +57,4 @@ export class TradeDataFormatter { const multiplier = trade.direction === 'long' ? 1 : -1; return (currentPrice - trade.entryPrice) * trade.size * multiplier; } - - formatTrade(trade, index, currentPrice) { - const isOpen = trade.status === 'open'; - const unrealizedProfit = this.calculateUnrealizedProfit(trade, currentPrice); - - const exitPrice = isOpen - ? (currentPrice !== null && currentPrice !== undefined ? currentPrice : trade.entryPrice) - : (trade.exitPrice !== null && trade.exitPrice !== undefined ? trade.exitPrice : 0); - - return { - number: index + 1, - entryDate: this.getTradeDate(trade, true), - entryBar: trade.entryBar !== undefined ? trade.entryBar : 'N/A', - exitDate: isOpen ? 'Open' : this.getTradeDate(trade, false), - exitBar: isOpen ? '-' : (trade.exitBar !== undefined ? trade.exitBar : 'N/A'), - direction: trade.direction, - entryPrice: this.formatPrice(trade.entryPrice), - exitPrice: this.formatPrice(exitPrice), - size: trade.size.toFixed(2), - profit: isOpen ? this.formatProfit(unrealizedProfit) : this.formatProfit(trade.profit), - profitRaw: isOpen ? unrealizedProfit : trade.profit, - entryId: trade.entryId || trade.entryID || 'N/A', - isOpen: isOpen, - }; - } -} - -/* Trade table HTML renderer (SRP, KISS) */ -export class TradeTableRenderer { - constructor(formatter) { - this.formatter = formatter; - } - - renderRows(trades, currentPrice) { - return trades - .map((trade, index) => { - const formatted = this.formatter.formatTrade(trade, index, currentPrice); - const directionClass = - formatted.direction === 'long' ? 'trade-long' : 'trade-short'; - const profitClass = formatted.isOpen - ? formatted.profitRaw >= 0 - ? 'trade-profit-positive' - : 'trade-profit-negative' - : formatted.profitRaw >= 0 - ? 'trade-profit-positive' - : 'trade-profit-negative'; - - return ` - - ${formatted.number} - ${formatted.entryDate} - ${formatted.entryBar} - ${formatted.direction.toUpperCase()} - ${formatted.entryPrice} - ${formatted.exitDate} - ${formatted.exitBar} - ${formatted.exitPrice} - ${formatted.size} - ${formatted.profit} - ${formatted.entryId} - - `; - }) - .join(''); - } } diff --git a/out/js/WindowResizeHandler.js b/out/js/WindowResizeHandler.js new file mode 100644 index 0000000..c4d5436 --- /dev/null +++ b/out/js/WindowResizeHandler.js @@ -0,0 +1,12 @@ +export class WindowResizeHandler { + #handler; + + constructor(onResize) { + this.#handler = onResize; + window.addEventListener('resize', this.#handler); + } + + destroy() { + window.removeEventListener('resize', this.#handler); + } +} diff --git a/out/package.json b/out/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/out/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/out/tests/ChartManager.test.js b/out/tests/ChartManager.test.js new file mode 100644 index 0000000..2ca1b66 --- /dev/null +++ b/out/tests/ChartManager.test.js @@ -0,0 +1,190 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ChartManager } from '../js/ChartManager.js'; + +// ── Stubs ────────────────────────────────────────────────────────────────────── + +function makeChartSpy() { + const resizeCalls = []; + const fitCalls = []; + return { + resize: (w, h) => resizeCalls.push({ w, h }), + timeScale: () => ({ fitContent: () => fitCalls.push(1) }), + _resizeCalls: resizeCalls, + _fitCalls: fitCalls, + }; +} + +function makeContainer(clientWidth, clientHeight) { + return { clientWidth, clientHeight }; +} + +function stubLightweightCharts() { + const calls = []; + const chartStub = {}; + global.LightweightCharts = { + createChart: (container, options) => { calls.push({ container, options }); return chartStub; }, + }; + return { calls, chartStub }; +} + +// ── ChartManager.createChart — option composition ───────────────────────────── +// +// createChart spreads chartOptions then unconditionally overrides height and +// width from config and container respectively. Non-conflicting chartOptions +// keys must pass through intact. + +describe('createChart() — height comes from config, never from chartOptions', () => { + it('config.height is used as the height option', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, {}); + assert.equal(calls[0].options.height, 400); + }); + + it('config.height overrides a conflicting height key in chartOptions', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, { height: 9999 }); + assert.equal(calls[0].options.height, 400); + }); +}); + +describe('createChart() — width comes from container.clientWidth, never from chartOptions', () => { + it('container.clientWidth is used as the width option', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 1200 }, { height: 400 }, {}); + assert.equal(calls[0].options.width, 1200); + }); + + it('container.clientWidth overrides a conflicting width key in chartOptions', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 1200 }, { height: 400 }, { width: 1 }); + assert.equal(calls[0].options.width, 1200); + }); +}); + +describe('createChart() — chartOptions pass-through and LightweightCharts delegation', () => { + it('non-conflicting chartOptions properties reach LightweightCharts.createChart', () => { + const { calls } = stubLightweightCharts(); + ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, { layout: { background: '#000' } }); + assert.deepEqual(calls[0].options.layout, { background: '#000' }); + }); + + it('passes the container element as the first argument to LightweightCharts.createChart', () => { + const { calls } = stubLightweightCharts(); + const container = { clientWidth: 800 }; + ChartManager.createChart(container, { height: 400 }, {}); + assert.equal(calls[0].container, container); + }); + + it('returns the chart instance from LightweightCharts.createChart', () => { + const { chartStub } = stubLightweightCharts(); + const result = ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, {}); + assert.equal(result, chartStub); + }); +}); + +// ── ChartManager.fitContent — timescale fit delegation ──────────────────────── +// +// fitContent calls timeScale().fitContent() on every chart exactly once per +// invocation. The implementation must iterate all charts, not only the first. + +describe('fitContent() — zero charts: no error', () => { + it('empty chart list produces no error', () => { + assert.doesNotThrow(() => ChartManager.fitContent([])); + }); +}); + +describe('fitContent() — timeScale().fitContent() called exactly once per chart', () => { + const chartCounts = [1, 2, 5]; + + for (const n of chartCounts) { + it(`${n} chart(s): each receives exactly one fitContent call`, () => { + const charts = Array.from({ length: n }, makeChartSpy); + ChartManager.fitContent(charts); + charts.forEach((chart, i) => { + assert.equal(chart._fitCalls.length, 1, `chart[${i}] fitContent call count`); + }); + }); + } +}); + +// ── ChartManager.handleResize — per-container resize delegation ─────────────── +// +// handleResize reads clientWidth and clientHeight from each chart's own +// container and calls chart.resize(width, height) exactly once per chart per +// invocation. Width and height must come from the same container with no +// cross-contamination between panes. + +describe('handleResize() — zero charts: no error', () => { + it('empty chart and container lists produce no error', () => { + assert.doesNotThrow(() => ChartManager.handleResize([], [])); + }); +}); + +describe('handleResize() — each chart receives its own container dimensions', () => { + const resizeCases = [ + { + label: '1 pane (main only)', + containers: [makeContainer(800, 400)], + }, + { + label: '2 panes (main + 1 indicator)', + containers: [makeContainer(1024, 600), makeContainer(1024, 200)], + }, + { + label: '5 panes — each with a distinct width and height', + containers: [ + makeContainer(320, 568), + makeContainer(768, 1024), + makeContainer(1440, 900), + makeContainer(2560, 1440), + makeContainer(3840, 2160), + ], + }, + ]; + + for (const { label, containers } of resizeCases) { + it(`${label}: resize(clientWidth, clientHeight) called once per chart with matching dimensions`, () => { + const charts = Array.from({ length: containers.length }, makeChartSpy); + ChartManager.handleResize(charts, containers); + charts.forEach((chart, i) => { + assert.deepEqual( + chart._resizeCalls, + [{ w: containers[i].clientWidth, h: containers[i].clientHeight }], + `chart[${i}]`, + ); + }); + }); + } +}); + +describe('handleResize() — reads container state at call time', () => { + it('successive calls each propagate the current container dimensions', () => { + const chart = makeChartSpy(); + const container = makeContainer(800, 400); + + ChartManager.handleResize([chart], [container]); + + container.clientHeight = 700; + ChartManager.handleResize([chart], [container]); + + assert.deepEqual(chart._resizeCalls, [ + { w: 800, h: 400 }, + { w: 800, h: 700 }, + ]); + }); +}); + +describe('handleResize() — boundary dimensions', () => { + it('zero width and height are propagated faithfully (collapsed or hidden pane)', () => { + const chart = makeChartSpy(); + ChartManager.handleResize([chart], [makeContainer(0, 0)]); + assert.deepEqual(chart._resizeCalls, [{ w: 0, h: 0 }]); + }); + + it('fractional sub-pixel HiDPI dimensions are propagated without rounding', () => { + const chart = makeChartSpy(); + ChartManager.handleResize([chart], [makeContainer(1920.5, 1080.5)]); + assert.deepEqual(chart._resizeCalls, [{ w: 1920.5, h: 1080.5 }]); + }); +}); diff --git a/out/tests/DesignSystem.test.js b/out/tests/DesignSystem.test.js new file mode 100644 index 0000000..39033d4 --- /dev/null +++ b/out/tests/DesignSystem.test.js @@ -0,0 +1,201 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +// ── File readers ────────────────────────────────────────────────────────────── +// +// Files are read once at module scope; all test bodies reference the cached +// strings. A missing file throws at load time, which fails the suite clearly. + +const tokensCSS = readFileSync(new URL('../css/tokens.css', import.meta.url), 'utf8'); +const stylesCSS = readFileSync(new URL('../css/styles.css', import.meta.url), 'utf8'); +const indexHTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + +// ── CSS parsers ─────────────────────────────────────────────────────────────── +// +// Pure functions over CSS text. Each extracts one kind of information so +// tests can compose them without re-implementing the same regex in every it(). + +function stripComments(css) { + return css.replace(/\/\*[\s\S]*?\*\//g, ''); +} + +function declaredTokensIn(css) { + return new Set(stripComments(css).match(/--[\w-]+(?=\s*:)/g) ?? []); +} + +function referencedTokensIn(css) { + return new Set( + [...stripComments(css).matchAll(/var\(\s*(--[\w-]+)/g)].map(m => m[1]), + ); +} + +function hexColorsIn(css) { + return stripComments(css).match(/#[0-9a-fA-F]{3,8}\b/g) ?? []; +} + +function rgbLiteralsIn(css) { + return stripComments(css).match(/rgba?\s*\(/g) ?? []; +} + +function importPathsIn(css) { + return [...css.matchAll(/@import\s+['"]([^'"]+)['"]/g)].map(m => m[1]); +} + +const declaredTokens = declaredTokensIn(tokensCSS); +const referencedTokens = referencedTokensIn(stylesCSS); + +// ── tokens.css — file structure ─────────────────────────────────────────────── +// +// Tokens file must be non-empty and contain the :root declaration block that +// makes the custom properties available to the entire document. + +describe('tokens.css — file is non-empty', () => { + it('file has content', () => { + assert.ok(tokensCSS.trim().length > 0); + }); +}); + +describe('tokens.css — :root block', () => { + it(':root block is present', () => { + assert.ok(stripComments(tokensCSS).includes(':root')); + }); + + it(':root block has an opening brace', () => { + assert.match(stripComments(tokensCSS), /:root\s*\{/); + }); +}); + +describe('tokens.css — token naming convention', () => { + it('at least one custom property is declared', () => { + assert.ok(declaredTokens.size > 0); + }); + + it('every declared name starts with -- (CSS custom property)', () => { + for (const token of declaredTokens) { + assert.ok(token.startsWith('--'), `'${token}' does not start with '--'`); + } + }); +}); + +// ── styles.css — file structure ─────────────────────────────────────────────── +// +// styles.css must be non-empty, must explicitly @import tokens.css so the +// dependency is visible in code, and must not re-declare the :root block +// (tokens live only in tokens.css). + +describe('styles.css — file is non-empty', () => { + it('file has content', () => { + assert.ok(stylesCSS.trim().length > 0); + }); +}); + +describe('styles.css — @import dependency declaration', () => { + it("@imports exactly one file", () => { + assert.equal(importPathsIn(stylesCSS).length, 1); + }); + + it("@imports './tokens.css'", () => { + assert.ok( + importPathsIn(stylesCSS).includes('./tokens.css'), + `imports found: ${importPathsIn(stylesCSS).join(', ')}`, + ); + }); + + it('@import is the first non-whitespace statement', () => { + assert.match(stylesCSS.trimStart(), /^@import/); + }); +}); + +describe('styles.css — no :root block (tokens live only in tokens.css)', () => { + it(':root is absent from the component stylesheet', () => { + assert.ok(!stripComments(stylesCSS).includes(':root')); + }); +}); + +// ── styles.css — single source of truth for color values ───────────────────── +// +// Every color must be defined in tokens.css and consumed via var(). A +// hardcoded color literal in styles.css means the token system has been +// bypassed and a future design change requires editing both files. + +describe('styles.css — no hardcoded hex color literals', () => { + it('zero hex color patterns after comment stripping', () => { + const found = hexColorsIn(stylesCSS); + assert.deepEqual( + found, + [], + `hardcoded hex colors found: ${found.join(', ')}`, + ); + }); +}); + +describe('styles.css — no hardcoded rgb() / rgba() literals', () => { + it('zero rgb/rgba patterns after comment stripping', () => { + const found = rgbLiteralsIn(stylesCSS); + assert.deepEqual( + found, + [], + `hardcoded rgb/rgba literals found: ${found.join(', ')}`, + ); + }); +}); + +// ── styles.css → tokens.css — variable resolution ──────────────────────────── +// +// Every var(--x) reference in styles.css must have a matching declaration +// in tokens.css. An unresolved reference silently falls back to the +// browser default (typically empty string), producing invisible breakage. + +describe('styles.css — every var() reference resolves to a declared token', () => { + for (const token of referencedTokens) { + it(`var(${token}) is declared in tokens.css`, () => { + assert.ok( + declaredTokens.has(token), + `'${token}' is referenced in styles.css but not declared in tokens.css`, + ); + }); + } +}); + +// ── tokens.css — no dead tokens ─────────────────────────────────────────────── +// +// Every declared token must be consumed by styles.css. Dead tokens add noise +// to the token surface, mislead future editors, and never survive rotation of +// the design. + +describe('tokens.css — every declared token is consumed by styles.css', () => { + for (const token of declaredTokens) { + it(`${token} is referenced in styles.css`, () => { + assert.ok( + referencedTokens.has(token), + `'${token}' is declared in tokens.css but never referenced in styles.css`, + ); + }); + } +}); + +// ── index.html — stylesheet integration ────────────────────────────────────── +// +// The page must load styles through the external stylesheet, never through an +// inline