diff --git a/.gitignore b/.gitignore index f81008f..f299544 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,7 @@ dist/ .pnp.* # Generated files +chart-data.json out/chart-data.json out/chart-config.json out/e2e-*-output.json @@ -230,3 +231,10 @@ testdata/*-output.json # Contribot contribot.*.json transcripts/ + +.playwright-cli +# Editor and tool artifacts +*.backup + +# Claude Code agent artifacts +.claude/scheduled_tasks.lock 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 ` -``` - -**Works on:** Chrome, Firefox, Safari, Edge (99%+ browser support) -**Device support:** Desktop, mobile, tablets -**Installation required:** **NONE** - runs directly in browser - -### **Node.js (One Command)** -```javascript -const fs = require('fs'); -const wasmBuffer = fs.readFileSync('strategy.wasm'); -WebAssembly.instantiate(wasmBuffer).then(wasm => { - const result = wasm.instance.exports.runStrategy(marketData); -}); -``` - -**Installation:** `npm install` (Node.js has built-in WASM support) - -### **Native Apps (Embedded Runtime)** -- **Electron/Tauri apps:** Direct WASM execution -- **Mobile (React Native):** Via WASM runtime -- **Desktop apps:** Embed WASM runtime (wasmer, wasmtime) - ---- - -## **Performance Comparison** - -| Approach | Speed | Consumer Device | Installation | -|----------|-------|-----------------|--------------| -| **Current Python** | 2500ms | ❌ Requires Python 3.12 | Complex | -| **Current Node.js** | 250ms | ⚠️ Requires Node.js | Medium | -| **Go WASM** | ~50ms | ✅ Browser built-in | **NONE** | -| **Rust WASM** | ~30ms | ✅ Browser built-in | **NONE** | - ---- - -## **Distribution Model** - -### **Option 1: Pure Browser Strategy** -1. User loads webpage -2. Downloads `strategy.wasm` (50-500KB typical size) -3. Executes instantly in browser -4. **Zero installation, zero dependencies** - -### **Option 2: npm Package** -```bash -npm install your-strategy-runner -``` -```javascript -import { runStrategy } from 'your-strategy-runner'; -const result = await runStrategy('strategy.wasm', marketData); -``` - -### **Option 3: Standalone Binary** (Go) -```bash -# Compile for all platforms -GOOS=windows GOARCH=amd64 go build -o strategy.exe -GOOS=darwin GOARCH=amd64 go build -o strategy-mac -GOOS=linux GOARCH=amd64 go build -o strategy-linux -``` -**Download and run** - no runtime required. - ---- - -## **WASM Size Optimization** - -**Go WASM** (unoptimized): ~2-5MB -**Go WASM** (with wasm-opt): ~500KB-1MB -**Rust WASM** (optimized): ~100-300KB - -**Optimization command:** -```bash -wasm-opt -Oz strategy.wasm -o strategy-optimized.wasm -``` - ---- - -## **Security Model** - -WASM runs in **sandboxed environment**: -- ✅ No file system access (unless explicitly imported) -- ✅ No network access (unless explicitly imported) -- ✅ Memory isolated from host -- ✅ Perfect for distributing trading strategies - -Users can run **precompiled strategies** without exposing source code. - ---- - -## **Recommended Path for Your Project** - -### **Phase 1: Go WASM (Immediate - 1-2 weeks)** - -Your golang-port already has parser + runtime. Add WASM target: - -```bash -cd golang-port -GOOS=js GOARCH=wasm go build -o ../out/strategy.wasm cmd/pinescript-go/main.go -``` - -Create simple HTML runner: -```html - - -``` - -**Result:** Browser-executable strategy with **zero installation**. - -### **Phase 2: Rust WASM (Future - 8-12 weeks)** - -For optimal performance (~30ms) and smallest binary size (~200KB). - ---- - -## **Bottom Line** - -### ✅ **WASM Publishing: TRIVIALLY EASY** -- Go WASM: Built-in, single command -- Rust WASM: Built-in, single command - -### ✅ **Consumer Device Execution: ZERO FRICTION** -- Modern browsers: Built-in WASM support (no installation) -- Node.js: Built-in WASM support -- Mobile/Desktop: Via browser or embedded runtime - -### ✅ **Your Project Status** -You have **Go binaries already compiled** in bin. Adding WASM target is **literally changing two environment variables**. - -**Verdict:** WASM publishing is **production-ready** and **consumer-friendly**. Your Go port makes this **immediate** (1-2 weeks to add web runner). - -# Give me hyperlink to an Online demo of wasm I could try right away on my mobile device - -## 🎮 **Interactive WASM Demos** - -### **1. Figma (Real Production App)** -**https://www.figma.com** -- Entire design tool runs on WASM -- Works perfectly on mobile browsers -- Real-world production WASM app with millions of users - -### **2. Google Earth** -**https://earth.google.com/web/** -- 3D globe rendering via WASM -- Works on mobile Safari/Chrome -- Impressive performance demonstration - -### **3. Photopea (Photoshop Clone)** -**https://www.photopea.com** -- Full image editor in browser (WASM) -- Mobile-responsive -- No installation needed - -### **4. Squoosh (Image Compression)** -**https://squoosh.app** -- Google's image compression tool -- WASM-powered codecs -- Works perfectly on mobile - -### **5. WASM Game Demos** -**https://wasmboy.app** -- Game Boy emulator in WASM -- Works on mobile browsers -- Upload ROM and play instantly - -### **6. FFmpeg in Browser** -**https://ffmpegwasm.netlify.app** -- Video processing via WASM -- Mobile compatible (though resource-intensive) -- Zero installation video editing - -### **7. Python in Browser (Pyodide)** -**https://pyodide.org/en/stable/console.html** -- Full Python interpreter (WASM) -- Run Python code on mobile -- Scientific computing in browser - -### **8. WebAssembly Studio** -**https://webassembly.studio** -- Online WASM development environment -- Compile C/Rust to WASM -- Run immediately in browser - ---- - -## 🚀 **Try This One First (Most Impressive)** - -**Squoosh** - https://squoosh.app -- Open on mobile -- Upload/take photo -- Watch real-time WASM image processing -- See compression happen instantly - -**Why it's impressive:** -- Complex image codecs (WebP, AVIF, MozJPEG) running at native speed -- No server processing - all WASM in your browser -- Works offline after first load - ---- - -All these work with **zero installation** on any modern mobile browser (Safari, Chrome, Firefox). - -# Can WASM run let's say React frontends or PixiJs viewports? - -**Yes, but with important distinctions:** - -## **React + WASM: Indirect Relationship** - -### ❌ **React itself does NOT run in WASM** -React runs as **normal JavaScript** in the browser's JS engine (V8/SpiderMonkey/JavaScriptCore). - -### ✅ **React CAN call WASM modules** -```jsx -import { runStrategy } from './strategy.wasm'; - -function TradingDashboard() { - const [result, setResult] = useState(null); - - useEffect(() => { - // React (JS) calls WASM function - const strategyOutput = runStrategy(marketData); - setResult(strategyOutput); - }, [marketData]); - - return ; // React renders -} -``` - -**Architecture:** -- React UI/rendering → **JavaScript** -- Heavy computation (strategy execution) → **WASM** -- React displays results from WASM - ---- - -## **PixiJS + WASM: Similar Pattern** - -### ❌ **PixiJS does NOT run in WASM** -PixiJS is a **JavaScript library** using WebGL for rendering. - -### ✅ **PixiJS CAN visualize WASM-computed data** -```javascript -import * as PIXI from 'pixi.js'; -import { calculateIndicators } from './strategy.wasm'; - -const app = new PIXI.Application(); // PixiJS (JS) - -// WASM computes indicator values -const indicators = calculateIndicators(marketData); - -// PixiJS renders the computed data -indicators.forEach((point, i) => { - const circle = new PIXI.Graphics(); - circle.beginFill(0x00FF00); - circle.drawCircle(i * 10, point.value, 5); - app.stage.addChild(circle); -}); -``` - ---- - -## **Real-World WASM + Frontend Examples** - -### **1. Figma (Production App)** -- **UI Framework:** Custom JS framework -- **WASM Role:** - - Vector rendering engine (C++) - - Layout calculations - - File parsing -- **Result:** Near-native performance in browser - -### **2. Photopea (Photoshop Clone)** -- **UI:** JavaScript/HTML -- **WASM Role:** - - Image processing algorithms (C++) - - Filter calculations - - Format encoding/decoding - -### **3. Google Earth** -- **UI:** JavaScript + WebGL -- **WASM Role:** - - 3D terrain rendering - - Geometry processing - - Data decompression - ---- - -## **For Your PineScript Project** - -### **Recommended Architecture:** - -``` -┌─────────────────────────────────────────┐ -│ React Frontend (JavaScript) │ -│ ├─ Chart rendering (PixiJS/Canvas) │ -│ ├─ UI controls │ -│ └─ State management │ -└──────────────┬──────────────────────────┘ - │ (function calls) - v -┌──────────────┴──────────────────────────┐ -│ PineScript Strategy (WASM) │ ← Go/Rust compiled -│ ├─ Market data processing │ -│ ├─ Indicator calculations │ -│ ├─ Strategy logic │ -│ └─ Trade signals │ -└──────────────┬──────────────────────────┘ - │ (returns data) - v -┌──────────────┴──────────────────────────┐ -│ React updates UI with results │ -│ PixiJS renders charts │ -└─────────────────────────────────────────┘ -``` - -### **Code Example:** - -```javascript -// strategy.wasm exports: runStrategy(bars: Array) -> signals -import init, { runStrategy } from './pinescript_strategy.wasm'; - -function TradingChart({ symbol, timeframe }) { - const [signals, setSignals] = useState([]); - - useEffect(() => { - async function loadStrategy() { - // Initialize WASM module - await init(); - - // Fetch market data (JS) - const bars = await fetch(`/api/bars/${symbol}/${timeframe}`); - - // Execute strategy in WASM (fast computation) - const result = runStrategy(bars); - - setSignals(result.signals); - } - - loadStrategy(); - }, [symbol, timeframe]); - - return ( - - ); -} -``` - ---- - -## **Performance Split** - -| Task | Best Runtime | Reason | -|------|--------------|---------| -| **UI Rendering** | JavaScript | Native DOM/WebGL APIs | -| **Chart Drawing** | JavaScript (PixiJS/Canvas) | WebGL is already native | -| **Strategy Logic** | **WASM** | CPU-intensive computation | -| **Indicator Calculations** | **WASM** | Mathematical operations | -| **Event Handling** | JavaScript | Browser event system | -| **State Management** | JavaScript | React ecosystem | - ---- - -## **Bottom Line** - -- **React/PixiJS stay in JavaScript** (they're designed for it) -- **WASM handles computation** (strategy execution, indicators) -- **They communicate via function calls** (JS ↔ WASM is seamless) - -**Your Go port compiled to WASM** would be the **computation engine**, while React/PixiJS would be the **presentation layer**. - -This is the **optimal architecture** - each technology doing what it does best. - -# How about Preact? - -**Preact is even better for WASM integration** - same patterns as React, but with significant advantages: - -## **Preact + WASM: Optimal Combination** - -### ✅ **Why Preact is Superior for WASM Projects** - -| Feature | React | Preact | Impact on WASM | -|---------|-------|--------|----------------| -| **Bundle Size** | ~130KB (minified) | **3KB** (minified) | Faster initial load | -| **WASM Module Size** | No impact | No impact | Same | -| **Total Bundle** | 130KB + WASM | **3KB + WASM** | 97% smaller JS | -| **Memory Usage** | Higher | **Lower** | More for WASM | -| **API** | Full React API | React-compatible | Same code | -| **Performance** | Fast | **Faster** | Better with WASM | - ---- - -## **Preact + WASM Code (Identical to React)** - -```javascript -import { h, render } from 'preact'; -import { useState, useEffect } from 'preact/hooks'; -import init, { runStrategy } from './strategy.wasm'; - -function TradingChart({ symbol }) { - const [signals, setSignals] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function executeStrategy() { - // Initialize WASM module - await init(); - - // Fetch market data - const response = await fetch(`/api/bars/${symbol}`); - const bars = await response.json(); - - // Run strategy in WASM (fast) - const result = runStrategy(bars); - - setSignals(result); - setLoading(false); - } - - executeStrategy(); - }, [symbol]); - - if (loading) return
Loading strategy...
; - - return ( -
-

Strategy Signals for {symbol}

- - {signals.map((signal, i) => ( -
- {signal.type}: {signal.price} @ {signal.time} -
- ))} -
- ); -} - -render(, document.body); -``` - -**Code is 99% identical to React** - you can even use `preact/compat` for 100% React compatibility. - ---- - -## **Real-World Preact + WASM Examples** - -### **1. Uber's m.uber.com** -- Preact for UI -- WASM for map rendering optimizations -- 3x faster than their React version - -### **2. Preact + WebAssembly Games** -- UI: Preact (3KB) -- Game engine: WASM (Rust/C++) -- Total bundle: ~500KB vs React's ~1MB+ - ---- - -## **Your PineScript Project with Preact** - -### **Bundle Size Comparison:** - -``` -React + WASM Strategy: -├─ React: 130KB -├─ React-DOM: 40KB -├─ Chart Library: 50KB -├─ Strategy WASM: 500KB -└─ Total: ~720KB - -Preact + WASM Strategy: -├─ Preact: 3KB ✅ -├─ Preact hooks: 1KB ✅ -├─ Chart Library: 50KB -├─ Strategy WASM: 500KB -└─ Total: ~554KB (23% smaller) -``` - ---- - -## **Preact + PixiJS + WASM Architecture** - -```javascript -import { h, render } from 'preact'; -import { useEffect, useRef } from 'preact/hooks'; -import * as PIXI from 'pixi.js'; -import init, { runStrategy } from './strategy.wasm'; - -function TradingDashboard() { - const canvasRef = useRef(null); - - useEffect(() => { - let app; - - async function setupChart() { - // Initialize WASM - await init(); - - // Setup PixiJS - app = new PIXI.Application({ - view: canvasRef.current, - width: 1200, - height: 600, - backgroundColor: 0x1a1a1a, - }); - - // Fetch data - const bars = await fetchMarketData('BTCUSDT', '1h'); - - // Run strategy in WASM (fast computation) - const { indicators, signals } = runStrategy(bars); - - // Render with PixiJS (fast rendering) - renderCandlesticks(app, bars); - renderIndicators(app, indicators); - renderSignals(app, signals); - } - - setupChart(); - - return () => app?.destroy(); - }, []); - - return ( -
- -
- {/* Preact UI controls */} -
-
- ); -} - -render(, document.getElementById('app')); -``` - ---- - -## **Performance Benchmark (Mobile Device)** - -### **iPhone 13 Safari:** - -| Stack | Initial Load | Strategy Execution | UI Render | Total | -|-------|--------------|-------------------|-----------|-------| -| **React + JS Strategy** | 800ms | 2500ms | 150ms | **3450ms** | -| **React + WASM Strategy** | 800ms | 50ms | 150ms | **1000ms** | -| **Preact + WASM Strategy** | **300ms** | 50ms | 100ms | **450ms** | - -**Preact + WASM is 7.7x faster than React + JS** - ---- - -## **Migration: React → Preact** - -### **Option 1: Drop-in Replacement (Zero Code Changes)** - -```javascript -// vite.config.js or webpack.config.js -export default { - resolve: { - alias: { - 'react': 'preact/compat', - 'react-dom': 'preact/compat' - } - } -} -``` - -**All your React code works unchanged.** - -### **Option 2: Native Preact (Optimal)** - -```diff -- import React, { useState } from 'react'; -+ import { h } from 'preact'; -+ import { useState } from 'preact/hooks'; - -- import ReactDOM from 'react-dom'; -+ import { render } from 'preact'; - -- ReactDOM.render(, document.getElementById('root')); -+ render(, document.getElementById('root')); -``` - ---- - -## **Recommended Stack for Your Project** - -``` -┌─────────────────────────────────────────┐ -│ Preact (3KB) - UI Framework │ -│ ├─ Trading dashboard │ -│ ├─ Chart controls │ -│ └─ Signal display │ -└──────────────┬──────────────────────────┘ - │ - v -┌──────────────┴──────────────────────────┐ -│ PixiJS (50KB) - Chart Rendering │ -│ ├─ Candlesticks │ -│ ├─ Indicators overlay │ -│ └─ WebGL acceleration │ -└──────────────┬──────────────────────────┘ - │ - v -┌──────────────┴──────────────────────────┐ -│ Go WASM (500KB) - Strategy Engine │ ← Your golang-port -│ ├─ PineScript execution │ -│ ├─ Indicator calculations │ -│ └─ Signal generation │ -└─────────────────────────────────────────┘ - -Total: ~553KB (vs React: ~720KB) -Load time: 300ms (vs React: 800ms) -``` - ---- - -## **Bottom Line** - -**Preact is the better choice when using WASM because:** - -1. **Smaller bundle** → WASM loads faster (less JS to parse) -2. **Lower memory** → More RAM for WASM computations -3. **Faster rendering** → Better responsiveness with WASM results -4. **React-compatible** → Can use React tutorials/libraries -5. **Mobile-optimized** → Perfect for your "consumer device" requirement - -**Your Go WASM strategy would work identically with both React and Preact**, but Preact gives you better performance for free. \ No newline at end of file diff --git a/e2e/fixtures/strategies/test-security-daily-sma-comparison.pine b/e2e/fixtures/strategies/test-security-daily-sma-comparison.pine new file mode 100644 index 0000000..549c346 --- /dev/null +++ b/e2e/fixtures/strategies/test-security-daily-sma-comparison.pine @@ -0,0 +1,12 @@ +//@version=4 +strategy("Security Daily SMA Comparison", overlay=true) + +sma_1d_20 = security(syminfo.tickerid, 'D', sma(close, 20)) +sma_1d_50 = security(syminfo.tickerid, 'D', sma(close, 50)) +sma_1d_200 = security(syminfo.tickerid, 'D', sma(close, 200)) + +plot(sma_1d_20, "SMA20_1D") +plot(sma_1d_50, "SMA50_1D") +plot(sma_1d_200, "SMA200_1D") +plot(sma_1d_20 > sma_1d_50 ? 1.0 : 0.0, "Bull20_50_1D") +plot(sma_1d_50 > sma_1d_200 ? 1.0 : 0.0, "Bull50_200_1D") diff --git a/out/css/styles.css b/out/css/styles.css new file mode 100644 index 0000000..a626c27 --- /dev/null +++ b/out/css/styles.css @@ -0,0 +1,270 @@ +@import './tokens.css'; + +/* ── Reset ──────────────────────────────────────────────────────────────── */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Base ────────────────────────────────────────────────────────────────── */ + +body { + font-family: var(--font-ui); + background: linear-gradient(135deg, var(--surface-base) 0%, var(--surface-raised) 100%); + color: var(--text-primary); + padding: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +/* ── Layout ──────────────────────────────────────────────────────────────── */ + +#container { + width: 100%; + max-width: 90vw; + margin: 0 auto; + padding: 1.5rem; + background-color: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-container); +} + +/* ── Typography ──────────────────────────────────────────────────────────── */ + +h1 { + font-size: 2.5rem; + margin-bottom: 2rem; + text-align: center; + text-shadow: var(--shadow-text); + background: linear-gradient(45deg, var(--color-accent), var(--color-accent-alt)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* ── Info card ───────────────────────────────────────────────────────────── */ + +.info { + background-color: var(--surface-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 1rem; + margin-bottom: 1rem; + color: var(--text-primary); +} + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ + +.refresh-btn { + background-color: var(--color-action); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--radius-md); + font-size: 1rem; + cursor: pointer; + margin-bottom: 1rem; + transition: background-color var(--transition-duration); +} + +.refresh-btn:hover { background-color: var(--color-action-hover); } +.refresh-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } +.refresh-btn:active { transform: scale(0.97); } + +.sort-toggle-btn { + background: transparent; + border: 1px solid var(--border-default); + color: var(--text-secondary); + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + cursor: pointer; + transition: border-color var(--transition-duration), color var(--transition-duration); +} + +.sort-toggle-btn:hover { border-color: var(--color-accent); color: var(--color-accent); } +.sort-toggle-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } +.sort-toggle-btn:active { transform: scale(0.97); } + +.fullscreen-toggle-btn { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + background: var(--surface-raised-alpha); + border: 1px solid var(--border-default); + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: var(--radius-md); + font-size: 1rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color var(--transition-duration), color var(--transition-duration); +} + +.fullscreen-toggle-btn:hover { border-color: var(--color-accent); color: var(--color-accent); } +.fullscreen-toggle-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } +.fullscreen-toggle-btn:active { transform: scale(0.97); } + +/* ── Chart container ─────────────────────────────────────────────────────── */ + +.chart-container { + position: relative; + background-color: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + overflow: hidden; + margin-bottom: 1rem; +} + +.chart-container > div { margin-bottom: 8px; } +.chart-container > div:last-child { margin-bottom: 0; } + +/* ── Fullscreen states ───────────────────────────────────────────────────── */ + +.chart-container:fullscreen, +.chart-container:-webkit-full-screen, +.chart-container.chart-fullscreen { + background: var(--surface-raised); + overflow-y: auto; + padding: 0; + border-radius: 0; + border: none; + display: flex; + flex-direction: column; + height: 100vh; +} + +.chart-container:fullscreen > #main-chart, +.chart-container:-webkit-full-screen > #main-chart, +.chart-container.chart-fullscreen > #main-chart { + flex: 1 1 auto; + min-height: 200px; +} + +.chart-container:fullscreen > div:not(#main-chart), +.chart-container:-webkit-full-screen > div:not(#main-chart), +.chart-container.chart-fullscreen > div:not(#main-chart) { + flex: 0 0 auto; +} + +/* ── Pane resize handle ──────────────────────────────────────────────────── */ + +.pane-resize-handle { + height: 4px; + cursor: row-resize; + background: transparent; + user-select: none; + flex-shrink: 0; + transition: background var(--transition-duration); +} + +.pane-resize-handle:hover { background: var(--color-accent-subtle); } +.chart-container > .pane-resize-handle { margin: 0; } + +/* ── Timestamp ───────────────────────────────────────────────────────────── */ + +.timestamp { + text-align: center; + font-size: 0.875rem; + color: var(--text-muted); + margin-top: 1rem; +} + +/* ── Trades section ──────────────────────────────────────────────────────── */ + +.trades-section { + margin-top: 2rem; + background-color: var(--surface-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 1rem; +} + +.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); +} + +.trades-header h2 { + color: var(--color-accent); + font-size: 1.5rem; + margin: 0; +} + +.trades-summary { + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* ── Trades table ────────────────────────────────────────────────────────── */ + +.trades-table-container { + overflow-x: auto; +} + +.trades-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.trades-table th { + background-color: var(--surface-base); + color: var(--text-secondary); + padding: 0.75rem; + text-align: left; + font-weight: 600; + border-bottom: 2px solid var(--border-default); +} + +.trades-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} + +.trades-table tbody tr:hover { + background-color: var(--surface-hover); +} + +.trades-table tbody tr:nth-child(4n+1):not(:only-child), +.trades-table tbody tr:nth-child(4n+2):not(:only-child) { + background-color: var(--stripe-tint); +} + +/* ── Trade row states ────────────────────────────────────────────────────── */ + +.trade-long { color: var(--color-long); } +.trade-short { color: var(--color-short); } + +.trade-profit-positive { color: var(--color-long); font-weight: 600; } +.trade-profit-negative { color: var(--color-short); font-weight: 600; } + +.trade-open { + color: var(--color-open); + font-weight: 600; + font-style: italic; +} + +.no-trades { + text-align: center; + padding: 2rem; + color: var(--text-muted); + font-style: italic; +} diff --git a/out/css/tokens.css b/out/css/tokens.css new file mode 100644 index 0000000..2820608 --- /dev/null +++ b/out/css/tokens.css @@ -0,0 +1,48 @@ +:root { + /* ── Surfaces ──────────────────────────────────────────────────────────── */ + --surface-base: #0f172a; + --surface-card: #1a2035; + --surface-raised: #1e293b; + --surface-raised-alpha: rgba(30, 41, 59, 0.85); + --surface-hover: rgba(255, 255, 255, 0.06); + + /* ── Borders ───────────────────────────────────────────────────────────── */ + --border-subtle: #2d3748; + --border-default: #334155; + + /* ── Text ──────────────────────────────────────────────────────────────── */ + --text-primary: #cbd5e1; + --text-secondary: #94a3b8; + --text-muted: #6b7280; + + /* ── Accent ────────────────────────────────────────────────────────────── */ + --color-accent: #5eead4; + --color-accent-alt: #2dd4bf; + --color-accent-subtle: rgba(94, 234, 212, 0.25); + + /* ── Actions ───────────────────────────────────────────────────────────── */ + --color-action: #2563eb; + --color-action-hover: #1d4ed8; + + /* ── Semantic ──────────────────────────────────────────────────────────── */ + --color-long: #10b981; + --color-short: #ef4444; + --color-open: #3b82f6; + + /* ── Trade table ───────────────────────────────────────────────────────── */ + --stripe-tint: rgba(255, 255, 255, 0.03); + + /* ── Typography ────────────────────────────────────────────────────────── */ + --font-ui: 'Segoe UI', system-ui, sans-serif; + + /* ── Border radius ─────────────────────────────────────────────────────── */ + --radius-md: 6px; + --radius-lg: 8px; + + /* ── Shadows ───────────────────────────────────────────────────────────── */ + --shadow-container: 0 4px 6px -1px rgba(0, 0, 0, 0.2); + --shadow-text: 0 2px 4px rgba(0, 0, 0, 0.3); + + /* ── Transitions ───────────────────────────────────────────────────────── */ + --transition-duration: 0.2s; +} diff --git a/out/index-bak.html b/out/index-bak.html deleted file mode 100644 index 642e0fd..0000000 --- a/out/index-bak.html +++ /dev/null @@ -1,840 +0,0 @@ - - - - Financial Chart Visualization - - - - - - -
-

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..d2a323f --- /dev/null +++ b/out/js/FullscreenToggle.js @@ -0,0 +1,94 @@ +/** + * 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.setAttribute('aria-pressed', 'false'); + 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'; + this.#button.setAttribute('aria-pressed', String(active)); + } +} 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..8a9b9ad --- /dev/null +++ b/out/js/TradeSortToggle.js @@ -0,0 +1,43 @@ +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 isAscending = this.#direction === SortDirection.ASC; + const arrow = isAscending ? '▲' : '▼'; + this.#button.textContent = `${arrow} ${isAscending ? 'Oldest first' : 'Newest first'}`; + this.#button.setAttribute('aria-pressed', String(isAscending)); + } +} 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